Artikkelit

Artikkelit olivat Suomipelit.comissa sivuston sulkeuduttua. Näin ollen niihin saattaa sisältyä rikkinäisiä linkkejä tai epämääräisiä viitteitä asioihin, joita ei enää ole olemassa, ja joskus jokin saattaa näkyä väärin.

Palaa artikkelilistaan

Peliohjelmoinnin peruskäsitteet - Osa 3

Tässä peliohjelmoinnin alkeiskurssin kolmannessa osassa käsitellään pelisilmukkaa, funktioita ja pelin toiminnan ja rakenteen jakamista loogisiin, helosti käsiteltäviin osiin. Samalla tehdään ristinollapelin pääohjelma.

7.10.2002 julkaistun artikkelin on kirjoittanut jalaine.

  1. Pelin toiminta
  2. Pelisilmukka
  3. Jaetaan koodi osiin
  4. Mitä funktioita tarvitaan?
  5. Tehdäänpä nyt se silmukka
  6. Miten jatketaan?

Noniin, jatketaanpa sitten peliohjelmoinnin perusteiden harjoittelemista. Tiedän, että sinulla jo sormet syyhyävät päästä koodia näpyttelemään ja väsäämään seuraavaa hittipeliä, mutta usko pois, aika jonka käytät näiden yksinkertaisten ja tylsienkin asioiden opettelemiseen ei ole hukkaan heitettyä.

Viime kerralla käytiin läpi ristinollapelissä tarvittavat tietorakenteet; päädyimme käyttämään taulukkoa pelilautana ja tietuetta pelaajan tietojen tallentamiseen. Nyt jatkamme tästä vähän eteenpäin. Tämän osan aiheena on pelisilmukka ja pelin rakenteen suunnittelu. Eli vieläkään ei saada aikaan valmista peliä, mutta osan lopussa ollaan sitä kuitenkin jo paljon lähempänä.

Pelin toiminta #

Aloitetaanpa siitä, että mietitään hetkinen mitä tietokone tekee suorittaessaan peliäsi. Tietokoneessahan on prosessori, joka suorittaa käsky kerrallaan ohjelmaa, keskusmuisti ja paljon muita hässäköitä.

Kun prosessori suorittaa ohjelmaa, tapahtuu se erittäin yksinkertaistetussa muodossa seuraavasti: Ensin se hakee muistista yhden käskyn, sitten se suorittaa sen ja tallentaa tuloksen johonkin rekisteriinsä tai keskusmuistiin. Siinä välissä tapahtuu vielä paljon kaikkea muutakin, mutta idea on siinä, että kone suorittaa koodia käskyn kerrallaan alusta loppuun.

Oleellista tuosta on huomata se, että ohjelman täytyy olla aika tarkkaan mietitty, koska tietokone suorittaa sitä ihan orjallisesti. Se ei pysty soveltamaan yhtään vaan kaikki täytyy kertoa sille tarkkaan.

Mutta ei mennä ainakaan vielä ihan niin laitteistonläheisiksi vaan mietitään hetkinen sitä kuinka itse pelaisit ristinollaa kaverisi kanssa:


  • Aloitetaan peli: haetaan paperia ja kynä.
  • Päätetään kumpi aloittaa
  • Pelaaja laittaa rastin haluamaansa kohtaan
  • Jos hän yrittää piirtää rastinsa sellaiseen kohtaan, jossa jo on merkki, se ei onnistukaan - vaan kaveri ärähtää, "Mitä oikein yrität?"
  • Kaverukset tarkistavat, onko jompikumpi jo saanut tarvittavat 3 ruutua
  • Jos on, peli loppuu
  • Muuten tarkistetaan, onko ruudukko täynnä
  • Jos on, peli loppuu
  • Jos ei, pelaaja vaihtuu ja jatketaan kohdasta 2

Tuossa on sama juttu hieman tarkennettuna. Se antaa jo aika hyvän kuvan siitä mitä ohjelman pitää tehdä. Tätä voitaisiin pitää yksinkertaisena suunnitteluna. En tässä käytä mitään hienoja termejä, kun tarkoituksena on yrittää herättää ajatteluasi siihen suuntaan, että pelien suunnittelu ja asioiden huomioiminen onnistuisi kuin luonnostaan.

