Vesipisarapelin ensimmäinen versio

Tutkitaan hieman suurempaa ohjelmaa, joka sisältää sekä olioita että vektorin käyttöä. Toteutetaan peli, joka toimii samantyylisesti kuin Splash back.

Toteutettavassa pelissä pelilaudalla on ruutuja, joista osa on tyhjiä ja osa sisältää erikokoisia vesipisaroita. Tavoitteena on saada lauta tyhjennettyä vedestä. Pelaajalla on vettä tankissa, ja hän voi tipauttaa yhden tipan vettä johonkin ruuduista ja näin kasvattaa pisaran kokoa. Ruudussa olevan pisaran suurin mahdollinen koko on 4 tippaa vettä. Jos ruutuun tulee enemmän vettä, pisara poksahtaa neljään osaan, ja roiskeet lähtevät etenemään laudalla neljään eri suuntaan, kunnes joko putoavat laudan reunalta pois tai osuvat johonkin laudalla olevista vesipisaroista ja lisäävät kyseisen pisaran veden määrää yhdellä tipalla. Tavoitteeseen eli tyhjään pelilautaan pääsee lisäämällä vettä sopiviin kohtiin siten, että pisarat roiskahtavat pois laudalta.

Hae opintojakson Git-keskustietovarastostasi ohjelmakoodi, jossa on toteutettu vesipisarapelistä yksinkertainen ASCII-grafiikalla varustettu versio. Löydät ohjelman hakemistosta examples/04/waterdrop_game_v1. Tutkitaan nyt tämän ohjelman toteutusta.

Neuvo

Voi olla, että haluat ohjelmaa tutkiessasi myöskin muokata sitä. Kokeilemalla oppii usein enemmän kuin vain lukemalla. Muista kuitenkin, että hakemiston examples sisälle ei saa tehdä muutoksia, että pystyt jatkossa vetämään uusia esimerkkejä hakemistoon kurssin keskustietovarastosta.

Kopioi siis hakemisto examples/04/waterdrop_game_v1 muokkauksia varten hakemistoksi student/04/waterdrop_game_v1 ja avaa tämä Qt Creatorissa.

Voi olla selvempää myöskin suorittaa ohjelma kertaalleen ennen kuin alat lukea materiaalia ja tutkia ohjelmakoodia yksityiskohtaisemmin. Kun ohjelma tulostaa kehotteen x y>, käyttäjän pitää syöttää sen ruudun x- ja y-koordinaatit, johon hän haluaa pudottaa tipan vettä. Tämä on vähän hankalampaa kuin graafisessa käyttöliittymässä, mutta tällä päästään alkuun. Pelilaudan origo on vasemmassa yläkulmassa, ja käyttämisen helpottamiseksi yläreunassa on tulostettuna X-akselin koordinaatteja ja vasemmassa reunassa Y-akselin koordinaatteja.

Ohjelman tietorakenteet: Pelilauta ja vesipisarat

Pelilauta (board) on kaksiulotteiden ruudukko, joka voidaan toteuttaa kaksiulotteisena vector-tietorakenteena.

Palautetaan mieleen edelliseltä ohjelmointikurssilta, miten tietorakenteita voidaan yhdistellä, ja miten tällaisissa tilanteissa viittaamme tietorakenteiden alkioihin.

Kaksiulotteinen vektori (alkiot muistissa peräkkäin (riveittäin))

Halutessaan saman kuvan voi piirtää myös toisessa järjestyksessä, joka saattaa olla havainnollisempi (X- ja Y-koordinaatit eivät mene niin helposti sekaisin). Aina, kun tietorakenteen hahmottaminen tuntuu hankalalta, kannattaa ottaa kynä käteen, ja piirtää itselleen kuva.

Kaksiulotteinen vektori (piirrettynä ruudukkomuotoon)

Pelilaudan ruuduissa on vesipisaroita. Vesipisara sisältää tietoa, esimerkiksi kuinka monta tippaa vettä siinä on. Vesipisara sisältää myös toimintoja, siihen voi esimerkiksi lisätä vettä, jolloin se joko kasvaa tai poksahtaa. Koska vesipisara sisältää sekä tietoa että toimintoja, voidaan se toteuttaa oliona.

