Pathfinding con lucchetto e chiave?


22

Sto lavorando a un gioco con mappe che assomigliano a blocchi e puzzle chiave . L'intelligenza artificiale deve raggiungere un obiettivo che potrebbe essere dietro una porta rossa bloccata, ma la chiave rossa potrebbe essere dietro una porta blu bloccata e così via ...

Questo puzzle è simile a un sotterraneo in stile Zelda, come questa immagine:

Sotterraneo Zelda

Per raggiungere l'obiettivo, devi sconfiggere il Boss, che richiede di andare oltre la fossa, che richiede la raccolta della Piuma, che richiede la raccolta della Chiave

I sotterranei di Zelda tendono ad essere lineari. Tuttavia, devo risolvere il problema nel caso generale. Così:

  • L'obiettivo potrebbe richiedere uno di un set di chiavi. Quindi forse hai bisogno di ottenere il tasto rosso o il tasto blu. Oppure potrebbe esserci una porta aperta a grande distanza!
  • Potrebbero esserci più porte e chiavi di un tipo. Ad esempio, potrebbero esserci più chiavi rosse nella mappa e raccoglierne una consentirà l'accesso a tutte le porte rosse.
  • L'obiettivo potrebbe essere inaccessibile perché le chiavi giuste si trovano dietro le porte chiuse

Come eseguirò l'individuazione del percorso su tale mappa? Come sarebbe il grafico di ricerca?

Nota: l'ultimo punto sulla rilevazione di obiettivi inaccessibili è importante; Un *, ad esempio, è estremamente inefficiente se l'obiettivo è inaccessibile. Vorrei affrontarlo in modo efficiente.

Supponiamo che l'IA sappia dove è tutto sulla mappa.


4
L'IA conosce e scopre le cose solo dopo averle sbloccate? Ad esempio, sa che la piuma è dietro la porta chiusa a chiave? L'intelligenza artificiale comprende concetti come "Questa è una serratura, quindi ho bisogno di una chiave" o è qualcosa di più semplice come "Ho qualcosa che mi blocca la strada, quindi prova tutte le cose che ho trovato. Piuma sulla porta? No. Chiave sulla porta? Sì! "
Tim Holt,

1
C'era qualche discussione precedente su questo problema in questa domanda sul pathfinding in avanti o indietro , che potrebbe esserti utile.
DMGregory

1
Quindi non stai cercando di simulare un giocatore, ma stai cercando di creare una corsa sotterranea ottimizzata? La mia risposta era sicuramente sulla simulazione del comportamento di un giocatore.
Tim Holt,

4
Sfortunatamente rilevare un obiettivo inaccessibile è piuttosto difficile. L'unico modo per essere sicuri che non ci sia modo di raggiungere l'obiettivo è quello di esplorare l'intero spazio raggiungibile per assicurarsi che nessuno di essi contenga un obiettivo - che è esattamente ciò che fa A * che lo fa fare così tanti passi in più se l'obiettivo è inaccessibile. Qualsiasi algoritmo che ricerca meno spazio rischia di perdere un percorso disponibile per l'obiettivo perché il percorso si nascondeva in una parte dello spazio che ha ignorato la ricerca. Puoi accelerarlo lavorando a un livello superiore, cercando il grafico delle connessioni della stanza invece di ogni piastrella o poligono di navmesh.
DMGregory

1
Offtopic, ho istintivamente pensato a Chip's Challenge anziché a Zelda :)
Flater,

Risposte:


22

Il percorso standard è abbastanza buono : i tuoi stati sono la tua posizione corrente + il tuo inventario attuale. "traslocare" significa cambiare stanza o cambiare inventario. Non trattato in questa risposta, ma non troppo sforzo aggiuntivo, sta scrivendo una buona euristica per A *: può davvero accelerare la ricerca preferendo raccogliere le cose piuttosto che allontanarsi da essa, preferendo sbloccare una porta vicino al bersaglio a cercare molto, ecc.

Questa risposta ha ottenuto molti voti da quando è arrivata prima e ha una demo, ma per una soluzione molto più ottimizzata e specializzata, dovresti anche leggere la risposta "Farlo indietro è molto più veloce" /gamedev/ / a / 150155/2624