Pelisilmukka #

Äskeisestä peliesimerkistä päästäänkin varsin luontevasti siirtymään pelisilmukkaan. Peliähän siis pelataan tietty aika, tarkalleen ottaen siihen asti, että toinen pelaaja voittaa tai tulee tasapeli. Sinä aikana tehdään monta kertaa samat asiat; kumpikin laittelee merkkejään ja tarkistellaan onko joku jo mahdollisesti voittanut. Tässä siis kannattaa käyttää silmukkaa. Oletan, kuten jo ensimmäisessä osassa sanoin, että sinulla on perustiedot ohjelmoinnista. Silmukan käsite lienee siis selvä: jotain ohjelmanpätkää toistetaan kunnes jokin ehto täyttyy, esimerkiksi peli loppuu.

Pseudokoodina, eli ohjelmakoodina, jonka ei ole tarkoitus sellaisenaan toimia vaan antaa suuntaa siitä miten lopullinen ohjelma toimisi, voisimme kirjoittaa pelisilmukan vaikkapa näin:

pelaaja=1;

while(peli_jatkuu)
{
	while(pelaaja laittaa laittomaan ruutuun)
		laita_merkki_haluamaasi_ruutuun();

	voittaja=tarkista_voittaja();	

	if(voittaja!=0) peli_jatkuu=false;

	else pelaaja=vaihda_pelaaja();
}


Okei, esimerkissämme ei vielä otettu oikeastaan mitään kantaa siihen, kuinka se käytännössä on mahdollista toteuttaa. Näppäimistön luku, grafiikat ja muu mukava on vielä kokonaan pois. Kyse on siis puhtaasti logiikasta, siitä kuinka ajattelemme pelin parhaiten toimivan. Koodin selitys voisi olla paikallaan.

Silmukassa tehdään aina yhden pelaajan siirto ja sen jälkeen tarkistetaan voittiko kyseinen pelaaja. Sen jälkeen, jos peli edelleen jatkuu, vaihdetaan pelaajaa ja mennään silmukkaa läpi toisen kerran. Tarkista_voittaja() -pseudofunktio palauttaa voittajan numeron (1 tai 2), jos joku voittaa ja 0, jos peli jatkuu. Tasapelitilanteessa se voisi palauttaa vaikkapa kolmosen. Vaihda_pelaaja() asettaa pelaajan numeroksi seuraavan pelaajan numeron. Ja tuo while(pelaaja laittaa laittomaan ruutuun) tarkoittaa sitä, että laita_merkki_haluamaasi_ruutuun() toistetaan niin monta kertaa, että pelaaja lopulta laittaa merkkinsä johonkin sallittuun ruutuun.

Jaetaan koodi osiin #

Ennen kuin tehdään lopullista versiota tuosta pelisilmukasta, käsitellään hieman funktioita ja sitä kuinka ohjelmaa kannattaa jakaa funktioiksi. Siis mitkä ohjelman osat tarvitsevat oman funktionsa ja miten funktioista saadaan paras hyöty irti. Oletan, että tiedät tekniikan, kuinka funktioita käytetään ja keskityn vain tähän teoriapuoleen.

Funktiojako on sellainen asia, jonka puuttumiseen tai vaillinaiseen ymmärtämiseen törmään useimmiten aloittelevien ohjelmoijien koodia lukiessani. Monesti mukana on joku funktio, esimerkiksi alkutekstien kirjoittamiseen - tekijä kun on kuullut, että on hienoa käyttää funktioita. Ja niinpä sellainen on pitänyt mukaan ahtaa. Funktioiden merkitys on kuitenkin jostain syystä jäänyt kuulematta. Sen takia paneudun nyt hetkeksi funktioihin. Asia ei ole monimutkainen - ja se helpottaa ohjelmointityötäsi HUOMATTAVASTI!

Funktioihin jakamiseen on oikeastaan kaksi tärkeää syytä: saman koodin uudelleenkirjoittamisen välttäminen ja koodin jakaminen sellaisiin osiin, jotka on helppo käsitellä, ja ne kummatkin oikeastaan voitaisiin yleistää selkeydeksi. Funktioita käytetään, jotta koodi olisi selkeämpää ja sitä olisi helpompi hallita. Yleensä tällä saavutetaan bugittomampaa koodia, helpommin korjattavaa koodia ja monesti myös paljon paremmin toimivaa koodia.