Suunnittelemassamme pelilaudassa on kaksiulotteinen vector-rakenne, jonka jokaisessa alkiossa on yksi olio. Pelilaudalla voi kuitenkin olla ruutuja, joissa ei ole yhtäkään tippaa vettä. Voiko olla vesipisaraolio, joka ei kuitenkaan sisällä yhtään vettä? Tämän vuoksi on loogisempaa antaa oliolle nimeksi Square kuin Waterdrop. Nyt siis kaksiulotteisen vektorin jokaisessa alkiossa on tallennettuna Square-tyyppinen olio, joka voi joko sisältää vettä tai olla tyhjä:

class Square { ... };
std::vector< std::vector< Square > > board;

Koska tätä tietorakennetta selvästikin käytetään ohjelmassa useita kertoja, voimme määritellä:

using Board = std::vector< std::vector< Square > >;

Tämä vähentää merkkijonon std::vector< std::vector< Square > > toistamisen tarvetta, eli selkeyttää ja lyhentää ohjelmakoodia. Mutta minne tämä using-lause pitäisi kirjoittaa? Vastataan kysymykseen tutkimalla seuraavaksi sitä, miten ohjelma on jaettu tiedostoihin.

Ohjelman tiedostot

Ohjelma on jaettu kolmeen tiedostoon seuraavasti:

  • main.cpp sisältää pääohjelman ja joitakin funktioita, joita pääohjelmassa käytetään apuna.
  • square.hh sisältää luokan rajapintojen lisäksi myös muita määrittelyjä, joita tarvitaan koko ohjelmassa.
  • square.cpp sisältää vain luokan metodien määrittelyt.

Tässä kohdassa voisi pohtia olisiko selkeämpää laittaa muut määrittelyt tiedoston square.hh sijaan kokonaan omaan .hh-tiedostoonsa. Tavallaan tämä olisi loogisempaa, koska tiedoston square.hh on tarkoitus sisältää luokan määrittelyosa.

Toisaalta, kun mietimme, että ruutu on osa pelilautaa ja pelilauta koostuu ruuduista, niin huomaamme, että nämä kaksi käsitettä liittyvät erittäin kiinteästi toisiinsa. Tämä puoltaa sitä ajatusta, että using Board -lause voisi sopia osaksi tiedostoa square.hh.

Lisäksi tämä ohjelma on pienikokoinen, joten ei liene välttämätöntä tehdä monta tiedostoa. Pidemmistä tiedostoista koostuvassa suuremmassa ohjelmassa erillinen tiedosto voisi olla parempi ratkaisu.

Tutki myös tiedostojen sisältämiä #include-direktiivejä. Huomaat jo edellisistä esimerkeistä tutun rakenteen, jossa square.hh on otettu mukaan molemmissa .cpp-tiedostoissa, koska sen sisältämiä määrittelyitä käytetään kaikissa osissa ohjelmaa.

Luokan ennakkoesittely

Tiedostoja katsoessasi saatoit huomata, että ennen edellä mainittua using-lausetta tiedostosta square.hh on rivi

class Square;

Tämä on luokan Square ennakkoesittely. Ennakkoesittelyä tarvitaan, koska rivi

using Board = std::vector< std::vector< Square > >;

ei käänny, jos kääntäjä ei tiedä, mikä Square on. Toisaalta Square-luokan rajapinta ei käänny, jos kääntäjä ei tiedä sitä kääntäessään, mikä Board on. Eli Square pitäisi olla määriteltynä ennen kuin voi määritellä, mikä Board on, ja Board pitäisi olla määriteltynä ennen kuin voi määritellä, mikä Square on.

Ratkaisuna on ennakkoesittely, jossa kerrotaan kääntäjälle, että Square on luokka, mutta ei vielä sen tarkemmin, mitä se sisältää. Tässä on kuitenkin kääntäjälle riittävästi tietoa, jotta se saa using-lauseen käännettyä.

Square-luokka

Attribuutit

Aivan itsestäänselvää on, että ruutuolion pitää tietää, kuinka monta tippaa vettä se sisältää.

