AI

a-gif

Kursuse raames on teil tarvis enda mängule lisada ka mõni AI komponent. Kõige parem viis alustada mõne AI komponendi loomisega, kui enne kogemust pole, ongi lisada mängu non-playable-characterid, kes liiguvad mängus ringi nii nagu teie mäng ette näeb.

NB! Siin materjalides on näitel kasutusel Kryonet, kui te kasutate mingit muud library-t, siis peaksite järgima selle dokumentatsiooni.

Näiteks võivad need NPCd jälitada mängijat, või hoopis proovida võita mängijat mõnes laburündi tüüpi mängus.

Siin vaatamegi, kuidas panna AI serverisse niimoodi, et kõik mängijad näeksid seda mängus umbes samal kohal ning lisame sellele ka A* pathfindingu.

Teie AI komponent peaks jooksma serveris sellepärast, et tegu on multiplayer mänguga. Multiplayer mängudes üldiselt keskendub kliendi pool renderdamisele ja enamus loogika võiks olla serveris.

Klasside loomine

Esiteks võiks teil olla iga mängu jaoks eraldi klass, siis on lihtne igat mängu eraldi hallata, ja need ei sega üksteist. Kui teil on terve serveri peale ainult 1 mängu klass, siis see pole ka probleem.

Teiseks oleks vaja luua mingi NPC klass serverisse, näiteks nii

0

Sellel klassil peaks kindlasti olema selle AI x ja y koordinaadid. Samuti peaksime igale NPC-le andma ka unikaalse id, selleks on lihtne viis kasutada staatilist suurima id muutujat ning seda koguaeg juurde lisada kui uue klassi loome. Lisasin sinna ka meetodi moveThread, sest AI võiks kohe nii-öelda käima minna, kui me selle klassi loome, see meetod võib praegu tühjaks jääda, me varsti jõuame selle juurde tagasi.

Kolmandaks võiks teil olla mingi list kõigist hetkel mängus olevatest NPCdest, eeldatavasti võiks see olla teie mängu klassis. Võiksite oma Game klassis teha midagi sarnast

1

Neljandaks peaksite samamoodi looma NPC klassi ka kliendi poolele, ning kusagil hoidma ka listi kõigist renderdavatest NPC-dest. Selle juurde, kuidas NPC liikumisi kliendi poolel renderdada, jõuame hiljem.

A* pathfinding ja liikumine

A* algoritm Javas

Erinevaid algoritme, mis leiavad tee mingist alguspunktist mingisse lõpppunkti on palju, aga kõige parem mängude jaoks on A*. A* plussid on näiteks kiirus, ta leiab alati kõige lühema tee ning ta on efektiivne.

Lühidalt öeldes töötab A* pathfinding nii:

  1. Anname ette mingi alguspunkti- ja lõpppunkti koordinaadid
  2. Salvestame alguspunkti meie koordinaatide järjekorda, prioriteediga 0
  3. Hakkame loopima koordinaatide järjekorda, valime kõige väiksema prioriteediga koordinaadi ning kustutame selle.
  4. Leiame hetkese punkti naabrid, kuhu saame liikuda (pole collisionit) (x - 1, y - 1, x + 1, y + 1)
  5. Arvutame iga koordinaadi jaoks prioriteedi (kaugus lõpppunktist + kui palju me liikunud oleme alguspunktist)
  6. Lisame leitud naabrid koos prioriteediga koordinaatide järjekorda
  7. Kordame 4-6.punkti kuni oleme jõudnud lõpppunkti või kuni järjekorras pole enam ühtegi punkti (ei leidnud teed)

Loome serverisse AStar klassi

2

Loome AStar klassi Node klassi

3

Ja loome meetodi findPath, leidmaks teed alguspunktist lõpppunkti

4

Natuke selgituseks, luues AStar klassi, peaksime kaasa andma enda kaardi kahedimensioonilise int massiivina, et meie A* algoritm teaks, kus on seinad, millest me läbi minna ei saa, ning samuti ka kui suur meie kaart on. Antud näites on see kahedimensiooniline massiiv veergude massiividest. Hetkel tähistab vaid integer 1, et seal on collision:

