AI¶
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 (NPC-sid), 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 NPC-d jälitada mängijat, või hoopis proovida võita mängijat mõnes labürindi tüüpi mängus.
Siin vaatamegi, kuidas panna AI serverisse niimoodi, et kõik mängijad näeksid seda mängus ligikaudu samas asukohas, ning lisame sellele ka A* teekonnaplaneerimise.
Teie AI komponent peaks jooksma serveris sellepärast, et tegu on multiplayer mänguga. Multiplayer mängudes keskendub kliendi pool üldiselt renderdamisele ja enamus loogika on serveris.
Pathfinding algoritmid¶
Pathfinding tähendab teekonna leidmist ühest punktist teise. Mängudes kasutatakse seda näiteks selleks, et NPC-d või vastased oskaksid liikuda mängija poole, leida tee läbi labürindi või vältida takistusi kaardil.
Lihtsamas keskkonnas võib AI liikuda otse sihtmärgi poole, kuid kui kaardil on seinad, takistused või keerulisemad ruumid, ei pruugi otse liikumine töötada. Sellisel juhul on vaja algoritmi, mis oskab arvestada, millised ruudud on läbitavad ja millised mitte.
Levinumad pathfinding algoritmid on näiteks BFS, Dijkstra ja A*.
1. BFS ehk breadth-first search otsib teed, liikudes alguspunktist samm-sammult kõikidesse võimalikesse suundadesse. See sobib hästi olukorda, kus kõik sammud on sama hinnaga. BFS leiab lühima tee, kuid võib suure kaardi puhul muutuda aeglaseks, sest ta kontrollib palju ruute.
2. Dijkstra algoritm on sarnane BFS-ile, kuid arvestab, et erinevatel teedel võib olla erinev hind. Näiteks võib üks ruut olla tavaline põrand, teine aga aeglustav muda või vesi. Dijkstra leiab kõige odavama tee, kuid kontrollib samuti palju võimalikke variante.
3. A* on mängudes üks levinumaid pathfinding algoritme. See kasutab lisaks juba läbitud teekonna pikkusele ka hinnangut selle kohta, kui kaugel ollakse sihtpunktist. Tänu sellele liigub A* otsing tavaliselt sihtmärgi poole targemalt ega kontrolli nii palju ebavajalikke ruute kui BFS või Dijkstra.
Selle projekti raames on parima tulemuse saavutamiseks rangelt soovituslik kasutada A* algoritmi, sest see sobib hästi ruudupõhise kaardi, takistuste ja NPC või vaenlase liikumise jaoks.
Täpsemalt A*-st¶
A* on pathfinding algoritm, mis leiab tee alguspunktist sihtpunkti. Selle eripära on see, et ta ei otsi teed täiesti pimesi, vaid kasutab hinnangut selle kohta, kui kaugel mingi ruut sihtpunktist on.
A* kasutab iga ruudu hindamiseks kolme väärtust:
gScore
näitab, kui pikk tee on juba alguspunktist selle ruuduni läbitud.
hScore
näitab hinnangut, kui kaugel see ruut sihtpunktist veel on.
Ruudupõhisel kaardil kasutatakse selleks tihti Manhattan distance'i.
fScore
on kahe eelmise väärtuse summa:
fScore = gScore + hScore
A* valib alati järgmiseks selle ruudu, mille fScore on kõige väiksem.
See tähendab, et algoritm eelistab ruute, kuhu jõudmine on seni olnud
lühike ja mis tunduvad samal ajal sihtpunktile lähedal olevat.
Lihtsustatult töötab A* nii:
Lisame alguspunkti uuritavate ruutude hulka.
Valime uuritavate ruutude hulgast väikseima
fScoreväärtusega ruudu.Kontrollime selle ruudu naabreid.
Jätame vahele ruudud, mis on takistused või kaardist väljas.
Arvutame sobivatele naabritele uued
gScore,hScorejafScoreväärtused.Salvestame iga ruudu juurde ka selle, millisest ruudust sinna tuldi.
Kordame tegevust, kuni jõuame sihtpunkti või kuni rohkem ruute pole uurida.
Kui sihtpunkt leitakse, saab tee taastada nii, et liigume sihtpunktist
tagasi mööda iga ruudu parent viidet kuni alguspunktini. Seejärel
pööratakse saadud list ümber ning tulemuseks on teekond alguspunktist
sihtpunktini.
Ruudupõhises mängus on A* hea valik, sest kaart on tavaliselt lihtsasti
esitatav kahemõõtmelise massiivina. Näiteks võib 0 tähendada
läbitavat ruutu ja 1 takistust. A* saab sellise info põhjal otsustada,
millistesse ruutudesse NPC võib liikuda ja millistesse mitte.
Soovi korral võib A* kohta lisaks lugeda siit: Introduction to the A* Algorithm.
Ning selgitavat videot saab vaadata siit: A* Pathfinding Algorithm Explained.
Klasside loomine¶
Esiteks oleks mõistlik luua iga mängu jaoks eraldi objekt, mis võimaldab mänge lihtsasti eraldi hallata ning hoiab need üksteisest sõltumatuna. Kui teil on ka terve serveri peale ainult 1 mängu objekt, siis pole ka see probleem. See tähendab, et teil läheb vaja mängu klassi.
Teiseks oleks vaja luua NPC klass serverisse, näiteks nii:
public class NPC {
private static int currentId = 0;
private static void incrementNextId() {
currentId++;
}
private final int netId;
private final float x;
private final float y;
public NPC(float x, float y) {
this.x = x;
this.y = y;
this.netId = currentId;
incrementNextId();
this.moveThread();
}
private void moveThread() {
}
public int getNetId() {
return this.netId;
}
public float getX() {
return this.x;
}
public float getY() {
return this.y;
}
}
Sellel klassil peaks kindlasti olema muutujad x ja y koordinaatide hoidmiseks.
Samuti peaksime igale NPC-le andma ka unikaalse id, mida hoiame muutujas netID.
Selleks on lihtne viis kasutada staatilist suurima id muutujat currentId ning seda suurendada
kui uue objekti loome. Lisasin klassile ka meetodi moveThread,
et AI alustaks oma tegevust kohe, kui objekt luuakse. Praegu võib see meetod jääda tühjaks,
kuid varsti jõuame selle juurde tagasi.
Kolmandaks võiks teil olla mingi list kõigist hetkel mängus olevatest
NPCdest ja meetod NPC-de loomiseks, eeldatavasti võiksid need asuda teie mängu klassis. Võiksite oma
Game klassis teha midagi sarnast:
public class Game {
private final List<NPC> aiBots = new ArrayList<>();
public Game(int gameId) {
this.generateAiBots();
}
private void generateAiBots() {
this.aiBots.add(new NPC(0, 0));
this.aiBots.add(new NPC(1, 1));
}
public List<NPC> getAiBots() {
return this.aiBots;
}
}
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* implementeerimine¶
Siin peatükis vaatame, kuidas A* algoritmi Javas alustada. Eesmärk on
luua eraldi AStar klass, millele antakse ette kaart kahemõõtmelise
massiivina ning mis tagastab leitud teekonna alguspunktist sihtpunkti.
A* klassi loomine¶
Loome serverisse
AStarklassi ja lisame selle koodi sinna:
public class AStar {
private final int maxX;
private final int maxY;
private final int[][] grid;
private final int[][] neighbours = {
{-1, 0}, // vasak
{0, -1}, // üles
{1, 0}, // parem
{0, 1} // alla
};
public AStar(int[][] grid) {
this.grid = grid;
this.maxX = grid[0].length;
this.maxY = grid.length;
}
AStar klassi loomisel tuleb kaasa anda kaardi kahedimensiooniline int
massiiv ehk int[][] grid. Sellest massiivist määratakse ka kaardi
laius maxX ja pikkus maxY. Siin 0 tähendab käidavat ruutu ja
1 tähendab takistust ehk collisionit.
Hiljem näete Main funktsiooni juures, kuidas sellist kaarti
kahedimensioonilise massiivina saaks luua. Siis defineerime neli
erinevat viisi, kuidas tegelane saab mängus liikuda
int[][] neighbours = ... kaudu.
AStar(int[][] grid) konstruktorit kasutatakse siis, kui mängus on
juba kaart olemas ja sellele antakse kaasa kaardi andmed, et A*
algoritm saaks õigesti kontrollida, kuhu tegelane võib liikuda.
Jätame AStar klassi lahti hetkel, sest järgmisena lisame sinna alla
Node alamklassi.
All on üks näide, kus x < 0 || x >= maxX kontrollib, kas x on
liiga paremale või vasakule liikunud ja y < 0 || y >= maxY
kontrollib, kas y on liiga üles või alla liikunud.
grid[y][x] == 1 kontrollib, kas mingi ruut on collision.
Praegu ei ole seda koodiosa vaja eraldi lisada. Seda kasutatakse hiljem
koos findPath meetodiga.
if (x < 0 || x >= maxX || y < 0 || y >= maxY || grid[y][x] == 1) {
continue;
}
Lisame
AStarklassi sisseNodealamklassi.
public class Node {
int x;
int y;
int gScore;
int hScore;
Node parent;
Node(int x, int y) {
this.x = x;
this.y = y;
this.gScore = 0;
this.hScore = 0;
this.parent = null;
}
void updateHScore(int dstX, int dstY) {
this.hScore = Math.abs(x - dstX) + Math.abs(y - dstY);
}
int getFScore() {
return this.gScore + this.hScore;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Node node)) return false;
return x == node.x &&
y == node.y;
}
@Override
public int hashCode() {
return Integer.hashCode(x + (y * maxY));
}
}
Node klassis tähistab hScore kaugust lõpppunktist, ning gScore tähistab seda,
kui palju me liikunud oleme, et sinna punktini jõuda.
Lisame
AStarklassi meetodifindPath.
public List<Node> findPath(int srcX, int srcY, int dstX, int dstY) {
List<Node> path = new ArrayList<>();
PriorityQueue<Node> openSet = new PriorityQueue<>(Comparator.comparingInt(AStar.Node::getFScore));
openSet.add(new Node(srcX, srcY));
Set<Node> closedSet = new HashSet<>();
while (!openSet.isEmpty()) {
AStar.Node current = openSet.poll();
if (current.x == dstX && current.y == dstY) {
while (current != null) {
path.add(current);
current = current.parent;
}
Collections.reverse(path);
return path;
}
closedSet.add(current);
for (int[] neighbour : neighbours) {
int x = current.x + neighbour[0];
int y = current.y + neighbour[1];
if (x < 0 || x >= maxX || y < 0 || y >= maxY || grid[y][x] == 1) {
continue;
}
AStar.Node neighbor = new AStar.Node(x, y);
int newGScore = current.gScore + 1;
if (closedSet.contains(neighbor)) {
continue;
}
if (!openSet.contains(neighbor) || newGScore < neighbor.gScore) {
neighbor.parent = current;
neighbor.gScore = newGScore;
neighbor.updateHScore(dstX, dstY);
openSet.add(neighbor);
}
}
}
return null;
}
Meetod findPath kasutab A* algoritmi, et leida kõige lühem tee ühest ruudust teise. Meetod alustab algpunktist,
käib läbi kõik võimalikud naaberruudud, välistab takistused ning arvutab iga ruudu jaoks hinnangu (fScore), kuni
jõuab sihtpunktini. Kui tee on leitud, taastatakse ruutude järjestus esmalt sihtpunktist tagasi alguspunktini.
Seejärel pööratakse list ümber käsuga Collections.reverse(path). Lühemalt öeldes tagastab see meetod teekonna
alguspunktist lõpppunkti.
A* algoritmi testimine¶
A* algoritmi saab testida näiteks eraldi Main klassiga.
import java.util.List;
public class Main {
public static void main(String[] args) {
int[][] grid = new int[][]{
{1, 1, 1, 1, 1},
{1, 0, 1, 1, 1},
{1, 0, 1, 1, 1},
{1, 0, 0, 0, 1},
{1, 0, 1, 0, 0},
};
AStar aStar = new AStar(grid);
List<AStar.Node> path = aStar.findPath(1, 1, 4, 4);
if (path != null) {
for (AStar.Node node : path) {
System.out.printf("%s:%s%n", node.x, node.y);
}
} else {
System.out.println("No path found.");
}
}
}
Kui algoritm töötab õigesti, peaks terminalis olema järgmine väljund:
1:1
1:2
1:3
2:3
3:3
3:4
4:4
Testimisel saime sama tulemuse, seega leiab algoritm antud kaardil korrektse tee alguspunktist sihtpunktini.
Liikumine kasutades A* algoritmi¶
NPC-de lisamine mängu¶
Esiteks oleks meil vaja NPC-d kuidagi mängijate kaartidele saada. Selleks võiks luua mõne packet'i klassi, millega saata infot AI kohta (tema id ja algne asukoht). See võiks olla midagi sellist:
package packets;
public static class OnSpawnNpc {
public int id;
public float x;
public float y;
}
Lihtsustatult öeldes oleks meil vaja igale uuele mängijale, kes mängu satub, saata iga NPC kohta infot. NPC-d saame eristada id järgi ning hiljem anname selle NPC id kaasa ka liikumise packet'itega.
Õige lähenemine oleks järgnev:
kui mingi mängija satub mängu -> loopi kõik NPCd
serveris läbi -> saada OnSpawnNpc objekt sellele mängijale
Kliendi poole NPC-de haldamine¶
Edasi peaksime selle packet'i kliendis ka vastu võtma ja nagu eelpool sai
mainitud, võiks meil ka kliendi poolel olla eraldi NPC klass ja
samuti ka list kõigist mängus olevatest NPC-dest. Võtame OnSpawnNpc packet'i kliendi poolel vastu,
ja teeme iga kord uue NPC objekti, kuhu
anname kaasa packet'iga saadud x ja y koordinaadid ning
id, mida on hiljem liikumiseks vaja.
Näide NPC klassist kliendi poolel
public class NPC extends Sprite {
public float x;
public float y;
public float moveX;
public float moveY;
private final int id;
public NPC(int id, float x, float y, Sprite sprite) {
super(sprite);
this.id = id;
this.x = x;
this.y = y;
this.moveX = x;
this.moveY = y;
}
Järgmisena leia üles kliendi pool fail, mis võtab vastu serveri saadetud paketid. Näiteks kui OnSpawnNpc pakett serverist saabub, siis see fail töötleb selle läbi ja lisab vastava NPC NPC-de listi. Nii saame oma NPC-id kliendi poole. Et seda teha, peame sinna faili (tavaliselt nimega src/client/NetworkListener.java või GameClient) lisama järgmise koodiosa:
if (object instanceof Network.OnSpawnNpc) {
final Network.OnSpawnNpc mov = (Network.OnSpawnNpc) object;
Gdx.app.postRunnable(new Runnable() {
@Override
public void run() {
play.addNpc(mov.id, mov.x, mov.y);
}
});
}
Siis leia mänguekraani klassi fail või mängu state’i klassi fail, mis hoiab NPC-de listi (tavaliselt see oleks näiteks klassis, kus on kõik see põhiloogika), ning lisa sinna järgmine koodiosa:
public void addNpc(int id, float x, float y) {
x *= 32;
y *= 32;
Sprite sprite = new Sprite(new Texture("some_sprite.png"));
NPC npc = new NPC(id, x, y, sprite);
aiNpcs.add(npc);
}
Selle meetodi ülesanne on lisada saadud NPC mängu.
Seal me defineerime ka mängukaardi suuruse (x *= 32 ja y *= 32).
Kuna kliendi poolel meie NPC klass extendib Sprite klassi, siis
lisame ka update() ja draw() meetodid.
NPC-de renderdamine kliendis¶
Liigume renderdamise juurde. Klassis, kus renderdate ka teisi asju, tuleks nüüd hakata renderdama ka NPC-sid. Ehk tänu sellele, te hakkate nägema seda teatud NPC-id.
private void renderNPCs() {
for (NPC npc : aiNpcs) {
npc.setPosition(npc.getX(), npc.getY());
npc.draw(renderer.getBatch());
}
}
renderNpcs() meetodi väljakutsumine tuleks panna teie render meetodi
alla, kus renderdate ka kogu muu mängu.
Nüüd peaksid vähemalt kõik NPC-d kaardile tekkima. Nende algsed asukohad
oleks vaja serveris ette anda vastavalt, kuidas teie mäng ette näeb.
Kui lõime NPC 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 kontrollite, et asukoht jääks 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.
NPC-de liikumise haldamine serveris¶
Liigume tagasi serverisse. Alguses tegime serverisse NPC klassi ning
ka meetodi moveThread, see võiks olla meetod, mis töötab 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
public static class OnNpcMove {
public int netID;
public float x;
public float y;
}
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, mis oli NPC failis. Me soovime, et seda meetodit kutsutaks
välja konstantselt teatud aja tagant. Selle jaoks on Javas olemas
ScheduledExecutorService.
public void moveThread() {
AStar aStar = new AStar(game.collisions);
Runnable botRunnable = () -> {
};
ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
executor.scheduleAtFixedRate(botRunnable, 2000, 300, TimeUnit.MILLISECONDS);
}
Natuke koodi selgituseks: 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 annate
kaasa oma mängu kaardi (collision'ite 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 muutuja path, mis on 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
Runnable botRunnable = () -> {
if (path == null || path.size() == 0) {
Random rand = new Random();
boolean foundGoodLocation = false;
while (!foundGoodLocation) {
int dstX = rand.nextInt((int) maxX - (int) minX + 1) + (int) minX;
int dstY = rand.nextInt((int) maxY - (int) minY + 1) + (int) minY;
if (game.collisions[dstY][dstX] == 0) {
foundGoodLocation = true;
path = aStar.findPath((int) x, (int) y, dstX, dstY);
}
}
Siin võtame mingi suvalise asukoha kaardil ning kui selle asukoha collision
on 0, ehk sinna on võimalik minna, siis leiame me teekonna NPC praegusest x ja
y koordinaatidest sinna asukohta järgneva reaga:
path = aStar.findPath((int) x, (int) y, dstX, dstY);
Muutujad minX, maxX, minY ja maxY võite kas ise defineerida,
või 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.
Nüüd kui meil on olemas path list, mille järgi liikuda, asume
nende asukohtade saatmise juurde. Nagu enne sai ära märgitud, siis findPath tagastab
teekonna alguspunktist lõpppunktini. Tee taastatakse küll alguses parent viidete abil
lõpppunktist alguspunktini, kuid seejärel pööratakse list ümber käsuga
Collections.reverse(path). Seetõttu saab NPC liikuda mööda listi algusest lõpuni.
Kui meie path ei ole null ja tema pikkus ei ole samuti 0,
teeme järgmist:
} else {
Network.OnNpcMove movement = new Network.OnNpcMove();
movement.netID = this.netId;
AStar.Node node = path.get(0);
path.remove(0);
movement.x = node.x;
movement.y = node.y;
x = node.x;
y = node.y;
server.sendEveryone(movement, -1, game.getGameId());
}
Võtame path listist 0 indeksiga elemendi ja eemaldame selle. Loome
OnNpcMove objekti 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 saadab siin näiteks packet'i igale
mängijale sendTCP meetodi abil. See on kasutusele võetud
sellepärast, et kui meil jookseb mitu mängu serveris paralleelselt, siis
ei saa me kasutada kryoneti sisseehitatud funktsiooni sendTCPAll.
private final ReentrantLock lock = new ReentrantLock();
public void sendEveryone(Object o, int exceptId, int gameId) {
lock.lock();
try {
for (Game game : this.games) {
if (game.getGameID() == gameId) {
for (GameConnection player : game.players) {
if (player != null) {
if (exceptId != -1) {
if (player.getID() != exceptId && player.gameId == gameId) {
player.sendTCP(o);
}
} else {
if (player.gameId == gameId) {
player.sendTCP(o);
}
}
}
}
}
}
} finally {
lock.unlock();
}
}
Meil on igal Game objektil oma id, ning iga game objekt sisaldab
listi hetkel sees olevatest mängijatest.
Multithreading¶
Kui te otsustate packet'ide saatmise lahendada sarnaselt selle näitega, 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 packet'e 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, vaid 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õiki 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.
Liikumise andmete uuendamine kliendi poolel¶
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
if (object instanceof Network.OnNpcMove) {
final Network.OnNpcMove mov = (Network.OnNpcMove) object;
Gdx.app.postRunnable(new Runnable() {
@Override
public void run() {
play.changeNpcLocation(mov.id, mov.x, mov.y);
}
});
}
public void changeNpcLocation(int id, float x, float y) {
x *= 32;
y *= 32;
for (NPC npc : aiNpcs) {
if (npc.getId() == id) {
npc.moveX = x;
npc.moveY = y;
}
}
}
Te võite selle ka omamoodi lahendada ning juhul kui te ei kasuta Kryonetti, toimub pakettide vastuvõtmine teistsugusel viisil.
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:
public void changeNpcLocation(int id, float x, float y) {
x *= 32;
y *= 32;
for (NPC npc : aiNpcs) {
if (npc.getId() == id) {
npc.setReceiveDifference(System.currentTimeMillis() - npc.lastReceive);
npc.setLastReceive(System.currentTimeMillis());
npc.moveX = x;
npc.moveY = y;
}
}
}
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 packet'i vahel.
Ja viimaks liigume NPC klassi ning lõpetame draw() ja
update() meetodid:
public void update(float deltaTime) {
double speed = (deltaTime / ((float) this.getReceiveDifference())) * 1000;
if (x > moveX) {
x -= (32 * speed);
}
if (x < moveX) {
x += (32 * speed);
}
if (y > moveY) {
y -= (32 * speed);
}
if (y < moveY) {
y += (32 * speed);
}
}
public void draw(Batch batch) {
this.update(Gdx.graphics.getDeltaTime());
super.draw(batch);
}
double speed = (deltaTime / ((float)this.getReceiveDifference())) * 1000;
Siin jagame deltaTime
praeguse packet'i ja eelmise packet'i 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.