Tämän lisäksi kunkin ruutuolion pitää päästä käsiksi muihin pelilaudalla sijaitseviin ruutuolioihin, koska sen pitää pystyä poksahtaessaan lisäämään vettä toisiin ruutuihin. Tämä onnistuu siten, että jokainen ruutuolio sisältää osoittimen pelilautaan, jolla se sijaitsee. Tämän osoittimen kautta olio voi osoittaa kaikkea pelilaudalla olevaa.

Ruutuolio haluaa käsitellä toisia ruutuja vain kohtisuoraan vierellään sekä ylä- ja alapuolellaan. Sen vuoksi ruutuolion pitää myös tietää, missä kohdassa lautaa se itse sijaitsee. Omien indeksiensä avulla se osaa indeksoida pelilautaa, johon pääsee käsiksi edellä mainitun osoittimen kautta.

Etsi edellä kuvailtujen attribuuttien määrittelyt esimerkkiohjelman ohjelmakoodista.

Metodit

Rakentajan ja purkajan lisäksi ruutuolio sisältää seuraavat toiminnot:

  • Ruudun pitää osata tulostaa itsensä, kun pelilautaa tulostetaan.
  • Ruutuun pitää voida lisätä tippa vettä.
  • Ruudussa olevan vesipisaran pitää poksahtaa, kun vettä tulee ruutuun enemmän kuin 4 tippaa.
  • Ruudulta pitää voida kysyä, onko siinä vettä, kun vesipisara poksahtaessaan haluaa selvittää, mihin ruutuun se lisää roiskauttamansa vedet.

Etsi näiden metodien määrittelyt luokan Square rajapinnasta.

Metodeista tulostaminen, veden lisääminen ja vesimäärän kysyminen ovat selkeästi toimenpiteitä, joilla ruutuoliota käsitellään ulkopuolelta. Siksi ko. metodit sijaitsevat julkisessa rajapinnassa (public). Esimerkiksi, kun pääohjelma tipauttaa tankista vettä pelilaudalle, kutsuu se oikeassa kohdassa olevan ruutuolion addWater -metodia, tai kun toinen, poksahtamassa oleva ruutuolio haluaa tarkastella laudalta, missä kohdassa sijaitsee ensimmäinen ruutu, jossa on vettä, se käyttää ruutuolion metodia hasWater toisille ruutuolioille.

Sen sijaan metodi pop ei ole sellainen, että kuka tahansa voisi sitä kutsua. Poksahtaminen tapahtuu vain ja ainoastaan silloin, kun vesipisaraan tulee yli 4 tippaa vettä. Sen suoritus siis riippuu olion sisäisestä tilasta. Sen vuoksi kyseinen metodi voidaan laittaa olion yksityiseen rajapintaan (private), ja olio voi itse kutsua sitä oikealla hetkellä jostain toisesta metodista.

Metodien käyttäminen operaattoreilla . ja ->

Kun tutkit ohjelmaa tarkemmin, huomaat, että pelilautavektorin metodeja kutsutaan välillä vanhalla tutulla tavalla:

olio.metodi(parametrit);

Tällainen kutsu löytyy esimerkiksi pääohjelmassa olevasta toistorakenteesta:

board.at(y-1).at(x-1).addWater();

Mutta välillä metodeja kutsutaan eri tavalla:

olio->metodi(parametrit);

Tällaisia kutsuja löytyy esimerkiksi pop-metodista.

Tämä johtuu siitä, että joissain kohdissa oliota käsitellään suoraan ja joissain kohdissa osoittimen kautta. Pääohjelmassa on talletettuna vector-olio, mutta ruutuolioihin on talletettuna osoitin vector-olioon.

Vektoriolio

Kun käytämme nimettyä oliota sen oman nimen avulla, kutsumme metodeita notaatiolla olio.metodi(parametrit), esim. tässä tapauksessa vectorolio.size().

Vektoriosoitin

Kun käytämme oliota osoittimen kautta, kutsumme metodeita notaatiolla osoitin->metodi(parametrit), esim. tässä tapauksessa vectorosoitin->size().

Qt Creator auttaa käyttämään oikeaa operaattoria. Kokeile siirtää kursori johonkin ruutuolion metodin määrittelyistä ja kirjoittaa siellä uudelle riville board_. ja seuraa, mitä tapahtuu.