Jos katsotaan funktioihin jakamista ensin hetki uudelleenkirjoittamisen näkökulmasta, huomataan, että usein ohjelmissamme toistamme samoja operaatioita moneen kertaan. Esimerkiksi, jos käytämme pelissämme grafiikkaa ja meidän täytyy ladata kuvia, lataamme luultavasti pelin aikana monta kuvaa. Kuvanlatausfunktioon tulee helposti muutama kymmenen riviä koodia ja jos se kirjoitetaan joka kerta uudestaan, kun taas tarvitaan kuvaa, pitenee ohjelmamme aivan turhaan. Nimittäin, jos teemme funktion, joka ottaa parametrinaan kuvan tiedostonimen ja palauttaa kuvan (esim. DirectDrawn surfacessa), meidän tarvitsee kirjoittaa vain kerran tuo kymmenkunta riviä, ja aina kuvaa ladattaessa kutsua tuota funktiota.

Tästä pääsemmekin helposti myös siihen selkeyteen ja ylläpidettävyyteen. Ajattele, että sinulla olisi ohjelma, jossa ladataan 20 kuvaa, ja jokaisen kuvan lataamiseen käytettävä koodi olisi aina kirjoitettu siihen kohtaan, jossa kuvaa tarvitaan. Sitten yhtäkkiä huomaisit, että kuvanlatauskoodissasi on virhe. Noh, ei muuta kuin tekisit korjaukset kaikkiin 20 kohtaan. Aikaa menisi ehkä puoli tuntia. Mutta entä, jos kuvia olisi 50 - tai sata. Siinä menisivät jo hermot. Jos sen sijaan olisit laittanut kuvan latauksen funktioon, tarvitsisi korjata vain sieltä, ja ongelma poistuisi kaikista kuvista. Aikaa menisi ehkä 5 minuuttia. Tietenkin ongelman suuruudesta riippuen.

Toinen juttu on sitten se koodin jakaminen selkeisiin, helposti käsiteltäviin osiin. Kun puhutaan koodin ymmärrettävyydestä, ajatellaan yleensä kommentteja. Aloittelija ei usko edes niitä tarvitsevansa, johtoajatuksena on saada äkkiä jotain toimivaa näkyviin. Mutta selkeys ja ymmärrettävyys on paljon enemmänkin. Se on sitä, että ohjelmakoodi on jaettu funktioihin, jotka tekevät sen mitä niiden oletetaan tekevän. Ne tekevät pieniä osatehtäviä niin, että ohjelmoijan on helppo pääohjelmaa katsoessaan yhdellä silmäyksellä nähdä kuinka sen logiikka toimii. Tätä asiaa haluan erityisesti tässä tutoriaalissa korostaa: kun joku lukee koodiasi, tulisi hänen pystyä saamaan ainakin suurpiirteinen käsitys ohjelmasi toiminnasta vilkaisemalla pääohjelmaasi - tai ainakin pelisilmukkaasi.

Siksi pelisilmukkaan tulee laittaa vain välttämätön, ja kaikki muu jaettava selkeisiin funktioihin. Ja funktioiden pitää olla sellaisia, että niiden toiminta on loogista.

Mitä funktioita tarvitaan? #

Noniin, mietitäänpä mitä ristinollapelissämme olisi järkevää laittaa omiin funktioihinsa. Ensinnäkin olisi tärkeää erotella piirtäminen ja pelin muu toiminta. Sen lisäksi voisi olla hyvä erottaa näppäinten luku pelin sääntöjen ja logiikan käsittelystä. Pelisilmukassa voisi olla siis vaikkapa seuraavat funktiot:


  • PISTE Lue_hiiri() - lukee hiiren painalluksen ja palauttaa koordinaatit, joihin hiirellä on klikattu
  • bool Tee_siirto(PISTE p, int pelaaja, PELILAUTA lauta) - muuttaa hiiren koordinaatit pelilaudan koordinaateiksi, tarkistaa voiko ko. ruutuun laittaa merkin ja merkitsee ruutuun pelaajan tunnuksen. Jos taas ruutuun ei voi laittaa (siinä on jo jonkun merkki), palauttaa funktio virheen merkiksi false. Tällöin täytyy hiiri lukea uudelleen.
  • int Tarkista_voittaja(PELILAUTA lauta) - käy läpi pelilaudan ja tarkistaa onko joku voittanut. Palauttaa voittajan numeron tai 0, jos kukaan ei vielä ole voittanut. Tasapelistä se palauttaa 3.
  • void Piirra_pelitilanne(PELILAUTA lauta, int pelaaja) - piirtää näytölle pelilaudan, ja kirjoittaa tiedon siitä kumman pelaajan vuoro on.

