Prevenzione degli alberi comportamentali


25

Sto tentando di mettere la testa intorno agli alberi comportamentali, quindi sto aggiungendo del codice di prova. Una cosa con cui sto lottando è come prevenire un nodo attualmente in esecuzione quando arriva qualcosa di prioritario.

Considera il seguente albero di comportamento semplice e fittizio per un soldato:

inserisci qui la descrizione dell'immagine

Supponiamo che sia passato un certo numero di zecche e non ci siano nemici nelle vicinanze, il soldato era in piedi sull'erba, quindi il nodo Siediti è selezionato per l'esecuzione:

inserisci qui la descrizione dell'immagine

Ora l' azione Sit down richiede tempo per essere eseguita perché è presente un'animazione da riprodurre, quindi ritorna Runningcome suo stato. Passano un segno di spunta o due, l'animazione è ancora in esecuzione, ma il nemico è vicino? trigger nodo condizione. Ora dobbiamo anticipare il nodo Sedere al più presto in modo da poter eseguire il nodo Attacco . Idealmente, il soldato non finiva nemmeno di sedersi: avrebbe potuto invertire la direzione dell'animazione se avesse appena iniziato a sedersi. Per un maggiore realismo, se ha superato un punto critico nell'animazione, potremmo invece scegliere di lasciarlo finire di sedersi e poi stare di nuovo in piedi, o forse farlo inciampare nella fretta di reagire alla minaccia.

Prova come potrei, non sono stato in grado di trovare una guida su come gestire questo tipo di situazione. Tutta la letteratura e i video che ho consumato negli ultimi giorni (ed è stato molto) sembrano aggirare questo problema. La cosa più vicina che sono stato in grado di trovare è stato questo concetto di reimpostazione dei nodi in esecuzione, ma ciò non offre a nodi come Sit down la possibilità di dire "ehi, non ho ancora finito!"

Ho pensato forse di definire un metodo Preempt()o Interrupt()sulla mia Nodeclasse base . Diversi nodi possono gestirlo come meglio credono, ma in questo caso proveremmo a rimettere in piedi il soldato il prima possibile e poi a tornare Success. Penso che questo approccio richiederebbe anche che la mia base Nodeabbia il concetto di condizioni separatamente rispetto ad altre azioni. In questo modo, il motore può controllare solo le condizioni e, se passano, impedire qualsiasi nodo attualmente in esecuzione prima di iniziare l'esecuzione delle azioni. Se questa differenziazione non fosse stabilita, il motore dovrebbe eseguire i nodi in modo indiscriminato e potrebbe quindi innescare una nuova azione prima di anticipare quella in esecuzione.

Per riferimento, di seguito sono riportate le mie classi di base correnti. Ancora una volta, questo è un picco, quindi ho cercato di mantenere le cose il più semplice possibile e aggiungere complessità solo quando ne ho bisogno e quando lo capisco, che è ciò con cui sto lottando in questo momento.

public enum ExecuteResult
{
    // node needs more time to run on next tick
    Running,

    // node completed successfully
    Succeeded,

    // node failed to complete
    Failed
}

public abstract class Node<TAgent>
{
    public abstract ExecuteResult Execute(TimeSpan elapsed, TAgent agent, Blackboard blackboard);
}

public abstract class DecoratorNode<TAgent> : Node<TAgent>
{
    private readonly Node<TAgent> child;

    protected DecoratorNode(Node<TAgent> child)
    {
        this.child = child;
    }

    protected Node<TAgent> Child
    {
        get { return this.child; }
    }
}

public abstract class CompositeNode<TAgent> : Node<TAgent>
{
    private readonly Node<TAgent>[] children;

    protected CompositeNode(IEnumerable<Node<TAgent>> children)
    {
        this.children = children.ToArray();
    }

    protected Node<TAgent>[] Children
    {
        get { return this.children; }
    }
}

public abstract class ConditionNode<TAgent> : Node<TAgent>
{
    private readonly bool invert;

    protected ConditionNode()
        : this(false)
    {
    }

