Målrettet handlingsplanlegging for en smartere AI

Målrettet handlingsplanlegging (GOAP) er et AI-system som enkelt gir dine agenter valg og verktøy for å ta smarte beslutninger uten å måtte opprettholde en stor og komplisert, finite state machine.

Se demonstrasjonen

I denne demonstrasjonen er det fire tegnklasser, hver med verktøy som bryter etter å ha blitt brukt for en stund:

  • Miner: Myntmalm på stein. Trenger et verktøy for å jobbe.
  • Logger: Chops trær for å produsere logger. Trenger et verktøy for å jobbe.
  • Wood Cutter: Kutt opp trær i brukbart tre. Trenger et verktøy for å jobbe.
  • Smed: Smirer verktøy på smeden. Alle bruker disse verktøyene.

Hver klasse vil finne ut automatisk, ved hjelp av målrettet handlingsplanlegging, hvilke handlinger de trenger for å utføre for å nå sine mål. Hvis deres verktøy går i stykker, vil de gå til en forsyningsbunke som har en laget av smeden.

Hva er GOAP?

Målrettet handlingsplanlegging er et kunstig intelligenssystem for agenter som gjør at de kan planlegge en rekke handlinger for å tilfredsstille et bestemt mål. Den spesifikke sekvensen av handlinger avhenger ikke bare av målet, men også på den nåværende tilstanden av verden og agenten. Dette betyr at hvis det samme målet leveres for forskjellige agenter eller verdens stater, kan du få en helt annen rekke handlinger. Dette gjør AI mer dynamisk og realistisk. La oss se på et eksempel, sett i demonstrasjonen ovenfor.

Vi har en agent, en trehopper, som tar logger og hugger dem opp i ved. Hakkeren kan leveres med målet MakeFirewood, og har handlingene ChopLog, GetAxe, og CollectBranches.

De ChopLog Tiltaket vil slå en logg inn i ved, men bare hvis trekutteren har en økse. De GetAxe Tiltaket vil gi treskutteren en økse. Endelig, den CollectBranches Tiltak vil også produsere brensel uten å kreve en økse, men brensel vil ikke være så høy i kvalitet.

Når vi gir agenten den MakeFirewood mål, vi får disse to forskjellige handlingssekvensene:

  • Trenger brensel -> GetAxe -> ChopLog = gjør brensel
  • Trenger brensel -> CollectBranches = gjør brensel

Hvis agenten kan få en økse, kan de hakke en logg for å lage ved. Men kanskje de ikke kan få øks; så kan de bare gå og samle grener. Hver av disse sekvensene vil oppfylle målet om MakeFirewood

GOAP kan velge den beste sekvensen basert på hvilke forutsetninger som er tilgjengelige. Hvis det ikke er noen økse, må treskutteren ty til å plukke opp grener. Plukke opp grener kan ta veldig lang tid og gi dårlig kvalitet ved, så vi vil ikke at den skal løpe hele tiden, bare når den må.

Hvem GOAP er for

Du er nå kjent for Finite State Machines (FSM), men hvis ikke, ta en titt på denne kjempebra opplæringen. 

Du kan ha kjørt inn i svært store og komplekse tilstander for noen av dine FSM-agenter, der du til slutt kommer til et punkt der du ikke vil legge til ny atferd fordi de forårsaker for mange bivirkninger og hull i AI.

GOAP gjør dette:

Finite State Machine sier: koblet overalt.

Inn i dette

GOAP: hyggelig og håndterlig.


Ved å koble fra handlingene fra hverandre kan vi nå fokusere på hver enkelt handling hver for seg. Dette gjør koden modulær, og enkel å teste og vedlikeholde. Hvis du vil legge til en annen handling, kan du bare plunk den inn, og ingen andre handlinger må endres. Prøv å gjøre det med en FSM!