Pääohjelma ja muut funktiot

Jotta pääohjelma ei kasvaisi liian pitkäksi, on tiedostossa main.cpp lisäksi määriteltynä funktiot, joilla voidaan

  • alustaa pelilaudalle satunnaisen kokoisia vesipisaroita pelin alussa
  • tulostaa pelilauta
  • tarkastaa, onko pelilaudalla vielä vettä, eli onko peli loppunut
  • lukea käyttäjältä komento, eli koordinaatit, joihin vettä tipautetaan.

Näitä käyttäen itse pääohjelma on melko lyhyt ja selkeälukuinen. Tutki pääohjelman koodia. Onko sen sisältämä koodi helposti ymmärrettävissä?

Vaikein yksityiskohta pääohjelmassa lienee while -rakenteen ehto, jossa ensimmäisenä osana on kokonaislukumuuttujan waterTank arvo. Mitä tapahtuu, kun kokonaislukumuuttujaa käytetään totuusarvon tilalla? Kääntäjä tekee kyseisessä kohdassa implisiittisen tyyppimuunnoksen totuusarvosta kokonaisluvuksi. Yhtä hyvin voisimme kirjoittaa ehdoksi waterTank != 0.

Tutki seuraavaksi funktiota initBoard

Kirjastosta random löytyy luokka default_random_engine, joka on yksi kirjaston tarjoamista satunnaislukugeneraattoreista. Kun kutsutaan satunnaislukugeneraattoriluokan rakentajaa (eli luodaan satunnaislukugeneraattoriolio), voidaan parametrina antaa nk. siemenluku. Jos siemenlukua ei anneta luomisen yhteydessä, se voidaan antaa myöhemmin kutsumalla funktiota seed(), kuten on tehty vesipisarapelin koodissa. Kun siemenluku on annettu, täytyy vielä kertoa, mitä jakaumaa halutaan käyttää. Kirjasto random tarjoaa tähän useita vaihtoehtoja, mutta tässä tapauksessa käytämme jakaumaa uniform_int_distribution. Jakaumalle täytyy lisäksi kertoa, miltä väliltä satunnaisluvut halutaan.

Tässä käytetyn satunnaislukugeneraattorin generoimat satunnaisluvut ovat nk. pseudosatunnaislukuja. Tämä tarkoittaa, että samalla siemenarvolla generoituu aina sama sarja satunnaislukuja. Voit testata pseudosatunnaisuutta suorittamalla esimerkiksi seuraavat ohjelmakoodirivit useampia kertoja:

default_random_engine gen(42);
uniform_int_distribution<int> distr(1, 100);
std::cout << distr(gen) << std::endl;
std::cout << distr(gen) << std::endl;
std::cout << distr(gen) << std::endl;
std::cout << distr(gen) << std::endl;
std::cout << distr(gen) << std::endl;

Pseudosatunnaisuudesta on se hyöty, että ohjelmaa testatessa pystyy toistamaan saman ohjelman suorituksen uudelleen, kun tietää, millä siemenluvulla satunnaislukugeneraattori oli alustettu ohjelman suorituksen alussa.

Funktio initBoard toimii siten, että käyttäjä saa halutessaan syöttää siemenluvun satunnaislukugeneraattorille. Jos käyttäjä ei syötä lukua (vaan painaa enter ja luetaan tyhjä merkkijono), satunnaislukugeneraattori alustetaan kellosta luetulla satunnaisella luvulla. Jos käyttäjä syöttää siemenluvun, käytetään sitä. Kun kurssin palautusjärjestelmä arvioi automaattisesti ohjelmia, joissa käytetään satunnaislukuja, täytyy ohjelma aina toteuttaa siten, että automaattitarkastin voi valita satunnaislukugeneraattorin siemenarvon.

Funktio stoi muuttaa merkkijonon kokonaisluvuksi. Tähän olemme jo tutustuneet edellä.

Pelilauta muodostetaan siten, että kahdessa sisäkkäisessä for-rakenteessa luodaan uusia ruutuolioita. Ruutuolion sisältämän veden määrä on satunnaisluku väliltä 0-4. Ensin ruutuoliot tallennetaan vektoriin nimeltä row (sisempi for). Kun row-vektorissa on riittävä määrä olioita, tallennetaan se itse pelilautaa esittävään vektoriin (ulompi for).