    protected ConditionNode(bool invert)
    {
        this.invert = invert;
    }

    public sealed override ExecuteResult Execute(TimeSpan elapsed, TAgent agent, Blackboard blackboard)
    {
        var result = this.CheckCondition(agent, blackboard);

        if (this.invert)
        {
            result = !result;
        }

        return result ? ExecuteResult.Succeeded : ExecuteResult.Failed;
    }

    protected abstract bool CheckCondition(TAgent agent, Blackboard blackboard);
}

public abstract class ActionNode<TAgent> : Node<TAgent>
{
}

Qualcuno ha qualche intuizione che potrebbe guidarmi nella giusta direzione? Il mio pensiero è nella giusta direzione o è ingenuo quanto temo?


Devi dare un'occhiata a questo documento: chrishecker.com/My_liner_notes_for_spore/… qui spiega come si cammina l'albero, non come una macchina a stati, ma dal ROOT ad ogni tick, che è il vero trucco della reattività. BT non dovrebbe aver bisogno di eccezioni o eventi. Stanno unendo i sistemi intrinsecamente e reagiscono a tutte le situazioni grazie al fatto che scorrono sempre dalla radice. Ecco come funziona la preventività, se una condizione esterna di priorità più alta verifica, scorre lì. (chiamando alcuni Stop()callback prima di uscire dai nodi attivi)
v.oddou

questo aigamedev.com/open/article/popular-behavior-tree-design è anche molto ben dettagliato
v.oddou

Risposte:


6

Mi sono ritrovato a porre la tua stessa domanda e ho avuto una breve conversazione nella sezione commenti di questa pagina del blog in cui mi è stata fornita un'altra soluzione del problema.