Du kan også legge til eller fjerne handlinger i fly for å endre en agents oppførsel for å gjøre dem enda mer dynamiske. Har en ogre som plutselig begynte å rase? Gi dem en ny "raserangrep" -aksjon som blir fjernet når de stiller seg. Bare å legge til handlingen i listen over handlinger er alt du trenger å gjøre; GOAP planleggeren vil ta vare på resten.

Hvis du finner at du har en veldig kompleks FSM for agenter, bør du gi GOAP en prøve. Et tegn på at FSM blir for komplisert er når hver stat har et myriade av if-else uttalelser som tester hvilken stat de burde gå til neste, og å legge til i en ny stat gjør deg stønn over alle implikasjonene det kan ha.

Hvis du har en veldig enkel agent som bare utfører en eller to oppgaver, kan GOAP være litt tunghendt og en FSM vil være tilstrekkelig. Det er imidlertid verdt å se på begrepene her og se om de vil være enkle nok til at du skal plugge inn i din agent.

handlinger

en handling er noe som agenten gjør. Vanligvis spiller det bare en animasjon og en lyd, og endrer litt stat (for eksempel, legger ved). Å åpne en dør er en annen handling (og animasjon) enn å plukke opp en blyant. En handling er innkapslet, og bør ikke bekymre deg for hva de andre handlingene er.

For å hjelpe GOAP bestemme hvilke handlinger vi vil bruke, er hver handling gitt a koste. En høy kostnad handling vil ikke bli valgt over en lavere kostnad handling. Når vi sekvenserer handlingene sammen, legger vi opp kostnadene og velger deretter sekvensen med laveste pris.

Lar tildele noen kostnader til handlingene:

  • GetAxe Kostnad: 2
  • ChopLog Kostnad: 4
  • CollectBranches Kostnad: 8

Hvis vi ser på sekvensen av handlinger igjen og legger til de totale kostnadene, vil vi se hva den billigste sekvensen er:

  • Trenger brensel -> GetAxe (2) -> ChopLog(4) = gjør brensel(totalt: 6)
  • Trenger brensel -> CollectBranches(8) = gjør brensel(totalt: 8)

Å skaffe en økse og hakke en tømmer produserer ved til en lavere pris på 6, mens samlingen av grener produserer tre til den høyere kostnaden av 8. Så velger vår agent å få en økse og hogge tre.

Men vil ikke denne samme sekvensen løpe hele tiden? Ikke hvis vi introduserer forutsetninger...

Forutsetninger og effekter

Handlinger har forutsetninger og effekter. En forutsetning er staten som kreves for at aktiviteten skal løpe, og effektene er endringen til staten etter at handlingen har gått.

For eksempel, ChopLog handling krever at agenten har en økse hendig. Hvis agenten ikke har en økse, må den finne en annen handling som kan oppfylle den forutsetningen for å la ChopLog action runde. Heldigvis, den GetAxe handling gjør det - dette er effekten av handlingen.

GOAP Planner

GOAP-planleggeren er et stykke kode som ser på handlingens forutsetninger og effekter, og skaper køer av handlinger som vil oppfylle et mål. Det målet er levert av agenten, sammen med en verdensstat, og en liste over handlinger agenten kan utføre. Med denne informasjonen kan GOAP planleggeren bestille handlingene, se hvilken som kan kjøre og som ikke kan, og deretter bestemme hvilke handlinger som er best å utføre. Heldigvis for deg, har jeg skrevet denne koden, så du trenger ikke.

For å sette opp dette, legger vi til forutsetninger og effekter for våre trehackers handlinger:

  • GetAxe Kostnad: 2. Forutsetninger: "en øks er tilgjengelig", "har ikke en økse". Effekt: "har en økse".
  • ChopLog Kostnad: 4. Forutsetninger:"har en økse". Effekt: "lage brensel"
  • CollectBranches Kostnad: 8. Forutsetninger: (ingen). Effekt: "lage brensel".