Di seguito il concetto di Javascript pienamente operativo. Ci scusiamo per la risposta come un dump del codice: in realtà lo avevo implementato prima di essere convinto che fosse una buona risposta, ma mi sembra abbastanza flessibile.

Per iniziare quando pensi al pathfinding, ricorda che l'erarchia dei semplici algoritmi di path path è:

  • Breadth First Search è il più semplice possibile.
  • L'algoritmo di Djikstra è come Breadth First Search ma con "distanze" variabili tra gli stati
  • Un * è Djikstras in cui hai un 'senso generale della giusta direzione' disponibile come euristico.

Nel nostro caso, la semplice codifica di uno "stato" come "posizione + inventario" e "distanze" come "movimento o utilizzo dell'oggetto" ci consente di utilizzare Djikstra o A * per risolvere il nostro problema.

Ecco un codice reale che dimostra il tuo livello di esempio. Il primo frammento è solo per confronto: passa alla seconda parte se vuoi vedere la soluzione finale. Iniziamo con un'implementazione di Djikstra che trova il percorso corretto, ma abbiamo ignorato tutti gli ostacoli e le chiavi. (Provalo, puoi vederlo solo prima del traguardo, dalla stanza 0 -> 2 -> 3-> 4-> 6-> 5)

function Transition(cost, state) { this.cost = cost, this.state = state; }
// given a current room, return a room of next rooms we can go to. it costs 
// 1 action to move to another room.
function next(n) {
    var moves = []
    // simulate moving to a room
    var move = room => new Transition(1, room)
    if (n == 0) moves.push(move(2))
    else if ( n == 1) moves.push(move(2))
    else if ( n == 2) moves.push(move(0), move(1), move(3))
    else if ( n == 3) moves.push(move(2), move(4), move(6))
    else if ( n == 4) moves.push(move(3))
    else if ( n == 5) moves.push(move(6))
    else if ( n == 6) moves.push(move(5), move(3))
    return moves
}

// Standard Djikstra's algorithm. keep a list of visited and unvisited nodes
// and iteratively find the "cheapest" next node to visit.
function calc_Djikstra(cost, goal, history, nextStates, visited) {

    if (!nextStates.length) return ['did not find goal', history]

    var action = nextStates.pop()
    cost += action.cost
    var cur = action.state

    if (cur == goal) return ['found!', history.concat([cur])]
    if (history.length > 15) return ['we got lost', history]

    var notVisited = (visit) => {
        return visited.filter(v => JSON.stringify(v) == JSON.stringify(visit.state)).length === 0;
    };
    nextStates = nextStates.concat(next(cur).filter(notVisited))
    nextStates.sort()

    visited.push(cur)
    return calc_Djikstra(cost, goal, history.concat([cur]), nextStates, visited)
}

console.log(calc_Djikstra(0, 5, [], [new Transition(0, 0)], []))

Quindi, come possiamo aggiungere elementi e chiavi a questo codice? Semplice! invece di ogni "stato" inizia solo il numero della stanza, ora è una tupla della stanza e il nostro stato di inventario:

 // Now, each state is a [room, haskey, hasfeather, killedboss] tuple
function State(room, k, f, b) { this.room = room; this.k = k; this.f = f; this.b = b }

Le transizioni ora cambiano da tupla (costo, stanza) a tupla (costo, stato), quindi possono codificare sia "spostarsi in un'altra stanza" sia "raccogliere un oggetto"

// move(3) keeps inventory but sets the room to 3
var move = room => new Transition(1, new State(room, cur.k, cur.f, cur.b))
// pickup("k") keeps room number but increments the key count
var pickup = (cost, item) => {
    var n = Object.assign({}, cur)
    n[item]++;
    return new Transition(cost, new State(cur.room, n.k, n.f, n.b));
};

infine, apportiamo alcune modifiche minori relative al tipo alla funzione Djikstra (ad esempio, continua a corrispondere al numero di una goal room anziché a uno stato completo) e otteniamo la nostra risposta completa! Nota che il risultato stampato va prima nella stanza 4 per ritirare la chiave, quindi nella stanza 1 per raccogliere la piuma, quindi va nella stanza 6, uccide il boss, quindi va nella stanza 5)

// Now, each state is a [room, haskey, hasfeather, killedboss] tuple
function State(room, k, f, b) { this.room = room; this.k = k; this.f = f; this.b = b }
function Transition(cost, state, msg) { this.cost = cost, this.state = state; this.msg = msg; }