Siinä on jonkin verran tärkeimpiä funktioita. Kuten huomaat, ne ovat varsin loogisia kokonaisuuksia: siirron tarkistaminen, voittajan tarkistaminen, piirtäminen jne. Ei vaadi kovin paljon miettimistä, että keksii jakaa ohjelman juuri tuollaisiin osiin. Parametrit ovat lähinnä suuntaa-antavia, mutta varmaankin aika lähellä lopullisia parametreja.

Ihmettelet ehkä, miksi hiiren lukeminen ja siirron tarkastaminen ei ole samassa funktiossa. Ne kun kuitenkin aika läheisesti liittyvät toisiinsa. Toki ohjelman voisi kirjoittaa niinkin, että hiiri luettaisiin tee_siirto-funktiossa, mutta yleensä suositaan sitä tapaa, että kaikki laitteistonläheinen osa ohjelmasta sijoitetaan erilleen abstraktimmasta osasta. Tämä sen takia, että jos vaikkapa joskus haluat siirtää Windowsissa tekemäsi pelin Linuxiin, on se helppo tehdä, kun ei tarvitse kuin muuttaa niitä laitteistonläheisiä osia - logiikka pysyy ihan samana.

Tehdäänpä nyt se silmukka #

Nyt meillä lienee tarpeeksi teoriaa funktioiden jakamisesta, ja voidaan alkaa soveltamaan niitä käytäntöön. Tärkeintä on muistaa, että pyritään kaikessa selkeyteen ja helposti ymmärrettävään koodiin. Harjoitus tekee mestarin, tärkeintä vain on se, että muistaa aina miettiä asioita - eikä vain tehdä niin kuin ensimmäisenä mieleen tulee.

Eli kerätään edellisissä osissa esitellyt tietotyypit ja tässä osassa mietityt funktiot yhteen ja kasataan ristinollapelillemme pääohjelma (main). Kommenteissa kerrotaan kaikki tarpeellinen.

/* Ristinollapelin pääohjelma - versio 1 
   Huom! Tämä ei vielä ole toimiva ohjelma, vaan 
   pelkkä pääohjelma, jonka ympärille myöhemmin 
   rakennetaan kokonainen peli */

/* Määritellään PELIMERKKI-tietotyyppi */ 
typedef enum { RISTI, NOLLA } PELIMERKKI; 


/* Määritellään PELAAJA-tietue (ks. tutoriaalisarjan 2. osa) */ 
typedef struct _PELAAJA 
{ 
	char nimi[40]; 
	PELIMERKKI nappula; 
} PELAAJA; 


/* Määritellään PISTE-tietue, jota käytetään 
   hiiren lukemiseen */
typedef struct _PISTE
{
	int x, y;
} PISTE;