GOAP-planleggeren har nå informasjonen som trengs for å bestille rekkefølgen av tiltak for å lage ved (vårt mål). 

Vi starter med å levere GOAP Planner med nåværende tilstand av verden og agentens tilstand. Denne kombinerte verdensstaten er:

  • "har ikke en økse"
  • "en økse er tilgjengelig"
  • "solen skinner"

Når vi ser på våre nåværende tilgjengelige handlinger, er den eneste delen av statene som er relevante for dem, "ikke har en økse" og "en øks er tilgjengelig" stater; den andre kan brukes til andre agenter med andre handlinger.

Ok, vi har vår nåværende verdensstat, våre handlinger (med sine forutsetninger og effekter), og målet. La oss planlegge!

Mål: "lage brensel" Nåværende tilstand: "Har ingen økse", "en øks er tilgjengelig" Kan handling ChopLog løpe? NEI - krever forutsetning "har en økse" Kan ikke bruke den nå, prøv en annen handling. Kan handling GetAxe løpe? JA, forutsetninger "en økse er tilgjengelig" og "ikke har en økse" er sant. PUSH-handling på kø, oppdater tilstand med action-effekt New State "har en økse" Fjern state "en økse er tilgjengelig" fordi vi bare tok en. Kan handling ChopLog løpe? JA, forutsetning "har en økse" er sant PUSH-handling på kø, oppdater status med action-effekten Ny stat "har en økse", "gjør brensel" Vi har nådd vårt mål med "gjør brensel" Handlingssekvens: GetAxe -> ChopLog

Planleggeren vil også løpe gjennom de andre handlingene, og det vil ikke bare stoppe når den finner en løsning på målet. Hva om en annen sekvens har en lavere kostnad? Det vil løpe gjennom alle muligheter for å finne den beste løsningen.

Når den planlegger, bygger den opp en tre. Hver gang en handling blir brukt, kommer den ut av listen over tilgjengelige handlinger, slik at vi ikke har en streng på 50 GetAxe handlinger tilbake til rygg. Staten er endret med den virkningens virkning.

Træret som planleggeren bygger opp ser slik ut:

Vi kan se at det faktisk vil finne tre baner til målet med sine totale kostnader:

  • GetAxe -> ChopLog (totalt: 6)
  • GetAxe -> CollectBranches(totalt: 10)
  • CollectBranches (totalt: 8)

Selv om GetAxe -> CollectBranches fungerer, den billigste stien er GetAxe -> ChopLog, så denne er returnert.

Hva ser forutsetninger og effekter faktisk ut i koden? Vel, det er opp til deg, men jeg har funnet det enklest å lagre dem som et nøkkelverdi-par, hvor nøkkelen alltid er en streng, og verdien er en gjenstand eller primitiv type (flyt, int, boolsk eller lignende). I C #, kan det se slik ut:

HashSet< KeyValuePair > forutsetninger; HashSet< KeyValuePair > effekter;

Når handlingen utfører, hvordan ser disse effektene ut og hva gjør de? Vel, de trenger ikke å gjøre noe - de er egentlig bare brukt til planlegging, og påvirker ikke den virkelige agentens tilstand før de løper for ekte. 

Dette er verdt å understreke: Planleggingshandlinger er ikke det samme som å kjøre dem. Når en agent utfører GetAxe handling, vil det trolig være nær en haug med verktøy, spille en bend-down-and-pick-animasjon, og lagre så et øksobjekt i ryggsekken. Dette endrer agentens tilstand. Men under GOAP planlegger, Statens endring er bare midlertidig, slik at planleggeren kan finne ut den optimale løsningen.

Prosedyriske forutsetninger