function next(cur) {
var moves = []
// simulate moving to a room
var n = cur.room
var move = room => new Transition(1, new State(room, cur.k, cur.f, cur.b), "move to " + room)
var pickup = (cost, item) => {
	var n = Object.assign({}, cur)
	n[item]++;
	return new Transition(cost, new State(cur.room, n.k, n.f, n.b), {
		"k": "pick up key",
		"f": "pick up feather",
		"b": "SLAY BOSS!!!!"}[item]);
};

if (n == 0) moves.push(move(2))
else if ( n == 1) { }
else if ( n == 2) moves.push(move(0), move(3))
else if ( n == 3) moves.push(move(2), move(4))
else if ( n == 4) moves.push(move(3))
else if ( n == 5) { }
else if ( n == 6) { }

// if we have a key, then we can move between rooms 1 and 2
if (cur.k && n == 1) moves.push(move(2));
if (cur.k && n == 2) moves.push(move(1));

// if we have a feather, then we can move between rooms 3 and 6
if (cur.f && n == 3) moves.push(move(6));
if (cur.f && n == 6) moves.push(move(3));

// if killed the boss, then we can move between rooms 5 and 6
if (cur.b && n == 5) moves.push(move(6));
if (cur.b && n == 6) moves.push(move(5));

if (n == 4 && !cur.k) moves.push(pickup(0, 'k'))
if (n == 1 && !cur.f) moves.push(pickup(0, 'f'))
if (n == 6 && !cur.b) moves.push(pickup(100, 'b'))	
return moves
}

var notVisited = (visitedList) => (visit) => {
return visitedList.filter(v => JSON.stringify(v) == JSON.stringify(visit.state)).length === 0;
};

// Standard Djikstra's algorithm. keep a list of visited and unvisited nodes
// and iteratively find the "cheapest" next node to visit.
function calc_Djikstra(cost, goal, history, nextStates, visited) {

if (!nextStates.length) return ['No path exists', history]

var action = nextStates.pop()
cost += action.cost
var cur = action.state

if (cur.room == goal) return history.concat([action.msg])
if (history.length > 15) return ['we got lost', history]

nextStates = nextStates.concat(next(cur).filter(notVisited(visited)))
nextStates.sort()

visited.push(cur)
return calc_Djikstra(cost, goal, history.concat([action.msg]), nextStates, visited)
o}

console.log(calc_Djikstra(0, 5, [], [new Transition(0, new State(0, 0, 0, 0), 'start')], []))

In teoria, questo funziona anche con BFS e non abbiamo avuto bisogno della funzione di costo per Djikstra, ma avere il costo ci consente di dire "raccogliere una chiave è senza sforzo, ma combattere un boss è davvero difficile e preferiremmo tornare indietro 100 passi anziché combattere il capo, se potessimo scegliere ":

if (n == 4 && !cur.k) moves.push(pickup(0, 'k'))
if (n == 1 && !cur.f) moves.push(pickup(0, 'f'))
if (n == 6 && !cur.b) moves.push(pickup(100, 'b'))

Sì, includendo lo stato di inventario / chiave nel grafico di ricerca è una soluzione. Sono preoccupato per i maggiori requisiti di spazio: una mappa con 4 chiavi richiede 16 volte lo spazio di un grafico senza chiavi.
congusbongus,

8
@congusbongus benvenuto nel problema del venditore ambulante completo di NP. Non esiste una soluzione generale che lo risolva in tempi polinomiali.
maniaco del cricchetto

1
@congusbongus Non credo in generale che il tuo grafico di ricerca sarà così tanto sovraccarico, ma se sei preoccupato per lo spazio, impacca solo i tuoi dati - potresti usare 24 bit per l'indicatore della stanza (16 milioni di stanze dovrebbero essere sufficiente per chiunque) e un po 'ciascuno per gli oggetti che ti interessano da usare come cancelli (fino a 8 unici). Se vuoi essere sofisticato, puoi usare le dipendenze per comprimere gli oggetti in bit ancora più piccoli, cioè usare lo stesso bit per "chiave" e "capo" poiché c'è una spesa transitiva indiretta
Jimmy,

@Jimmy Anche se non è personale, apprezzo la menzione della mia risposta :)
Jibb Smart