5

Meetod findPath tagastab teekonna lõpppunktist alguspunkti.

Võiksite selle koodi läbi lugeda ja natuke analüüsida, et saaksite aru mis toimub.

Node klassis tähistab hScore kaugust lõpppunktist, ning gScore tähistab seda, kui palju me liikunud oleme, et sinna punktini jõuda.

Võite testida, kuidas see algoritm töötab, näiteks nii

6

Liikumine kasutades A* algoritmi

Esiteks oleks meil vaja NPC-d kuidagi mängijate kaartidele saada. Selleks võiks luua mõne packeti klassi, millega saata infot AI kohta (tema id ning algne asukoht)

See võiks olla midagi sellist

7

Lihtsustatult öeldes oleks meil vaja igale uuele mängijale kes mängu satub, saata iga NPC kohta infot. Id selle jaoks, et eristada mitut NPC-d ning hiljem anname selle NPC id kaasa ka liikumise packetitega.

Kõige õigem olekski (kui mingi mängija satub mängu -> loopi kõik NPCd serveris läbi -> saada OnSpawnNpc klass sellele mängijale)

Edasi peaksime selle packeti kliendis ka vastu võtma ja nagu eelpool sai mainitud, võiks meil ka kliend poolel olla eraldi NPC klass ja samuti ka list kõigist mängus olevatest NPC-dest. Võtame OnSpawnNpc packeti kliendi poolel vastu, ja iga kord teeme uue NPC klassi, kus anname kaasa x ja y koordinaadid mille saime, ning samuti ka id, mida on hiljem liikumiseks vaja.

Näide NPC klassist kliendi poolel

8

OnSpawnNpc paketi vastu võtmine, ning meetod, kus lisame uue NPC listi

9

10

Kuna kliendi poolel meie NPC klass extendib Sprite klassi, siis lisame ka update() ja draw() meetodid.

Liigume renderdamise juurde. Klassis kus jookseb teie põhi loogika ning kus renderdate ka teisi asju, tuleks nüüd hakata renderdama ka NPC-sid

Selleks oleks hea lisada eraldi meetod

11

renderNpcs() meetodi väljakutse tuleks panna teie render meetodi alla, kus renderdate ka kogu muud mängu.

Nüüd peaksid vähemalt kõik NPC-d kaardile tekkima, nende algsed asukohad oleks vaja serveris ette anda selle järgi, kuidas teie mäng ette näeb. Ehk siis enne kui lõime AI klassi serverisse, lisasime sinna ka x ja y koordinaadid, ning need oleks vaja kaasa anda vastavalt teie mängule. Muidugi tuleks jälgida, et need teie kaardist välja ei läheks. Üks soovitus kuidas seda teha, on näiteks see, et te võtate oma kaardi piirid (kõige alumine vasak punkt ning kõige ülemine parem punkt) ja asukoht peaks siis jääma nende kahe koordinaadi vahele.

Tuleb ka silmas pidada seda, et pathfindingus kasutame tile-de koordinaate. Ehk kui 1 tile suurus on 32x32, siis tuleks NPC x ja y koordinaat korrutada 32-ga.

Liigume tagasi serverisse. Alguses tegime serverisse NPC klassi ning ka meetodi moveThread, see võiks olla meetod, mis töötab iga mingi teatud aja tagant, ning muudab meie NPC asukohta.

Loome alguses klassi NPC liikumise saatmiseks, see võiks olla ka arusaadavalt nimetatud, näiteks OnNpcMove

12

Anname kaasa selle NPC id, et kliendis teada millist NPC-d peame liigutama, ja samuti ka x ja y koordinaadid, kuhu NPC peaks liikuma.

Liigume moveThread meetodi juurde. Me soovime, et see meetod käiks konstantselt iga minig teatud aja tagant. Selle jaoks on Javas olemas ScheduledExecutorService.

13

Natuke koodi poole pealt, loome uue ScheduledExecutorService ning paneme botRunnable Runnable jooksma iga 300 millisekundi tagant ning see hakkab jooksma 2 sekundit pärast väljakutset.