Noen ganger må handlinger gjøre litt mer for å avgjøre om de kan kjøre. For eksempel, GetAxe Handlingen har forutsetningen om at "en økse er tilgjengelig" som må søke i verden, eller i umiddelbar nærhet, for å se om det er en økse som agenten kan ta. Det kan avgjøre at nærmeste økse bare er for langt unna eller bak fiendens linjer, og vil si at den ikke kan løpe. Denne forutsetningen er prosessorisk og må kjøre noen kode; Det er ikke en enkel boolsk operatør som vi bare kan bytte.

Selvfølgelig kan noen av disse prosedyriske forutsetningene ta litt tid å løpe, og skal utføres på noe annet enn gjengetråden, ideelt som en bakgrunnstråd eller som Coroutines (i enhet).

Du kan også få prosedyreffekter hvis du ønsker det. Og hvis du vil introdusere enda mer dynamiske resultater, kan du endre koste av handlinger på fluen!

GOAP og State

Vårt GOAP-system må bo i en liten Finite State Machine (FSM), av den eneste grunnen at handlinger i mange spill må være nær et mål for å kunne utføre. Vi ender opp med tre stater:

  • Tomgang
  • Flytte til
  • PerformAction

Når det er tomgang, vil agenten finne ut hvilket mål de vil oppfylle. Denne delen håndteres utenfor GOAP; GOAP vil bare fortelle hvilke handlinger du kan kjøre for å utføre det målet. Når et mål er valgt, sendes det til GOAP Planner, sammen med start- og verdensbegreperen, og planleggeren returnerer en liste over handlinger (hvis den kan oppfylle det målet).

Når planleggeren er ferdig og agenten har sin liste over handlinger, vil den forsøke å utføre den første handlingen. Alle handlinger må vite om de må være innenfor rekkevidde av et mål. Hvis de gjør det, vil FSM trykke på neste tilstand: Flytte til.

De Flytte til staten vil fortelle agenten at den trenger å flytte til et bestemt mål. Agenten vil gjøre bevegelsen (og spille tur animasjonen), og la FSM vite når den er innenfor rekkevidde av målet. Denne tilstanden er da poppet av, og handlingen kan utføre.

De PerformAction tilstanden vil kjøre den neste handlingen i køen av handlinger returnert av GOAP Planner. Handlingen kan være øyeblikkelig eller sist over mange rammer, men når den er ferdig blir den slått av og deretter utføres neste handling (igjen, etter å ha sjekket om den neste handlingen må utføres innenfor et objekts rekkevidde).

Alt dette gjentas til det ikke er noen handlinger igjen for å utføre, på hvilket tidspunkt går vi tilbake til Tomgang stat, få et nytt mål, og planlegg igjen.

En ekte kodeeksempel

Det er på tide å se på et virkelig eksempel! Ikke bekymre deg; Det er ikke så komplisert, og jeg har gitt en arbeidskopi i Unity og C # for deg å prøve ut. Jeg vil bare snakke om det kort her, så du får en følelse for arkitekturen. Koden bruker noen av de samme WoodChopper-eksemplene som ovenfor.

Hvis du vil grave rett inn, hodet her for koden: http://github.com/sploreg/goap

Vi har fire arbeidere:

  • Smed: gjør jernmalm til verktøy.
  • Logger: bruker et verktøy for å hugge ned trær for å produsere logger.
  • Miner: miner bergarter med et verktøy for å produsere jernmalm.
  • Wood cutter: bruker et verktøy for å hogge logger for å produsere brensel.

Verktøy slites ut over tid og må byttes ut. Heldigvis gjør smeden verktøy. Men jernmalm er nødvendig for å lage verktøy; Det er her Miner kommer inn (som også trenger verktøy). The Wood Cutter trenger logger, og de kommer fra loggeren; begge trenger verktøy også.

Verktøy og ressurser lagres på forsyningspinner. Agenter vil samle materialene eller verktøyene de trenger fra haugene, og også slippe av produktet på dem.

