Vesipisarapelin toinen versio

Vesipisarapelin ensimmäisessä versiossa pelilaudalla olevat vesipisarat poksahtaessaan roiskuttivat vettä toisiin vesipisaroihin, ja tavoitteena oli saada pelilauta tyhjennettyä vedestä. Jatkamme nyt vesipisarapelin parissa ja toteutamme siitä uuden version käyttäen uusia oppimiamme asioita kuten osoittimia.

Ensimmäisessä versiossa vesipisaroiden välinen kommunikointi toimi siten, että pisaran poksahtaessa ruutuolio lisäsi suoraan vettä niihin ruutuolioihin, joihin vesi poksahduksen seurauksena roiskahtaisi. Tämä ei kuitenkaan täysin vastannut esikuvana olleen peliversion toimintaa, koska siinä roiskeet siirtyvät pelilaudalla eteenpäin ruutu kerrallaan. Tällöin roiske saavuttaa viereisen ruudun nopeammin kuin pelilaudan toisella puolella olevan ruudun, mikä vaikuttaa esimerkiksi pisaroiden poksahtamisjärjestykseen.

Pelin moduulit ja tietorakenteet

Uudessa versiossa toteutamme pelin siten, että pelilaudalla ei ole ruutuolioita, vaan vesipisaraolioita. Erona on se, että ensimmäisessä versiossa jokaisessa pelilaudan ruudussa oli olio. Nyt toteutamme version, jossa jokaisessa ruudussa on osoitin. Jos ruutu sisältää vesipisaran, vesipisaraolio on talletettuna osoittimen päähän. Jos ruutu on tyhjä, osoitin on nullptr.

Alla olevassa kuvassa näkyy kierroksen 4 pelilauta (vasemmalla) ja versio, jota alamme toteuttaa nyt (oikealla).

Vasemmalla kierroksen 4 pelilauta ja oikealla versio, jota alamme toteuttaa

Uudessa versiossa on pisaraolioiden lisäksi roiskeolioita, jotka liikkuvat pelilaudalla. Poksahtava vesipisaraolio muodostaa pelilaudalle neljä roiskeoliota, jotka etenevät laudalla ja kuljettavat lisättävän veden siihen kohtaan lautaa, jossa se lisätään toiseen vesipisaraolioon. Uudessa versiossa on siis enemmän olioita, jotka kommunikoivat keskenään. Modulaarisuuden lisäksi saamme esimerkin olio-ohjelmoinnista.

Aivan kaikkea toiminnallisuutta on haastava toteuttaa pelkästään pisaraolioon ja roiskeolioon. Tarvitaan myös moduuli, joka vastaa pelin toiminnasta kokonaisuutena. Tämän moduulin nimeämme pelilaudaksi. Lopputuloksena on seuraavanlainen moduulijako:

  • Luokka Drop toteuttaa yksittäisen vesipisaran, eli se sisältää esimerkiksi pisaran vesimäärän ja vastaa pisaran poksahtamisesta oikeaan aikaan.
  • Luokka Splash toteuttaa yksittäisen roiskeen, eli se sisältää tiedon siitä, missä kohdassa roiske on ja osaa edetä oikeaan suuntaan.
  • Edellisistä poiketen luokasta GameBoard luodaan vain yksi olio, joka hallinnoi pisaraolioita ja roiskeolioita.
  • Pääohjelmamoduuli muodostaa pelilautaolion ja vastaa käyttäjän syötteiden käsittelemisestä.

Ennen kuin alat tutkia hakemistosta examples/10/waterdrop_game_v2 löytyvää ohjelmakoodia tarkemmin, kannattaa suorittaa ohjelma ainakin kertaalleen, joten sinun pitää kopioida se omaan student-hakemistoosi. Ohjelmakoodin toimintaa on helpompi ymmärtää, kun olet nähnyt, miten toiminta esitetään ascii-grafiikkana näytöllä.

Olioiden välinen kommunikointi

Ohjelma on monimutkainen, eikä sitä ole välttämätöntä tutkia kokonaisuudessaan. Olio-ohjelmointinäkökulman ymmärtämiseksi käymme läpi yhden vesipisaran tipauttamista pelilaudalle.