13

A * indietro farà il trucco

Come discusso in questa risposta a una domanda sulla ricerca del percorso in avanti o indietro , la ricerca del percorso indietro è una soluzione relativamente semplice a questo problema. Funziona in modo molto simile a GOAP (Goal Oriented Action Planning), pianificando soluzioni efficienti riducendo al minimo le meraviglie senza scopo.

In fondo a questa risposta ho una ripartizione di come gestisce l'esempio che hai dato.

In dettaglio

Pathfind dalla destinazione all'inizio. Se, nel tuo percorso, ti imbatti in una porta chiusa, hai un nuovo ramo nel tuo percorso che continua attraverso la porta come se fosse sbloccato, con il ramo principale che continua a cercare un altro percorso. Il ramo che continua attraverso la porta come se fosse sbloccato non sta più cercando l'agente AI - ora sta cercando una chiave che può usare per passare attraverso la porta. Con A *, la sua nuova euristica è la distanza dal tasto + la distanza dall'agente di intelligenza artificiale, anziché solo la distanza dall'agente di intelligenza artificiale.

Se il ramo della porta sbloccata trova la chiave, continua a cercare l'agente AI.

Questa soluzione è resa un po 'più complicata quando sono disponibili più chiavi valide, ma è possibile diramare di conseguenza. Poiché i rami hanno una destinazione fissa, ti consente comunque di utilizzare un'euristica per ottimizzare il percorso (A *) e si spera che i percorsi impossibili vengano tagliati rapidamente - se non c'è modo di aggirare la porta chiusa, il ramo che non passare attraverso la porta esaurisce rapidamente le opzioni e il ramo che passa attraverso la porta e cerca la chiave continua da solo.

Ovviamente, dove sono disponibili una varietà di opzioni praticabili (chiavi multiple, altri oggetti per aggirare la porta, lungo percorso attorno alla porta), molti rami saranno mantenuti, influenzando le prestazioni. Ma troverai anche l'opzione più veloce e potrai usarla.


In azione

Nel tuo esempio specifico, l'individuazione del percorso dall'obiettivo all'inizio:

  1. Incontriamo rapidamente una porta del boss. Il ramo A continua attraverso la porta, ora alla ricerca di un capo da combattere. Il ramo B rimane bloccato nella stanza e scadrà presto quando scopre che non c'è via d'uscita.

  2. Il ramo A trova il boss e ora sta cercando la partenza, ma incontra una buca.

  3. Il ramo A continua sopra la fossa, ma ora sta cercando la piuma e farà di conseguenza una linea d'api verso la piuma. Viene creato il ramo C che tenta di trovare un modo per aggirare la fossa, ma scade non appena non è possibile. Questo, o viene ignorato per un po ', se il tuo euristico A * scopre che il ramo A sembra ancora più promettente.

  4. Il ramo A incontra la porta chiusa a chiave e continua attraverso la porta chiusa come se fosse sbloccata, ma ora sta cercando la chiave. Il ramo D continua anche attraverso la porta chiusa a chiave, cercando ancora la piuma, ma poi cercherà la chiave. Questo perché non sappiamo se dobbiamo prima trovare la chiave o la piuma, e per quanto riguarda l'individuazione del percorso, la Partenza potrebbe essere dall'altra parte di questa porta. Il ramo E cerca di trovare un modo per aggirare la porta chiusa a chiave e fallisce.

  5. Il ramo D trova rapidamente la piuma e continua a cercare la chiave. È autorizzato a passare di nuovo attraverso la porta chiusa a chiave, poiché sta ancora cercando la chiave (e sta procedendo a ritroso nel tempo). Ma una volta che ha la chiave, non sarà in grado di passare attraverso la porta chiusa (dal momento che non potrebbe passare attraverso la porta chiusa prima di aver trovato la chiave).

  6. I rami A e D continuano a competere, ma quando il ramo A raggiunge la chiave, cerca la piuma e non riuscirà a raggiungere la piuma perché deve passare di nuovo attraverso la porta chiusa a chiave. Il ramo D, d'altra parte, quando raggiunge la chiave, rivolge la sua attenzione all'inizio e la trova senza complicazioni.

  7. Il ramo D vince. Ha trovato il percorso inverso. Il percorso finale è: Start -> Chiave -> Piuma -> Boss -> Obiettivo.