Koden har seks hoved GOAP klasser:

  • GoapAgent: forstår tilstand og bruker FSM og GoapPlanner å operere.
  • GoapAction: handlinger som agenter kan utføre.
  • GoapPlanner: Planlegg handlingene for GoapAgent.
  • FSM: den finite state machine.
  • FSMState: en stat i FSM.
  • IGoap: grensesnittet som våre ekte Laborer-skuespillere bruker. Slår seg inn i hendelser for GOAP og FSM.

La oss se på GoapAction klasse, siden det er den du vil underklasse:

offentlig abstrakt klasse GoapAction: MonoBehaviour private HashSet> forutsetninger; privat HashSet> effekter; privat bool inRange = false; / * Kostnaden for å utføre handlingen. * Finn ut en vekt som passer til handlingen. * Endring av det vil påvirke hvilke handlinger som velges under planlegging. * / Offentlig flytekost = 1f; / ** * En handling må ofte utføres på et objekt. Dette er det objektet. Kan være null. * / offentlig GameObject mål; offentlig GoapAction () forutsetninger = nytt HashSet> (); effects = new HashSet> ();  offentlig tomrom doReset () inRange = false; mål = null; tilbakestille ();  / ** * Tilbakestill eventuelle variabler som må nullstilles før planlegging skjer igjen. * / offentlig abstrakt tomt tilbakestilling (); / ** * Er handlingen ferdig? * / offentlig abstrakt bool isDone (); / ** * Kontroller prosedyren om denne handlingen kan løpe. Ikke alle handlinger * trenger dette, men noen kanskje. * / offentlig abstrakt bool checkProceduralPrecondition (GameObject agent); / ** * Kjør handlingen. * Returnerer True hvis handlingen utføres vellykket eller feil * hvis noe skjedde og det kan ikke lenger utføre. I dette tilfellet * skal handlingen køen rydde ut og målet kan ikke nås. * / offentlig abstrakt bool utføre (GameObject agent); / ** * Må denne handlingen være innenfor rekkevidde av et målspillobjekt? * Hvis ikke, trenger ikke moveTo-tilstanden å kjøre for denne handlingen. * / Offentlig abstrakt bool kreverInRange (); / ** * Er vi innenfor rekkevidde av målet? * MoveTo-tilstanden vil angi dette, og det blir tilbakestilt hver gang denne handlingen utføres. * / public bool isInRange () return inRange;  offentlig tomgang setInRange (bool inRange) this.inRange = inRange;  offentlig ugyldig addPrecondition (strengnøkkel, objektverdi) forhåndsbetingelser.Add (ny KeyValuePair(nøkkel, verdi));  Offentlig tomretting fjerneFjernelse (strengnøkkel) KeyValuePair remove = default (KeyValuePair); foreach (KeyValuePair kvp i forutsetninger) if (kvp.Key.Equals (key)) remove = kvp;  hvis (! default (KeyValuePair) .Equals (fjern)) forutsetninger. Fjern (fjern);  offentlig ugyldig addEffect (strengnøkkel, objektverdi) effects.Add (ny KeyValuePair(nøkkel, verdi));  Offentlig ugyldig removeEffect (strengnøkkel) KeyValuePair remove = default (KeyValuePair); foreach (KeyValuePair kvp i effekter) if (kvp.Key.Equals (key)) remove = kvp;  hvis (! default (KeyValuePair) .Equals (fjern)) effects.Remove (remove);  offentlig HashSet> Forutsetninger get return preconditions;  offentlig HashSet> Effekter get return effects; 

Ingenting for fancy her: det lagrer forutsetninger og effekter. Det vet også om det må være innenfor rekkevidde av et mål, og i så fall vet FSM å skyve Flytte til angi når det trengs. Det vet når det er gjort også; som bestemmes av implementeringsaksjonsklassen.

Her er en av handlingene:

offentlig klasse MineOreAction: GoapAction private bool mined = false; privat IronRockComponent targetRock; // hvor vi får malmen fra private float startTime = 0; offentlig float miningDuration = 2; // sekunder offentlig MineOreAction () addPrecondition ("hasTool", true); // Vi trenger et verktøy for å gjøre dette addPrecondition ("hasOre", false); // hvis vi har malm, vil vi ikke ha mer addEffect ("hasOre", true);  Offentlig overstyring, ugyldig tilbakestilling () mined = false; targetRock = null; startTime = 0;  offentlig overstyring bool isDone () return mined;  offentlig overstyring bool kreverInRange () return true; // ja vi må være i nærheten av en rock offentlig overstyring bool checkProceduralPrecondition (GameObject agent) // finn nærmeste rock som vi kan min IronRockComponent [] rocks = FindObjectsOfType (typeof (IronRockComponent)) som IronRockComponent []; IronRockComponent nærmest = null; flyte nærmestDist = 0; foreach (IronRockComponent rock in rocks) hvis (nærmeste == null) // første, så velg den for nå nærmeste = rock; nearestDist = (rock.gameObject.transform.position - agent.transform.position) .magnitude;  ellers // er denne en nærmere enn den siste? float dist = (rock.gameObject.transform.position - agent.transform.position) .magnitude; hvis (dist < closestDist)  // we found a closer one, use it closest = rock; closestDist = dist;    targetRock = closest; target = targetRock.gameObject; return closest != null;  public override bool perform (GameObject agent)  if (startTime == 0) startTime = Time.time; if (Time.time - startTime > miningDuration) // ferdig gruvedrift BackpackComponent ryggsekk = (BackpackComponent) agent.GetComponent (typeof (BackpackComponent)); backpack.numOre + = 2; mined = true; ToolComponent tool = backpack.tool.GetComponent (typeof (ToolComponent)) som ToolComponent; tool.use (0.5f); hvis (tool.destroyed ()) Destroy (backpack.tool); backpack.tool = null;  returnere sann; 

Den største delen av handlingen er checkProceduralPreconditions metode. Det ser etter nærmeste spillobjekt med en IronRockComponent, og lagrer denne målgruppen. Da, når den utfører, blir den som reddet målrock og vil utføre handlingen på den. Når handlingen gjenbrukes i planlegging igjen, tilbakestilles alle feltene slik at de kan beregnes igjen.

Dette er alle komponenter som legges til Gruvearbeider enhet objekt i enhet:


For at agenten skal fungere, må du legge til følgende komponenter for den:

  • GoapAgent.
  • En klasse som implementerer IGoap (i eksempelet ovenfor er det Miner.cs).
  • Noen handlinger.
  • En ryggsekk (bare fordi handlingene bruker den, den er ikke relatert til GOAP).
Du kan legge til hvilke handlinger du vil, og dette vil endre hvordan agenten oppfører seg. Du kan selv gi det alle handlinger, slik at det kan min malm, smidde verktøy og hogge tre.

Her er demonstrasjonen på nytt igjen:

Hver arbeider går til målet som de trenger for å oppfylle sin handling (tre, stein, hakkeblokk eller hva som helst), utfører handlingen, og går ofte tilbake til forsyningsbunken for å slippe av sine varer. Smeden vil vente en liten stund til det er jernmalm i en av forsyningspinner (lagt til av Miner). Smeden går da av og gjør verktøy, og vil slippe av verktøyene på forsyningsbunken nærmest ham. Når en arbeiders verktøy bryter, vil de gå av til forsyningsbunken i nærheten av smeden hvor de nye verktøyene er.

Du kan ta tak i koden og hele appen her: http://github.com/sploreg/goap.

Konklusjon

Med GOAP kan du lage en stor serie med handlinger uten hodepine av sammenkoblede stater som ofte kommer med en Finite State Machine. Handlinger kan legges til og fjernes fra en agent for å produsere dynamiske resultater, samt å holde deg ren når du opprettholder koden. Du vil ende opp med en fleksibel, smart og dynamisk AI.