Syötä satunnaislukugeneraattorille siemenarvoksi 7 ja sitten ensimmäisen vesipisaran koordinaateiksi 4 11. Tutki ohjelman tulostetta ja ohjelmakoodia selvittääksesi, mitä ohjelman suorituksessa tapahtuu. Ohjelmakoodista pitäisi löytyä seuraavat kohdat:

  1. Funktio main kutsuu pelilaudan metodia addWater, joka havaitsee, että ruudusta (4, 11) löytyy osoitin, jonka päässä on pisaraolio. Pelilautaolio kutsuu kyseisen pisaraolion addWater-metodia.

  2. Koordinaateissa (4, 11) sijaitseva pisara poksahtaa. Se muodostaa neljä roiskeoliota, joiden kaikkien sijaintina on ruutu (4, 11), mutta joista jokaisella on eri suunta. Metodille addWater on välitetty pelilaudan splashes_-vektori viiteparametrina, joten metodin suorittamat push_backin kutsut kohdistuvat tähän vektoriin, eli uudet roiskeoliot menevät suoraan pelilaudalle.

  3. Funktio main kutsuu pelilaudan tulostusmetodia, joka ensi töikseen kutsuu metodia moveSplashes.

  4. Metodi moveSplashes käy läpi kaikki pelilaudan splashes_-vektorissa olevat roiskeoliot ja kutsuu jokaiselle näistä move-metodia.

  5. Ylös- ja alaspäin menossa olevat roiskeet siirtyvät ruutuihin, joissa on vesipisara. Metodi moveSplashes huomaa tämän ja kutsuu metodia addWater oikealle pisaraoliolle ja tuhoaa roiskeolion. Tämän vaiheen voit havaita seuraavassa tulostuvassa pelilautakuvassa siten, että ruudun (4, 11) ylä- ja alapuolella olevat pisarat ovat kasvaneet yhtä pykälää isommiksi.

  6. Sen sijaan vasemmalle ja oikealle päin menossa olevat roiskeet päätyvät ensimmäisen move-metodikutsunsa jälkeen ruutuun, jossa on nullptr sen merkiksi, että ruudussa ei ole pisaraoliota. Pelilaudan tulostuksessa tämä näkyy siten, että roisketta kuvataan merkeillä < ja >.

    Pelilaudan tulostaminen on monimutkaisempaa, kuin kierroksella 4 toteutetussa ohjelmassa, koska tyhjää ruutua ei voi suoraan tulostaa välilyöntimerkillä, vaan ensin pitää selvittää, onko ruudussa roiske tai jopa useampia roiskeita. Tyhjän ruudun tulostusmerkin valitseminen on toteutettu omassa funktiossaan, jotta tulostusfunktio olisi selkeämpi. Todennäköisesti ymmärrät ohjelman toiminnan ilman metodin droplessSquareChar tutkimistakin.

  7. Pelilaudan tulostusfunktio toimii siten, että tulostusta toistetaan do - while -rakenteessa niin kauan, kuin splashes_-vektorissa on jäljellä yksikin roiske.

    Pelilaudan yksi roiske etenee ruutuun (2, 11) asti siten, että ennen jokaista tulostusta pelilaudan tulostusmetodi kutsuu metodia moveSplashes. Sama pätee myös roiskeelle, joka etenee ruutuun (6,11), mutta keskitytään tässä vaiheessa vain ensiksi mainittuun roiskeeseen, joka liikkuu vasemmalle.

  8. Kun roiske on ruudussa (2, 11), metodi moveSplashes huomaa jälleen tämän ja lisää vettä kyseisessä ruudussa olevaan pisaraolioon sen addWater-metodia käyttäen sekä tuhoaa roiskeolion.

  9. Veden lisääminen ruudussa (2, 11) olevaan pisaraolioon saa myös aikaan poksahduksen, eli pisaraolio muodostaa taas neljä uutta roiskeoliota. Metodi moveSplashes ei kuitenkaan välittänyt addWater-metodille parametrina pelilaudan splashes_-vektoria, vaan toisen väliaikaissäiliön. Siksi nämä roiskeet eivät ala heti siirtyä moveSplashes-metodin toistorakenteessa. Viimeisenä toimenaan moveSplashes tallentaa uudet muodostuneet roiskeet pelilaudan splashes_-vektoriin, minkä vuoksi pelilaudan tulostus jatkuu.

  10. Seuraavan kerran pelilautaa tulostettaessa laudalla on siis neljä roisketta ruudussa (2, 11). Tulostusfunktio kutsuu metodia droplessSquareChar, joka palauttaa ruudun (2, 11) kohdalla “sotkumerkin” *, koska yhteen ruutuun ei voi tulostaa neljän erisuuntaan menossa olevan roiskeen merkkejä. Tämä “sotkumerkki” siis kuvaa tavallaan myös vesipisaran poksahtamista.

  11. Pelilaudan tulostus jatkuu taas siten, että ensin tulostusfunktio kutsuu metodia moveSplashes. Roiskeiden siirtyessä jo läpi käydyllä tavalla osuu kolme roiskeista suoraan vesipisaroihin, ja oikealle lähtenyt roiske tyhjään ruutuun.

  12. Hyvin samanlaista tapahtuu ruudussa (6,11). Erona on tosin se, että nyt vain kaksi roisketta osuu pisaroihin, nimittäin ylös ja oikealle etenevät. Alas ja vasemmalle etenevät roiskeet osuvat tyhjään ruutuun.

  13. Kun oikealle etenevä roiske (ruudusta (2, 11)) ja vasemmalle etenevä roiske (ruudusta (6, 11)) ovat yhtä aikaa ruudussa (4, 11) tulostuu jälleen “sotkumerkki” *, joka ei tällä kertaa esitä uutta poksahdusta, vaan sitä, että eri suuntiin etenevät pisarat olivat hetken samassa ruudussa.