Nüüd on meil olemas runnable, mis läheb käima iga 300 millisekundi tagant. Hiljem te võite seda kiirust muidugi muuta.

Ja veel peaksite te ka sinna looma uue AStar klassi, kus siis annate kaasa oma mängu kaardi (collisionite jaoks), et hiljem kasutada findPath meetodit tee leidmiseks.

Nüüd peaksime serverisse NPC klassi lisama path listi (list Nodedest mis saime tagastuseks findPath meetodist)

Loome path listi, see on siis selle NPC hetkene teekond, mida ta läbib

private ArrayList<AStar.Node> path;

Nüüd saame moveThread meetodis vaadata, et kui meie path on kas null, või tema pikkus on 0, siis leiame uue teekonna A* abil

14

Siin toimub praegu see, et me võtame mingi täiesti suvalise asukoha kaardil kui selle asukoha collision on 0, ehk sinna on võimalik minna.

Muutujad minX, maxX, minY ja maxY võite ise defineerida, või näiteks valida minimaalseks x-ks ja y-ks 0 ja maksimaalseks x-ks ja y-ks teie mängu kaardi kõige suurema võimaliku asukoha.

While loopi kasutame sellepärast, et me otsime kaardil mingit täiesti suvalist asukohta nii kaua kuni me oleme leidnud asukoha, kus collisionit pole.

See, milline peaks NPC järgmine asukoht olema teie mängus, saate lõpuks ise defineerida, hetkel on näide lihtsalt selle kohta, et võtame suvalise asukoha kaardil.

Ja lõpuks kui asukoht sobib, leiame teekonna NPC praegusest x ja y koordinaatidest sinna kuhu me soovime minna.

Rida

path = aStar.findPath((int) yCur, (int) xCur, (int) y, (int) x);

Nüüd kui meil on olemas asukohta list path mille järgi liikuda, asume nende asukohtade saatmise juurde. Nagu ennem sai ära märgitud, siis findPath tagastab teekonna tagurpidi, ehk ülalpool andsime teekonna ette nii, et praegune asukoht on justkui see kuhu minna tahame ja asukoht kuhu me tegelikult minna tahame on siis algne asukoht. Ehk siis tagurpidi. Muidugi on võimalik ka list tagurpidi pöörata, aga see oleks mõtetu tegevus.

Kui meie path ei ole null ja tema pikkus ei ole samuti 0, teeme järgmist:

15

Võtame path listist 0 indeksiga elemendi ja eemaldame selle. Loome OnNpcMove klassi saatmiseks kõigile mängus olevatele mängijatele, kuhu lisame selle NPC id ja x ja y koordinaadid kuhu ta liigub ning lõpuks saadame selle.

Meetod sendEveryone on siin näites selline, mis saadab packeti igale mängijale sendTCP meetodi abil. See on kasutusele võetud sellepärast, et kui meil jookseb mitu mängu serveris paralleelselt, siis me ei saa kasutada kryoneti sisseehitatud funktsiooni sendTCPAll.

16

Meil on igal Game klassil oma id, ning iga game klass sisaldab listi hetkel sees olevatest mängijatest.

Kui te otsustate packetide saatmise lahendada sarnaselt sellega, siis tuleb tähele panna seda, et me kutsume meetodit sendEveryone välja mitmelt erinevalt threadilt. Main threadilt, kus võtame vastu klientide poolt saadetud packette ning nüüd ka NPC klassi moveThreadilt, sellepärast, et executor loob uue threadi, et mitte takistada seda mis toimub main threadil. Nüüd on väga oluline, et meetodit sendEveryone me ei käivitaks samal ajal mitmelt erinevalt threadilt, kuna siis võib juhtuda situatsioon, kus see meetod ei lõppegi kunagi ära, jääb käima. See juhtub sellepärast, et me loeme samu muutujaid mitmelt erinevalt threadilt. See võib tunduda hetkel keeruline, aga lihtsalt tasuks praegu meelde jätta, et multithreading on ohtlik ning sellega tuleb olla ettevaatlik.

Selle jaoks on Javas olemas selline asi nagu Lock.

