Pelin tekoäly Aleksi Vuorela IIO14S1 Tietorakenteet ja algoritmit harjoitustyö Joulukuu 2015 Ohjelmistotekniikan koulutusohjelma Tekniikan ja liikenteen ala
1 1 Johdanto Harjoitustyön aiheena oli tutustua pelin tekoälyn toteuttamiseen vaadittaviin tietorakenteisiin ja algoritmeihin. Tein olio-ohjelmointi 2 kurrsin harjoitustyönä oman pelin C++ ohjelmointikielellä ja toteutin tekoälyn tähän peliin. Pelini on ylhäältäpäin kuvattu toimintapeli tai käytännössä ammuntapeli jonka kenttä rakentuu tiilistä. Tarkemmin tekemäni peli on käsitelty olio-ohjelmointi 2 kurssin harjoitustyöni dokumentaatiossa: http://student.labranet.jamk.fi/~h8346/cpp/harkkatyo/olio2harjoitustyo.html Tekoäly oli siis tarkoitus tehdä pelin vihollisille eli zombeille, jotka yrittävät tietysti tappaa pelaajan. Lähdin miettimään pelini kannalta kaikista tärkeimpiä ominaisuuksia mitä tekoälyltä halusin ja kolme keskeistä asiaa nousi pinnalle. Kaikista tärkein asia oli, että vihollisilla olisi erilaisia tiloja, joiden välillä siirrytään vihollisen havaintojen perusteella. Vihollinen käyttäytyisi tietysti eri tavalla riippuen siitä, missä tilassa se on. Tähän ratkaisuksi löytyi finite state machine eli äärellinen tilakone niminen tekniikka. Toinen keskeinen asia pelini kannalta oli se, että viholliset eivät näkisi pelaajaa pelikentän seinien läpi. Tähän avuksi löytyi Bresenhamin algoritmi. Viimeinen asia oli jo kurssilta tutuksi tullut lyhimmän reitin etsiminen eli etsitään lyhin reitti vihollisen sijainnista pelaajan luokse. Tähän käytin A*-algoritmia. Näillä tekniikoilla sain aikaiseksi pelini vihollisille simppelin tekoälyn ja seuraavissa luvuissa käsittelen kunkin kyseisistä tekniikoista sekä selitän, kuinka ohjelmoin ne C++ ohjelmointikielellä.
2 2 Finite state machine Finite state machine on yksi eniten käytetyistä tekoälytekniikoista peleissä. Tilakone koostuu äärellisestä määrästä tiloja, joiden välillä on tilasiirtymiä. Pelien tekoälyssä käytetään termiä agentti oliosta, joka tekee havaintoja pelimaailmasta ja suorittaa itsenäisesti niistä riippuvia toimintoja. Kun jatkossa käytän termiä agentti, se tarkoittaa siis pelin vihollista eli zombia. Pelissäni tilasiirtymät aiheutuvat agentin havainnoista. Agentin käyttäytyminen määräytyy tilakoneen tilan mukaan. Tilakoneessani on kolme eri tilaa: normal, alert ja evasion. Normal-tila on oletustila, josta kaikki agentit aloittavat. Tässä tilassa agentti vain seisoskelee paikallaan odotellen jostain havainnosta aiheutuvaa tilasiirtymää. Alert-tilaan siirrytään, kun agentti havaitsee pelaajan. Havaitsemiseen on kaksi ehtoa: pelaajan on ensinnäkin oltava näköetäisyydellä, ja toiseksi näköyhteyttä ei voi estää mikään, esimerkiksi seinä. Tämä näköyhteyden testaaminen tehdään Bresenhamin algoritmilla, josta lisää myöhemmässä luvussa. Mikäli pelaaja on tarpeeksi lähellä ja mikään ei estä näkemistä, siirrytään alert-tilaan. Tässä tilassa agentti yrittää jatkuvasti päästä pelaajan lähelle hyökkäys etäisyydelle. Mikäli agentti on tarpeeksi lähellä pelaajaa, se hyökkää ja pelaaja menettää terveyspisteitä. Evasion-tilaan siirrytään silloin, jos alert-tilan aikana agentti menettää näköyhteyden pelaajaan, esimerkiksi kun pelaaja piiloutuu seinän taakse tai pääsee liian kauas pakoon. Tässä tilassa aluksi kirjataan ylös pelaajan viimeisin tunnettu sijainti ja mennään sinne. Viimeisimmän sijainnin ympäriltä etsitään pelaajaa jonkin aikaa, ja mikäli pelaajaa ei löydetä, siirrytään normal-tilaan. Jos pelaaja löydetään, palataan takaisin alert-tilaan. Seuraavalla sivulla on kuvassa 1 esitetty tilakaavio, joka havainnollistaa tiloja ja siirtymiä niiden välillä.
3 Kuva 1. Tilakaavio. Tilakoneen toteuttamiseen on monia vaihtoehtoja, mutta tämän homman kannalta parhaaksi vaihtoehdoksi katsoin stack-based finite state machinen eli pinorakenteeseen pohjautuvan äärellisen tilakoneen. Pino tietorakenne on jo kurssilta tuttu käsite, ja tässä sitä pystyi hyödyntämään nerokkaasti käytännössä. Pinon päällimmäisenä on siis sen hetkinen aktiivinen tila, jonka perusteella tehdään jotain toimintoja. Kun tilasiirtymä tapahtuu, esimerkiksi normal- ja alert-tilan välillä, lyödään uusi tila (alert) pinon päällimmäiseksi, jolloin siitä tulee sen hetkinen aktiivinen tila. Jos halutaan siirtyä aikaisempaan tilaan, esimerkiksi ollaan evasiontilassa ja löydetään pelaaja, voidaan pinon päällimäinen tila (evasion) heittää pois, jolloin edellinen tila (alert) tulee taas aktiiviseksi. Pino rakenteen avulla tilojen hallinta saadaan siis hyvin simppeliksi ja vain yksi tila voi olla aktiivinen kerrallaan, mikä on koko finite state machinen idea!
4 Toteutus C++:lla Sitten koko homman varsinaiseen toteutukseen C++:lla. En nyt lähde kaikkea koodia avaamaan tässä tarkasti, sillä tämä ei ole ohjelmoinnin kurssi, vain keskeiset asiat finite state machinen toiminnan kannalta. Kuva 2. Keskeiset funktiot ja muuttujat. Kuvassa 2 esitellään Enemy header-tiedostossa olevien keskeisten funktioiden ja muuttujien nimet sekä kommentit siitä, mitä ne tekevät. Kuvan ylimmässä kohdassa näkyvät siis tilakoneen eri tilat tallennettuna enum luokkaan nimeltä State. Updatefunktiota kutsutaan jokaisella framella, joka siis päivittää agenttia kokoajan. Tämä on varmasti finite state machinen toiminnan kannalta kaikkein tärkein funktio, joten katsotaan sen toteutusta tarkemmin seuraavalla sivulla. Seuraavaksi tulevat eri tiloille ominaiset funktiot eli idle, attack ja search. Idlefunktiota kutsutaan kun aktiivinen tila on normal, attackia kun tila on alert ja searchia kun tila on evasion. Funktio getcurrentstate kertoo sen hetkisen aktiivisen tilan. Viimeisenä on pino nimeltä statestack, jonne siis tilat tallennetaan ja pinon päällimmäinen elementti on sen hetkinen aktiivinen tila. Kuten huomataan, C++:ssa on pino tietorakenne jo valmiiksi toteutettu standardikirjastossa, joten sitä ei onneksi tarvitse itse koodata.
5 Kuva 3. Update-funktio. Sitten update-funktiosta tarkemmin. Se tsekkaa aina aluksi etäisyyden pelaajan ja agentin välillä sekä piirtää viivan Bresenhamin algoritmilla, jonka avulla testataan ettei mikään ole estämässä näköyhteyttä pelaajaan, tämän toteutuksesta seuraavassa luvussa. Seuraavaksi tulee switch-rakenne, jossa aluksi katsotaan mikä tila on tällä hetkellä aktiivinen getcurrentstate-funktiolla. Aktiivisen tilan perusteella kutsutaan joko idle-, attack- tai search-funktiota. Esimerkiksi jos aktiivinen tila on normal, kutsutaan idle-funktiota joka näyttää seuraavalta. Kuva 4. Idle-funktio.
6 Idle-funktiossa aluksi katsotaan, onko pelaaja näköetäisyydellä ja nähdäänkö häntä (eli mikään ei estä näköyhteyttä, checklos niminen funktio). Mikäli nämä ehdot toteutuvat, pelaaja on nähty, jolloin pinoon statestack pusketaan päällimmäiseksi elementiksi alert-tila. Nyt kun seuraavalla framella update-funktio pyörähtää alusta taas, agentin tilaksi muuttuu alert ja kutsutaan attack-funktiota. Jos taas ehdot eivät toteudu, eli pelaaja on liian kaukana tai seinän takana, agentti ei tee mitään muuta kuin seisoskelee paikallaan sillä tekoäly on (vielä) hyvin simppeli ja tyhmä. Kuten aiemmin totesin, finite state machinet ovat yksi eniten käytetyistä tekoälytekniikoista peleissä, ja niiden avulla saadaan aikaiseksi hyvinkin monimutkaisia kokonaisuuksia. Alla olevassa kuvassa 5 on esitelty vanhan klassikko pelin Quaken (1996) Shambler hirviön tilakone, joka on toteutettu täysin samalla tavalla, mutta siinä on lisäksi käytetty alemman tason tilakoneita, eli Attack State ja Melee Attack jakautuvat alitiloihin. Kuva 5. Quaken Shambler hirviön tilakone.
7 3 Bresenhamin algoritmi Toinen keskeinen asia pelini kannalta oli se, että agentit eivät näkisi pelaajaa pelikentän seinien läpi. Tähän avuksi löytyi Bresenhamin algoritmi. Homman ideana on siis piirtää jana pelaajan ja agentin välille Bresenhamin algoritmin avulla ja katsoa, ettei mikään janan pisteistä osu esteeseen, kuten seinään. Janaa ei kannata piirtää tavallisen suoran yhtälön y = kx + b mukaan, koska laskemisessa tarvitaan silloin runsaasti prosessoriaikaa vieviä kertolaskuja. DDA-algoritmi käyttää kertolaskujen tilalla yhteenlaskuja. DDA-algotimin heikkous on kuitenkin siinä, että sitä käytettäessä joudutaan tekemään jatkuvasti pyöristyksiä. Parempi vaihtoehto janan piirtämiseksi on Bresenhamin algoritmi, joka käyttää pelkästään kokonaislukuaritmetiikkaa. Algoritmi on vanha, vuodelta 1962 peräisin, mutta sitä käytetetään yhä paljon grafiikkaohjelmoinnissa vaikka monia uusia, moderneja algoritmejä on kehitetty, sillä se on nopea ja yksinkertainen. Sitä voidaankin pitää grafiikkaohjelmoinnin alkeisoperaationa. Algoritmin perusideana on iteroida janan x- ja y-komponenteista pidempää ja korottaa sopivin välein piirtokorkeutta. Sopiva väli ei ole kokonaisluku, joten murtolukua simuloidaan virhemuuttujan avulla. Enempää on varmaan turha selittää, sillä se menee enemmän matematiikan puolelle, mutta yksinkertaistettuna lasketaan kokonaislukujen jakolaskua dy/dx. Algoritmi löytyy valmiiksi toteutettuna netistä joka ikiselle ohjelmointikielelle, ja pienellä soveltamisella sain sen omaan peliinikin toimimaan. Kooditasolla janan piirtämistä on varmaan turha alkaa tarkemmin käymään läpi, mutta oleellinen pointti on tekemäni checklos (check line of sight) funktio.
8 Kuva 6. Check line of sight funktio. Bresenhamin algoritmilla siis lasketaan omassa funktiossaan janan kaikkien pisteiden koordinaatit, ja ne tallennetaan vektoriin nimeltä points. For-loopissa käydään sitten jokainen janan pisteistä läpi, ja katsotaan levelin toucheswall-funktiolla, osuuko mikään janan pisteistä pelikentän seiniin. Tämän tuloksena siis tiedetään, onko näköyhteyden tiellä seinää estämässä. Bresenhamin algoritmi toimii hyvin line of sightin toteuttamiseen simppeleissä, ylhäältäpäin kuvatuissa peleissä, mutta esimerkiksi 3D-peleissä tarvitaan hyvinkin monimutkaisia menetelmiä line of sightin toteuttamiseen. 4 A*-algoritmi A*-algoritmi on kaikkein suosituin lyhimmän reitin hakualgoritmi pelien tekoälyssä. Se yhdistää kurssilta tutuksi tulleen Dijkstran algoritmin sekä greedy best-first searchin parhaimmat puolet, joka johtaa parhaimpaan tulokseen kun etsitään lyhintä reittiä yhteen kohteeseen. Dijkstran algoritmi toimii hyvin lyhimmän reitin löytämiseen, mutta kuluttaa turhaan resursseja etsiessään reitin kaikkiin mahdollisiin solmuihin, myös sellaisiin, jotka eivät vaikuta lupaavilta lyhimmän reitin kannalta päämäärään. Greedy best-first search taas toimii siten, että tehdään funktio, joka laskee jatkuvasti kuinka kaukana päämäärästä ollaan, ja priorisoidaan niitä reittejä, jotka ovat lähempänä päämäärää. Tämä on kaikista nopein tapa, jos kentässä ei ole mitään esteitä, kuten seiniä. Ongelma tässä tulee tietysti siinä, jos reitillä vastaan tulee jokin este ja joudutaan lähteä etsimään kiertotietä.
9 A*-algoritmi käyttää hyödykseen näitä molempia: Dijkstran tapaa laskea lyhin reitti lähtösolmusta ja greedy best-first searchin tapaa priorisoida reittejä, jotka ovat lähimpänä päämäärää. Kuva 7. Dijkstra vs greedy best-first vs A*.
10 Kuten kuvasta 7 nähdään hyvin, A* etsii lyhintä reittiä aluksi vain siltä alueelta, joka on lähimpänä päämäärää ja laskee lyhimmän reitin lähtösolmusta päämäärään. Haku aluetta kasvatetaan kauemmaksi päämäärästä, jos reittiä ei löydetä lähialueelta esteen takia, mutta kaikissa mahdollisissa solmuissa ei käydä turhaan, kuten Dijkstran algoritmissa, joten lyhin reitti löydetään paljon nopeammin. Valitettavasti en kerennyt vielä toteuttaa peliini A*-algoritmia C++:lla, sillä sen koodaamisessa on melkoinen työ. Se on kyllä hyvin jo vauhdissa, mutta kooditasolla on tullut törmättyä ongelmallisiin tilanteisiin. 5 Yhteenveto Aluksi harjoitustyön piti käsitellä pelkästään finite state machineja, mutta asia oli hyvin mielenkiintoista ja tuli innostuttua vähän liikaa, tunteja vierähti melkoisesti. Paljon enemmän aikaahan tässä meni asioiden toteuttamiseen kooditasolla omaan peliini kuin algoritmien toimintaan perehtymiseen. Aion varmasti saada peliini A*- algoritmin toimintakuntoon vapaa-ajallani ja tulen parantelemaan tekoälyä uusilla ominaisuuksilla. Minulla on jo paljon ideoita, esimerkiksi: kun vihollinen huomaa pelaajan, se hälyttää myös muut lähialueella olevat viholliset, vihollinen kuulisi askelten äänistä jos pelaaja lähestyy takaapäin tai jos se kuulisi ammuskelua lähettyvillä, ennalta-arvattavuuden parantamista, uusia tiloja, kokonaan uudenlaisia, uniikkeja tekoälyjä eri tyyppisille vihollisille kuten pomo taisteluille. Tulen päivittelemään peliäni GitHubiin aina kun saan siihen jotain uutta esittely kuntoon. Lopuksi laitan vielä linkin videoon, josta näkee tekoälyn toiminnassa pelissäni (videon toimivuus testattu vain Chromella): http://student.labranet.jamk.fi/~h8346/cpp/harkkatyo/liitteet/video.mp4
11 Lähteet 1. Tekoäly tietokonepeleissä. Tampereen teknillisen yliopiston luentokalvot. Viitattu 4.12.2015. Http://www.cs.tut.fi/~peliohj/tekoaly.pdf. 2. Bevilacqua, F. 2013. Finite-State Machines: Theory and Implementation. Artikkeli sivustolla Tuts+. Viitattu 4.12.2015. Http://gamedevelopment.tutsplus.com/tutorials/finite-state-machinestheory-and-implementation--gamedev-11867. 3. Nystrom, R. 2014. Game Programming Patterns. Genever Benning. 4. Bresenhamin algoritmi. Artikkeli Wikipediassa. Viitattu 4.12.2015. Https://fi.wikipedia.org/wiki/Bresenhamin_algoritmi. 5. Savioja, L. 2007. Viivan toteutus. TKK:n luentokalvot. Viitattu 4.12.2015. Http://www.tml.tkk.fi/Opinnot/T- 111.4300/2007/lectures/tg2c_viivantoteutus.pdf. 6. Introduction to A*. Artikkeli sivustolla Red Blob Games. Http://www.redblobgames.com/pathfinding/a-star/introduction.html. Liitteet Https://github.com/vuoale/GHOST.git Lähdekoodit ovat luettavissa varmaan helpommin GitHub repostani kuin että alkaisin koodeja tähän raporttiin laittamaan. Keskeiset koodit tekoälyn kannalta löytyvät GHOST kansion alta tiedostoista Enemy.cpp ja Enemy.h.