Samaan tyyliin roiskeet jatkavat laudalla etenemistä, kunnes muutaman pelilaudan tulostuksen jälkeen kaikki roiskeet ovat kadonneet.

Pisaraolioiden ja roiskeolioiden vastuut

Edellistä toimintaesimerkkiä tutkiessasi huomasit, että ohjelman rakenne on melko monimutkainen. Herää kysymys, että oliko tästä olioiden paljoudesta nyt jotain hyötyä. Olisiko kuitenkin ollut parempi, että pelilauta olisi vain sisältänyt tiedot pisaroiden vesimääristä ja roiskeiden sijainneista, ja tämä pelilautaolio olisi sitten hallinnut koko toimintaa?

Roiskeiden kohtaaminen

Yhtenä esimerkkinä siitä, mitä hyötyä on pienestä asiasta vastuun ottavasta oliosta, on se kohta edellistä suoritusta, jossa kaksi roisketta etenevät päinvastaisiin suuntiin ja kohtaavat toisensa samassa ruudussa. Kun katsot luokkaa Splash ja mietit, miten tämä kahden (tai useammankin) roiskeen kohtaaminen monimutkaistaa roiskeen liikkumisalgoritmia, niin huomaat, että ei mitenkään. Splash-luokan vastuulla on yksittäisen roiskeolion liikkuminen. Vaikka kaksi oliota kohtaisivatkin samassa ruudussa, ei se monimutkaista yksittäisen roiskeen toimintaa. Jos meillä olisi tämän sijaan pelilautaolio, joka hallinnoisi kaikkia pisaroihin ja roiskeisiin liittyviä yksityiskohtia, vaikuttaisi tämä asia ehkä roiskeiden siirtämisalgoritmiin jollain toisella tavalla. Olio-ohjelmoinnin ideana siis oli muodostaa pieniä osia, jotka vastaavat omasta toiminnastaan itsenäisesti.

Olioiden tuhoutuminen

Edellä kävimme jo läpi, miten pelilautaolio huolehtii roiskeolioiden tuhoamisesta metodissa moveSplashes. Sen sijaan pisaraolioiden tuhoutuminen tapahtuu hyvin erityylisesti. Jos pisaraolion metodissa addWater käy niin, että pisaran maksimikoko ylittyy ja se poksahtaa, tuhoaa pisaraolio itse itsensä roiskeiden muodostamisen jälkeen. Tämä tapahtuu siten, että pisaraolio kutsuu pelilautaolion metodia removeDrop.

On tärkeää, että tämä itsetuhoinen toimenpide on ehdottomasti viimeinen asia, mitä metodissa tapahtuu, koska olion toiminta sen jälkeen, kun sille varattu muisti on vapautettu, on määrittelemätöntä.

Tämä ero roiskeolioiden tuhoamisessa ja pisaraolioiden tuhoutumisessa kuvastaa hyvin sitä, mitä tarkoitetaan, kun puhutaan moduulin vastuualueesta. Pisaraolio on omatoiminen tuhoutumisessaan. Tämä helpottaa pelilautaolion toteuttamista. Sen sijaan pelilautaolion moveSplashes-metodissa suoritetaan aika paljon asioita, jotka voisivat hyvin kuulua roiskeolion vastuulle. Toisenlaisessa toteutuksessa roiskeolio voisi suoraan lisätä veden siihen pisaraolioon, johon se törmää, ja poistaa itse itsensä. Koska pelilautaolio suorittaa joitakin roiskeiden toimenpiteitä, on se paisunut suuremmaksi kuin ehkä on hyvä. Toisaalta, jos roiskeoliolle olisi laitettu enemmän vastuuta, tarvitsisi sekin pääsyn käsiksi pelilautaan, mikä monimutkaistaisi ohjelman tietorakenteita. Jokaisessa erilaisessa ratkaisussa on monta eri näkökulmaa. Ei ole yhtä oikeaa ratkaisua, vaan ohjelmoijan pitää osata punnita eri ratkaisujen hyviä ja huonoja puolia.

Yhteenveto

Epätäydellisyydessään tämä ohjelman toteutus tarjoaa siis hyvän mahdollisuuden filosofiseen pohdintaan siitä, mikä toteutuksen moduulijaossa on hyvää ja mikä huonoa. Onko moduulien vastuualueet valittu järkevästi? Vastaako jokainen moduuli niistä asioista, mitkä sen vastuulle kuuluvat? Jos vastuiden jakoa muutettaisiin, yksinkertaistaisiko vai monimutkaistaisiko se ohjelmaa?

Näihin kysymyksiin perehdytään enemmän tätä opintojaksoa seuraavilla opintojaksoilla (esimerkiksi Ohjelmointi 3: Tekniikat sekä Ohjelmistojen suunnittelu). Mutta jos olet kiinnostunut aiheesta, kannattaa sitä pohtia omia projekteja tehdessä muutenkin.