private final ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    //do something
} finally {
    lock.unlock();
}

Kasutame meetodis sendEveryone muutujat ReentrantLock lock ning meetodi välja kutsega lukustame selle. Sisuliselt tähendab see seda, et kui lock on juba mingi teise threadi poolt hõivatud (lukustatud), siis teine thread, mis ka sama meetodit välja kutsus, niikaua ootab.

Nüüd, kuna me loopime listi players (list kõigis mängus olevatest mängijatest) ja me muudame seda listi kahel juhul - kui keegi mängu tuleb või kui keegi mängust lahkub, siis me peaksime ka sinna lisama selle sama muutuja lock. Ehk siis kui keegi tuleb mängu, teeme lock.lock(), lisame mängiija listi, ning peale seda vabastame luku lock.unlock(). Samamoodi ka mängust lahkumisega. Seda on vaja teha sellepärast, et muidu võib juhtuda selline asi, et näiteks saadame ühelt NPC threadilt uusi asukohti sendEveryone meetodiga, ja täpselt samal ajal ka keegi lahkub mängust. Juhtub selline asi nagu ConcurrentModificationException. Me loopime listi samal ajal, kui me seda muudame ning sellega tekib palju probleeme. Ehk siis kui te kasutate sama meetodit nagu siin näites, kasutage locke nendes kohtades.

Muidugi te võite huvi pärast katsetada, et mis juhtub siis kui te ei kasuta lock-i. Suur tõenäosus, et midagi ei juhtugi, kui teil palju mängijaid mängus ei ole.

Kui teil on vaid 1 mängu seanss, ja muidu saadate ka kõikki packette Kryonet-i sendTCPAll meetodiga, siis seda võite vabalt otse välja kutsuda mitmelt erinevalt threadilt. See ei tohiks probleem olla, vähemalt Kryonetis.

Nüüd peaks meil NPC-de liikumise saatmisega olema kõik, ja saame liikuda kliendi poolel renderdamise juurde.

Meil peaks kliendis NPC klassis olema hetkese asukoha koordinaadid (x ja y) ning asukoha koordinaadid kuhu me hetkel liigume (moveX ja moveY).

Võtame OnNpcMove packeti vastu, ja muudame vastavalt sellele kindla NPC klassi moveX ja moveY muutujat

17

18

Te muidugi ei pea selle järgi tegema ning samuti kui te ei kasuta Kryonetti, siis toimub packetite vastuvõtmine teistmoodi. Aga mida tuleb silmas pidada on see, et kui suur teie 1 tile on. Antud näites me korrutame saadud x ja y koordinaadid 32-ga, sest selles näites on mängu kaardi 1 tile suurus 32x32 pikslit.

Nüüd oleks vaja NPC klassi lisada 2 uut muutujat

private long receiveDifference = 0;
private long lastReceive = 0;

Ja samuti ka meetodisse, kus muudate NPC asukohta

19

See on selle jaoks, et me saaks sujuvalt renderdada NPC-sid niimoodi, et kõik näeksid neid umbes enam-vähem samal kohal samal ajal. Salvestame ära vahe millisekundites praeguse ja eelmise NPC move packeti vahel.

Ja viimaks liigume NPC klassi ning lõpetame draw() ja update() meetodid

20

double speed = (deltaTime / ((float)this.getReceiveDifference())) * 1000;

Selle rea võib kokku võta nii, et ta töötab :) Jagame deltaTime praeguse packeti ja eelmise packeti vahega ning lõpuks korrutame 1000-ga. Niimoodi ei teki teil ka probleeme, kui näiteks tahate liikumiskiirust muuta, siis te lihtsalt muudate serveri poolel executori aja ära. Siin näites oli ta 300 millisekundit.

Ja peale seda liigume vastavalt siis hetkesest x ja y koordinaadist moveX ja moveY koordinaati, ehk siis lõppkoordinaati.

Nüüd peaks teil mingi algeline AI enda mängus olemas olema. Mõned teemad nagu näiteks threading võivad tunduda täiesti tundmatud, kuid siin projektis te ei peagi palju teadmisi nende kohta omama.