La prima cosa è usare un nodo simultaneo. Il nodo simultaneo è un tipo speciale di nodo composito. Consiste in una sequenza di controlli preliminari seguita da un singolo nodo di azione. Aggiorna tutti i nodi figlio anche se il nodo azione è nello stato "in esecuzione". (A differenza del nodo sequenza che deve iniziare l'aggiornamento è dal nodo figlio in esecuzione corrente.)

L'idea principale è quella di creare altri due stati di ritorno per i nodi di azione: "annullamento" e "annullato".

Il fallimento del controllo delle condizioni preliminari nel nodo simultaneo è un meccanismo che attiva la cancellazione del nodo di azione in esecuzione. Se il nodo di azione non richiede una logica di annullamento di lunga durata, restituirà immediatamente "annullato". Altrimenti passa allo stato di "annullamento" in cui è possibile inserire tutta la logica necessaria per la corretta interruzione dell'azione.


Ciao e benvenuto in GDSE. Sarebbe bello se tu potessi aprire quella risposta da quel blog a qui e alla fine link a quel blog. I collegamenti tendono a morire, avendo una risposta completa qui, la rende più persistente. La domanda ha 8 voti ora, quindi una buona risposta sarebbe fantastica.
Katu,

Non credo che qualsiasi cosa che riporti gli alberi comportamentali alla macchina a stati finiti sia una buona soluzione. Il tuo approccio mi sembra come se dovessi immaginare tutte le condizioni di uscita di ogni stato. Quando questo è effettivamente lo svantaggio di FSM! BT ha il vantaggio di ricominciare dalla radice, creando implicitamente un FSM completamente connesso, evitando di scrivere esplicitamente le condizioni di uscita.
v.

5

Penso che il tuo soldato possa essere scomposto in mente e corpo (e quant'altro). Successivamente, il corpo può essere scomposto in gambe e mani. Quindi, ogni parte necessita di un proprio albero di comportamento e anche di un'interfaccia pubblica, per richieste da parti di livello superiore o inferiore.

Quindi, invece di gestire micro ogni singola azione, devi semplicemente inviare messaggi istantanei come "body, siediti per un po 'di tempo" o "body, corri lì" e body gestirà animazioni, transizioni di stato, ritardi e altre cose per tu.

In alternativa, il corpo può gestire comportamenti come questo da solo. Se non ha ordini, potrebbe chiederti "possiamo sederci qui?". Ancora più interessante, a causa dell'incapsulamento, puoi facilmente modellare caratteristiche come stanchezza o stordimento.

Puoi anche scambiare parti: fai elefante con l'intelletto di zombi, aggiungi ali agli umani (non se ne accorgerà nemmeno) o qualsiasi altra cosa.

Senza decomposizione come questa, scommetto che prima o poi rischi di incontrare un'esplosione combinatoria.

Inoltre: http://www.valvesoftware.com/publications/2009/ai_systems_of_l4d_mike_booth.pdf


Grazie. Dopo aver letto la tua risposta 3 volte, penso di aver capito. Leggerò quel PDF questo fine settimana.
io--

1
Avendo pensato a questo nell'ultima ora, non sono sicuro di comprendere la distinzione tra avere BT completamente separati per mente e corpo rispetto a un singolo BT che è scomposto in sotto-alberi (referenziato attraverso un decoratore speciale, con script di build-time legando tutto insieme in un unico grande BT). Mi sembra che ciò fornirebbe benefici di astrazione simili e potrebbe effettivamente rendere più facile capire come si comporta una determinata entità perché non devi guardare attraverso BT separati. Tuttavia, probabilmente sono ingenuo.
io--

@ user13414 La differenza è che avrai bisogno di script speciali per costruire un albero, quando solo l'utilizzo dell'accesso indiretto (cioè quando il nodo del corpo deve chiedere al suo albero quale oggetto rappresenta le gambe) può essere sufficiente e inoltre non richiede alcun cervello aggiuntivo. Meno codice, meno errori. Inoltre, perderai la possibilità di cambiare (facilmente) sottostruttura in fase di esecuzione. Anche se non hai bisogno di tale flessibilità, non perderai nulla (inclusa la velocità di esecuzione).
Ombre nella pioggia,

3

Stendermi a letto ieri sera, ho avuto una sorta di epifania su come avrei potuto farlo senza introdurre la complessità a cui mi stavo appoggiando nella mia domanda. Implica l'uso del composito "parallelo" (scarsamente chiamato, IMHO). Ecco cosa sto pensando:

inserisci qui la descrizione dell'immagine

Spero che sia ancora abbastanza leggibile. I punti importanti sono:

  • la sequenza Sit down / Delay / Stand up è una sequenza all'interno di una sequenza parallela ( A ). Ad ogni tick, la sequenza parallela controlla anche la condizione Near Enemy (invertita). Se un nemico è vicino, la condizione fallisce e così pure l'intera sequenza parallela (immediatamente, anche se la sequenza figlio si trova a metà strada attraverso Siediti , Ritarda o Alzati )
  • in caso di guasto, il selettore B sopra la sequenza parallela salterà nel selettore C per gestire l'interruzione. È importante sottolineare che il selettore C non funzionerebbe se la sequenza parallela A fosse completata correttamente
  • il selettore C quindi cerca di alzarsi normalmente, ma può anche innescare un'animazione inciampata se il soldato è attualmente in una posizione troppo scomoda per semplicemente alzarsi

Penso che funzionerà (lo proverò presto nel mio picco), nonostante sia un po 'più disordinato di quanto mi aspettassi. La cosa buona è che alla fine sarei in grado di incapsulare sotto-alberi come elementi logici riutilizzabili e fare riferimento ad essi da più punti. Ciò allevierà la maggior parte delle mie preoccupazioni lì, quindi penso che questa sia una soluzione praticabile.

Certo, mi piacerebbe ancora sapere se qualcuno ha qualche idea su questo.

AGGIORNAMENTO : sebbene questo approccio funzioni tecnicamente, l'ho deciso sux. Questo perché i sottoalberi non collegati devono "conoscere" le condizioni definite in altre parti dell'albero in modo da poter innescare la propria morte. Mentre condividere i riferimenti al sottoalbero contribuirebbe ad alleviare questo dolore, è ancora contrario a ciò che ci si aspetta guardando l'albero del comportamento. In effetti, ho fatto lo stesso errore due volte su un picco molto semplice.

Pertanto, seguirò l'altra strada: supporto esplicito per la prelazione all'interno del modello a oggetti e uno speciale composito che consente l'esecuzione di una serie diversa di azioni quando si verifica la prelazione. Pubblicherò una risposta separata quando avrò qualcosa che funziona.


1
Se vuoi davvero riutilizzare i sottotitoli, allora la logica per quando interrompere ("nemico vicino" qui) dovrebbe presumibilmente non far parte del sottostruttura. Invece forse il sistema può chiedere a qualsiasi sottostruttura (ad es. B qui) di interrompersi a causa di uno stimolo con priorità più elevata, e quindi passerebbe a un nodo di interruzione appositamente contrassegnato (C qui) che gestirà il ripristino del personaggio in uno stato standard , ad esempio in piedi. Un po 'come l'equivalente dell'albero dei comportamenti della gestione delle eccezioni.
Nathan Reed,

1
Potresti anche incorporare più gestori di interruzioni a seconda dello stimolo che sta interrompendo. Ad esempio, se l'NPC è seduto e inizia a prendere fuoco, potresti non volerlo alzare (e presentare un bersaglio più grande), ma piuttosto rimanere basso e cercare la copertura.
Nathan Reed,

@Nathan: divertente di cui parli sulla "gestione delle eccezioni". Il primo possibile approccio che ho pensato ieri sera è stata questa idea di un composito Preempt, che avrebbe avuto due figli: uno per l'esecuzione normale e uno per l'esecuzione anticipata. Se il bambino normale passa o fallisce, quel risultato si propaga verso l'alto. Il bambino preemptivo scapperebbe sempre solo se si verificasse la preemption. Tutti i nodi avrebbero un Preempt()metodo, che scorrerebbe attraverso l'albero. Tuttavia, l'unica cosa che "gestirà" veramente sarebbe il composito preempt, che passerebbe immediatamente al suo nodo child preempt.
io--

Poi ho pensato all'approccio parallelo che ho delineato sopra, e questo mi è sembrato più elegante perché non richiede una extra cruft in tutta l'API. Per quanto riguarda l'incapsulamento dei sottoalberi, penso che ovunque sorga la complessità, sarebbe un possibile punto di sostituzione. Questo potrebbe anche essere il punto in cui si verificano diverse condizioni che vengono spesso verificate insieme. In tal caso, la radice della sostituzione sarebbe una sequenza composita, con più condizioni come suoi figli.
io--

Penso che i sottotitoli che conoscono le condizioni di cui hanno bisogno per "colpire" prima di essere eseguiti siano perfettamente appropriati in quanto li rendono autonomi e molto espliciti contro impliciti. Se questo è un problema più grande, allora non mantenere le condizioni all'interno della sottostruttura, ma sul "sito di chiamata" di esso.
Seivan,

2

Ecco la soluzione su cui ho optato per ora ...

  • La mia Nodeclasse di base ha un Interruptmetodo che, per impostazione predefinita, non fa nulla
  • Le condizioni sono costrutti di "prima classe", in quanto sono tenuti a restituire bool(il che implica che sono veloci da eseguire e non necessitano mai più di un aggiornamento)
  • Node espone una raccolta di condizioni separatamente alla sua raccolta di nodi figlio
  • Node.Executeesegue prima tutte le condizioni e fallisce immediatamente se una qualsiasi condizione fallisce. Se le condizioni hanno esito positivo (o non ce ne sono), chiama in ExecuteCoremodo che la sottoclasse possa svolgere il proprio lavoro effettivo. C'è un parametro che consente di saltare le condizioni, per i motivi che vedrai di seguito
  • Nodeconsente inoltre di eseguire le condizioni in modo isolato tramite un CheckConditionsmetodo. Naturalmente, in Node.Executerealtà chiama solo CheckConditionsquando è necessario convalidare le condizioni
  • Il mio Selectorcomposito ora chiama CheckConditionsper ogni bambino che considera per l'esecuzione. Se le condizioni falliscono, passa direttamente al bambino successivo. Se passano, controlla se esiste già un figlio in esecuzione. In tal caso, chiama Interrupte quindi non riesce. Questo è tutto ciò che può fare a questo punto, nella speranza che il nodo attualmente in esecuzione risponderà alla richiesta di interruzione, cosa che può fare ...
  • Ho aggiunto un Interruptiblenodo, che è una specie di decoratore speciale perché ha il flusso regolare di logica come figlio decorato, e quindi un nodo separato per le interruzioni. Esegue il figlio normale fino al completamento o al fallimento, purché non venga interrotto. Se viene interrotto, passa immediatamente all'esecuzione del nodo figlio di gestione delle interruzioni, che potrebbe essere un sottoalbero complesso quanto richiesto

Il risultato finale è qualcosa del genere, tratto dal mio picco:

inserisci qui la descrizione dell'immagine

Quanto sopra è l'albero del comportamento di un'ape, che raccoglie il nettare e lo restituisce al suo alveare. Quando non ha nettare e non è vicino a un fiore che ne ha, vaga:

inserisci qui la descrizione dell'immagine

Se questo nodo non fosse interrompibile non avrebbe mai fallito, quindi l'ape avrebbe vagato perennemente. Tuttavia, poiché il nodo padre è un selettore e ha figli con priorità più alta, la loro idoneità all'esecuzione viene costantemente verificata. Se le loro condizioni passano, il selettore genera un'interruzione e il sottoalbero in alto passa immediatamente al percorso "Interrotto", che semplicemente salta al più presto non riuscendo. Ovviamente, potrebbe prima eseguire alcune altre azioni, ma il mio picco non ha altro da fare se non la cauzione.

Per ricollegarlo alla mia domanda, tuttavia, potresti immaginare che il percorso "Interrotto" potrebbe tentare di invertire l'animazione di seduta e, in mancanza, far inciampare il soldato. Tutto ciò ostacolerebbe la transizione verso lo stato con priorità più elevata, ed è proprio questo l'obiettivo.

Io penso che io sono contento di questo approccio - in particolare i pezzi di nucleo I schema di cui sopra - ma ad essere onesti, è sollevato ulteriori domande circa la proliferazione di specifiche implementazioni di condizioni e le azioni, e legando l'albero comportamento nel sistema di animazione. Non sono nemmeno sicuro di poter ancora articolare queste domande, quindi continuerò a pensare / a picchiare.


1

Ho risolto lo stesso problema inventando il decoratore "When". Ha una condizione e due comportamenti secondari ("allora" e "altrimenti"). Quando "Quando" viene eseguito, controlla le condizioni e, a seconda del risultato, viene eseguito / altrimenti figlio. Se il risultato della condizione cambia, viene eseguito il ripristino del figlio e viene avviato il figlio corrispondente all'altro ramo. Se il figlio termina l'esecuzione, l'intero "Quando" termina l'esecuzione.

Il punto chiave è che a differenza della BT iniziale in questa domanda in cui la condizione è controllata solo all'inizio della sequenza, il mio "Quando" continua a controllare la condizione mentre è in esecuzione. Quindi, la parte superiore dell'albero del comportamento viene sostituita con:

When[EnemyNear]
  Then
    AttackSequence
  Otherwise
    When[StandingOnGrass]
      Then
        IdleSequence
      Otherwise
        Hum a tune

Per un utilizzo "When" più avanzato, si vorrebbe anche introdurre un'azione "Wait" che semplicemente non fa nulla per un determinato periodo di tempo o indefinitamente (fino a quando non viene ripristinata dal comportamento del genitore). Inoltre, se hai bisogno di un solo ramo di "Quando", un altro può contenere azioni "Successo" o "Fallito", che rispettano la realtà e falliscono immediatamente.


Penso che questo approccio sia più vicino a quello che gli inventori originali di BT avevano in mente. Utilizza un flusso più dinamico, motivo per cui lo stato "in esecuzione" in BT è uno stato molto pericoloso, che dovrebbe essere usato molto raramente. Dobbiamo progettare BT tenendo sempre presente la possibilità di tornare alla radice in qualsiasi momento.
v.

0

Mentre sono in ritardo, ma spero che questo possa aiutare. Soprattutto perché voglio assicurarmi di non aver perso personalmente qualcosa da solo, anche se ho cercato di capirlo. Ho preso principalmente in prestito questa idea da Unreal, ma senza renderla una Decoratorproprietà su una base Nodeo fortemente legata alla Blackboard, è più generica.

Questo introdurrà un nuovo tipo di nodo chiamato Guardche è come una combinazione di a Decorator, Compositee ha una condition() -> Resultfirma accanto a unupdate() -> Result

Ha tre modalità per indicare come deve avvenire l'annullamento quando Guardritorna Successo Failed, l'annullamento effettivo dipende dal chiamante. Quindi per una Selectorchiamata a Guard:

  1. Annulla .self -> Annulla Guard(e il relativo figlio in esecuzione) solo se è in esecuzione e la condizione eraFailed
  2. Annulla .lower-> Annulla i nodi con priorità inferiore solo se sono in esecuzione e la condizione era SuccessoRunning
  3. Annulla .both -> Entrambi .selfe in .lowerbase alle condizioni e ai nodi in esecuzione. Si desidera annullare self se è in esecuzione e si dovrebbe condizionare falseo annullare il nodo in esecuzione se vengono considerati con priorità inferiore in base alla Compositeregola ( Selectornel nostro caso) se la condizione lo è Success. In altre parole, fondamentalmente sono entrambi i concetti combinati.

Come Decoratore diversamente Compositeci vuole solo un figlio singolo.

Anche se Guardsolo prendere un singolo bambino, è possibile nidificare come tanti Sequences, Selectorso di altro tipo Nodescome si vuole, tra cui altri Guardso Decorators.

Selector1 Guard.both[Sequence[EnemyNear?]] Sequence1 MoveToEnemy Attack Selector2 Sequence2 StandingOnGrass? Idle HumATune

Nello scenario sopra, ogni volta Selector1che esegue gli aggiornamenti, eseguirà sempre i controlli delle condizioni sulle protezioni associate ai suoi figli. Nel caso sopra, Sequence1è custodito e deve essere verificato prima di Selector1continuare con le runningattività.

Ogni volta che Selector2o Sequence1è in esecuzione non appena EnemyNear?ritorna successdurante un Guards condition()controllo, Selector1verrà emesso un interruzione / annullamento per running nodee poi continuerà come al solito.

In altre parole, possiamo reagire al ramo "inattivo" o "attacco" in base a poche condizioni, rendendo il comportamento molto più reattivo rispetto a se ci fossimo accordati Parallel

Ciò consente anche di proteggere singoli Nodeche hanno una priorità più elevata contro l'esecuzione Nodesnello stessoComposite

Selector1 Guard.both[Sequence[EnemyNear?]] Sequence1 MoveToEnemy Attack Selector2 Guard.both[StandingOnGrass?] Idle HumATune

Se HumATunedura a lungo Node, Selector2controllerà sempre quello per primo se non fosse per Guard. Quindi se l'npc è stato teletrasportato su una patch di erba, la prossima volta Selector2viene eseguito, controllerà Guarde si annullerà HumATuneper funzionareIdle

Se viene teletrasportato dalla patch di erba, annullerà il nodo in esecuzione ( Idle) e passerà aHumATune

Come vedi qui, il processo decisionale si basa sul chiamante Guarde non su Guardse stesso. Le regole di chi è considerato lower priorityrimane al chiamante. In entrambi gli esempi, è Selectorchi definisce ciò che costituisce un lower priority.

Se avessi ricevuto un Compositecall Random Selector, avresti potuto definire le regole all'interno dell'implementazione di quello specifico Composite.

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.