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.
Caution
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.
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.
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.