6

Modifica : è scritto dal punto di vista di un'intelligenza artificiale che è fuori per esplorare e scoprire un obiettivo e non conosce in anticipo la posizione di chiavi, blocchi o destinazioni.

In primo luogo, supponiamo che l'IA abbia una sorta di obiettivo generale. Ad esempio "Trova il capo" nel tuo esempio. Sì, vuoi batterlo, ma in realtà si tratta di trovarlo. Supponiamo che non abbia idea di come raggiungere l'obiettivo, solo che esiste. E lo saprà quando lo troverà. Una volta raggiunto l'obiettivo, l'IA può smettere di funzionare per risolvere il problema.

Inoltre, userò qui il termine generico "lucchetto" e "chiave", anche se potrebbe essere un abisso e una piuma. Cioè, piuma "sblocca" l'abisso "blocco".

Approccio alla soluzione

Sembra che tu inizi per primo con solo un'intelligenza artificiale che era fondamentalmente un esploratore di labirinti (se pensi alla tua mappa come a un labirinto). Esplorare e mappare tutti i luoghi in cui può andare sarebbe l'obiettivo principale dell'IA. Potrebbe basarsi semplicemente su qualcosa di semplice come "Vai sempre al percorso più vicino che ho visto ma non ancora visitato."

Tuttavia, durante l'esplorazione potrebbero entrare in gioco alcune regole che potrebbero cambiare la priorità ...

  • Ci vorrebbe qualsiasi chiave trovata, a meno che non avesse già la stessa chiave
  • Se trovasse un lucchetto che non aveva mai visto prima, proverebbe ogni chiave trovata su quel lucchetto
  • Se una chiave funzionasse su un nuovo tipo di blocco, ricorderebbe il tipo di chiave e il tipo di blocco
  • Se trovasse un lucchetto che aveva visto prima e aveva la chiave, avrebbe usato il tipo di chiave ricordato (ad esempio, trovato un secondo lucchetto rosso, il tasto rosso funzionava prima sul lucchetto rosso, quindi basta usare il tasto rosso)
  • Ricorderebbe la posizione di qualsiasi blocco che non poteva sbloccare
  • Non avrebbe bisogno di ricordare la posizione dei blocchi che aveva sbloccato
  • Ogni volta che trovava una chiave e sapeva di eventuali blocchi precedentemente sbloccabili, visitava immediatamente ciascuno di quei blocchi bloccati e cercava di sbloccarli con la nuova chiave trovata
  • Ogni volta che sbloccasse un percorso, tornerebbe semplicemente all'obiettivo di esplorazione e mappatura, dando la priorità all'entrata nella nuova area

Una nota su quest'ultimo punto. Se deve scegliere tra andare a controllare un'area inesplorata vista prima (ma non visitata) rispetto a un'area inesplorata dietro un percorso appena sbloccato, dovrebbe rendere prioritario il percorso appena sbloccato. Questo è probabilmente dove ci sono nuove chiavi (o blocchi) che saranno utili. Ciò presuppone che un percorso bloccato probabilmente non sarà un vicolo cieco inutile.

Espansione dell'idea con chiavi "bloccabili"

Potresti potenzialmente avere chiavi che non possono essere prese senza un'altra chiave. O chiavi bloccate per così dire. Se conosci le tue vecchie grotte colossali, devi avere la gabbia per uccelli per catturare l'uccello, che ti servirà in seguito per un serpente. Quindi "sblocchi" l'uccello con la gabbia (che non blocca il percorso ma non può essere raccolto senza la gabbia), quindi "sblocca" il serpente (che blocca il tuo percorso) con l'uccello.

Quindi aggiungendo alcune regole ...

  • Se una chiave non può essere presa (è bloccata), prova tutte le chiavi che hai già su di essa
  • Se trovi una chiave che non puoi sbloccare, ricordala per dopo
  • Se trovi una nuova chiave, vai a provarla su ogni chiave bloccata nota e percorso bloccato

Non mi occuperò nemmeno del modo in cui portare una certa chiave potrebbe negare l'effetto di un'altra chiave (Grotte colossali, l'asta spaventa l'uccello e deve essere lasciata cadere prima che l'uccello possa essere raccolto, ma è necessario in seguito per creare un ponte magico) .

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.