int main(void)
{

	int voittaja=0; /* Voittajan numero - 0=peli ei vielä loppunut */
	PELAAJA pelaajat[2]; /* Kaksi pelaajaa */
	int pelilauta[3][3]; /* Pelilauta */
	PISTE piste; /* Tätä käytetään hiiren tietojen välitykseen */
	int vuorossa = 1; /* Vuorossa oleva pelaaja (1 tai 2) */
	
	/* Alustetaan pelaajien arvot (kumpi on risti / nolla yms.) */
	alusta_pelaajat(pelaajat);

	/* Alustetaan pelilauta (nollataan kaikki sen ruudut) */	
	alusta_pelilauta(pelilauta);

	/* Koska pelissä käytetään grafiikan piirtoon ja hiiren lukuun
 	   Allegro-kirjastoa, täytyy sekin alustaa tässä ennen kuin 
	   aloitetaan pelisilmukkaa */
	alusta_allegro();

	/* Pelisilmukka: Pyöritään ympäri ja ympäri niin 
	   kauan kunnes voittaja-muuttujan arvo muuttuu 
	   (1=pelaaja 1 voitti, 2=pelaaja 2 voitti ja 3=tasapeli) */

	while(voittaja==0)
	{
		/* Tehdään logiikka, jos hiirellä on klikattu johonkin,
		   muuten vain piirretään */
		if(lue_hiiri(&piste))
		{
			/* Ks. tee_siirto()-funktion esittely yllä */
			if(tee_siirto(&piste, pelilauta, vuorossa))
			{
				/* Tarkistetaan voittiko pelaaja tällä 
				   siirrollaan ja jatkuuko peli vielä */
				voittaja = tarkista_voittaja(pelilauta);

				/* Seuraavan pelaajan vuoro */
				vuorossa = vaihda_pelaaja(vuorossa);
			}
			
			/* Jos siirto ei ollut sallittu, tehdään 
			   piirto ja aloitetaan taas silmukan alusta */
		}
		
		/* Piirretään pelin grafiikat */
		piirra_pelitilanne(pelaajat, pelilauta);
	}

	/* Pelin lopetus: Kerrotaan kuka voitti - vai tuliko tasapeli, 
	   tehdään muut lopetustoimet */

	kerro_voittaja(voittaja);

	lopeta_allegro();

	return 0;
}


Selitän ohjelmaa hieman, mutta en paljon, koska oikeastaan siinä on vain laitettu käytäntöön sellaisia asioita, joista tuossa edellä on puhuttu. Pääohjelmaan siis kuuluu oikeastaan kolme osaa: Alku, jossa alustetaan käytetyt muuttujat ja tehdään kaikki valmistelut, Pelisilmukka, jossa tehdään itse peli ja Lopetus, jossa viimeistellään pelin suoritus ja tehdään lopetustoimet.

Alku ja loppu tehdään vain kerran, kun taas pelisilmukkaa käydään läpi niin kauan kuin peliä kestää. Siksi erityisesti pelisilmukka on tärkeää suunnitella hyvin ja selkeäksi.

Pelisilmukasta tein sellaisen, että piirto tehdään joka kerta, vaikka peli ei etenisikään, siksi että silloin, kun pelaaja liikuttelee hiirtä näytöllä, mutta ei paina mitään nappia, täytyy piirtää, mutta peli ei kuitenkaan etene. Niinpä suurin osa pelisilmukasta suoritetaan vain silloin, kun hiirtä klikataan.

Myös tee_siirto() aiheuttaa sen, että osa koodista joko suoritetaan tai hypätään yli sen mukaan oliko siirto sallittu. Jos siirto ei ollut sallittu, ei kannata tarkistaa voittajaa tai vaihtaa pelaajaa. Silloin vain piirretään ja sama pelaaja saa taas yrittää uudestaan. Jos vaikka tällä kertaa osuisi oikeaan ruutuun.

Miten jatketaan? #

Niin, tämä osa alkaa olla tässä. Opettelimme sitä kuinka pelisilmukka tulisi rakentaa ja miten pelin toiminta jaetaan funktioihin. Samalla teimme peliimme pääohjelman rungon, jonka päälle alamme ensi kerralla kasaamaan hieman lihoja - kirjoittamaan funktioita. Tuo runko ei itsessään vielä tee mitään, eikä sitä pysty edes kääntämään, mutta se on jo askel kohti valmista peliä.

Seuraavassa osassa toteutamme siis pelin logiikkaan liittyvät funktiot tee_siirto(), tarkista_voittaja() ja vaihda_pelaaja(), sekä alustusfunktiot alusta_pelaajat() ja alusta_pelilauta(). Sitä seuraavalla kerralla luomme pikaisen katsauksen grafiikkaan Allegroa hyödyntäen, kuitenkin niin, että Allegron tekniikalla on siinä mahdollisimman pieni osuus, ja saamme pelin valmiiksi.

Ei muuta kuin opetteluintoa, ja hauskaa koodausta! Palautetta saa ja pitää antaa - vaikkapa suomipelit.com:in keskustelualueella. Yritän sitten artikkelia vielä parannella ja ainakin vastailla kysymyksiisi.