Pelilaudan alustaminen satunnaislukuja käyttäen ei välttämättä ole pelin pelaamismukavuuden kannalta mielekkäin ratkaisu. Toisinaan arpoutuu sellainen pelilauta, että pelin voi voittaa tipauttamalla yhden tipan vettä oikeaan kohtaan lautaa. Sopivana pohdintatehtävänä haasteista kiinnostuneille voisikin olla sellaisen pelilaudan alustusalgoritmin kehittäminen, jolla saisi alustettua pelilautoja sopivasti eritasoisiksi. (Esimerkiksi laudalla olevissa vesipisaroissa on aina yhteensä tietty määrä vettä. Läpi pääseminen vaikeutuu kun pääsee korkeammalle tasolle.)

Tutki seuraavaksi funktiota printBoard

Funktio tulostaa pelilaudan X- ja Y-akselien numeroinnit ja joitakin välilyöntejä ja rivinvaihtoja. Olennaisimman sisällön tulostavat ruutuoliot, joiden tulostusfunktiota tämä funktio kutsuu. Eli ruudut huolehtivat itse siitä, että niille tulostuu oikeanlainen merkki ruudun tilasta riippuen.

Pelilaudan X- ja Y-akselien numeroinnissa tulostuu vain koordinaatin jakojäännös kymmenellä jaon suhteen, jotta yli kymmenenkin suuruiset koordinaatit pysyvät siististi linjassa ASCII-käyttöliittymässä. Halutessasi voit kokeilla vaihtaa laudan kokoa suuremmaksi.

Kaikkein eniten virheitä aiheuttava yksityiskohta taitaa olla se, että käyttäjälle tulostettavat koordinaatit alkavat ykkösestä, mutta vektorin indeksointi nollasta. Se käy ilmi tästäkin funktiosta.

Tämä funktio sisältäisi vähän vähemmän yksityiskohtia, jos se olisi toteutettu ilman välilyöntien tulostamista. Nuivaa ASCII-käyttöliittymää on kuitenkin edes vähän helpompi käyttää, kun pelilautaa on vähän levennetty keinotekoisesti tulostamalla ylimääräisiä välilyöntejä.

Miksi ihmeessä funktion toteuttaja on laittanut funktiolle parametriksi tietovirran, johon tulostus suoritetaan? Ehkä tässä on ajateltu, että jossain tilanteessa tulostus voitaisiin tehdä myös johonkin muuhun tietovirtaan kuin std::cout. Palaamme tietovirtoihin vähän myöhemmin.

Tutki seuraavaksi funktiota readCommandSuccesfully

Tämä funktio tekee vain hyvin minimaaliset tarkistukset syötteille. Se tarkistaa, kuuluvatko annetut koordinaatit aikaisemmin määritellylle välille.

Control-C lopettaa ohjelman.

Yhteenveto

Ohjelma oli paitsi hyvä esimerkki siitä, miten olioita talletetaan vektoriin, myöskin hyvä esimerkki olio-ohjelmoinnista. Pelin olennaisin logiikka on ohjelmassa toteutettu siten, että ruutuoliot kommunikoivat keskenään, esimerkiksi lisäämällä vettä toisille ruutuolioille.

Ohjelmaa tutkiessamme opimme myös uusia käsitteitä, kuten luokan ennakkoesittelyn tekeminen ja using-lauseen käyttäminen uuden tyypin määrittelemiseksi. Lisäksi näimme hyvän esimerkin siitä, miten oliota käsitellään osoittimen läpi.

Peli ei toimi samoin kuin alkuperäinen esikuvansa (linkin takaa löytynyt nettipeli), koska poksahtaessaan vesipisara lisää samantien vettä muihin vesipisaroihin. Alkuperäisessä pelissähän vesipisarasta lähtee roiskeita, joiden matka lähellä olevaan vesipisaraan on nopeampi kuin johonkin kauempana olevaan vesipisaraan. Palaamme tähän aiheeseen paremmalla toteutuksella, kunhan taidot karttuvat.