Kirjautuminen

Haku

Tehtävät

Oppaat: Peliohjelmointi, C++-matopeli: Osa 3 - Pelin raakaversio

  1. Osa 1 - Välineet ja suunnitelma
  2. Osa 2 - Ohjelman perustoiminnot ja valikko
  3. Osa 3 - Pelin raakaversio
  4. Osa 4 - Liike, törmäykset ja viimeistely

Kirjoittaja: Metabolix (2009).

Perusfunktioiden jälkeen päästään käsiksi itse peliin. Tarvittavat toiminnot tunnetaan jo: mato luodaan, se liikkuu, törmää seiniin ja itseensä, syö omenoita ja kasvaa. Tehdään jokaiselle toiminnolle oma funktio, jotta saadaan kirjoitettua pelin rakenne koodin muotoon. Funktioiden ei tarvitse alussa toimia: seuraava vaihe on toteuttaa funktiot loogisessa järjestyksessä niin, että välituloksia voi helposti testata.

Pääfunktion sisältö

Kun valikosta siirrytään pelitilaan, täytyy ensiksi alustaa pelin muuttujat alkutilanteen mukaan. Koska matopelissä ei ole erilaisia pelikenttiä, ei tarvitse kuin luoda mato ja ensimmäinen omena. Tämän jälkeen suoritetaan silmukassa pelin perustoimintoja: luetaan käyttäjän syötteet, liikutetaan matoa, tehdään törmäystarkistukset ja piirretään tilanne. Kun peli päättyy, lopetetaan silmukka, tuhotaan mato ja omena ja palataan valikkoon. Tämä kaikki sijoitetaan funktioon peli::aja, joka toteutetaan tämän oppaan lopussa.

Pelin nopeus

Pelin pitäisi toimia yhtä nopeasti kaikilla tietokoneilla, vaikka uudet tietokoneet ovat paljon tehokkaampia kuin vanhat. Tämän takia pelissä täytyy mitata aikaa, jotta mato liikkuisi aina samalla nopeudella.

Kone voi olla joko liian nopea tai liian hidas. Liika nopeus on helppo korjata: ohjelma voi välillä odottaa hieman, jotta peli toimisi hitaammin. Liika hitaus vaatii paremman ratkaisun. Pienissä peleissä eniten aikaa kuluu yleensä piirtämiseen, joten ongelma ratkeaa, kun jätetään osa tilanteista piirtämättä. Esimerkkipelissä matoa liikutetaan 50 kertaa sekunnissa eli 0,02 sekunnin aika-askeleella. Jos ei ole vielä seuraavan liikutuskerran aika, ohjelma voi vaikka odottaa hetken, ja jos taas kone on niin hidas, että aikaa on ehtinyt kulua enemmänkin, voidaan matoa liikuttaa monta kertaa, ennen kuin tilanne piirretään uudestaan.

Käytännössä säilytetään tietoa pelin kellosta ja todellisesta kellosta. Jos pelin kello edistää, ohjelman täytyy odotella hieman, ja jos taas pelin kello jätättää, täytyy kiriä oikea aika kiinni piirtokertojen välissä.

Seuraavassa animaatiossa on kolme palloa. Vasemmanpuoleinen kuvaa tilannetta nopealla koneella: pallo liikkuu oikein. Keskimmäinen esittää tilannetta hitaalla koneella, jos koneen nopeutta ei ole huomioitu. Kolmas pallo taas näyttää, miten liikkumisen oikeasti kuuluu hitaalla koneella toimia: pallo liikkuu yhtä nopeasti kuin vasemmanpuoleinenkin, mutta osa välivaiheista jätetään piirtämättä.

Jos kone ei pysty suorittamaan laskuja tarpeeksi nopeasti, täytyy ehkä laskea tarkkuutta eli pidentää aika-askelta: matoa voitaisiin liikuttaa vaikka kymmenen kertaa sekunnissa hieman pitemmin loikkauksin, jolloin koneella olisi vähemmän laskettavaa. Samalla pelistä kuitenkin tulisi nykivä, ja liian pitkillä loikkauksilla esimerkiksi törmäykset voisivat jäädä huomaamatta: mato voisi vahingossa hypätä kokonaan omenan yli.

Ajan mittaaminen ja odottaminen

Esimerkkipelissä aikaa voidaan mitata SDL:n funktiolla SDL_GetTicks, joka kertoo ohjelman alusta kuluneen ajan millisekunteina, ja odottamista varten on funktio SDL_Delay. Tehdään molemmille toiminnoille vielä omat funktiot.

// ohjelma.hpp
namespace ohjelma {
	// Funktiot ajan hakemiseen (ja laskurin nollaamiseen) sekä odottamiseen.
	float sekunnit(bool nollaa = false);
	void odota();
}
// ohjelma.cpp
// Kertoo nollauksesta kuluneiden sekuntien määrän.
float ohjelma::sekunnit(bool nollaa) {
	static Uint32 alku;
	Uint32 nyt = SDL_GetTicks();
	if (nollaa) {
		alku = nyt;
	}
	return (nyt - alku) / 1000.0f;
}

// Odottaa lyhyen ajan.
void ohjelma::odota() {
	SDL_Delay(1);
}

Pelin data

Ennen funktioiden kirjoittamista täytyy tehdä jonkinlaiset rakenteet, joihin pelin data tallennetaan. Matopelissä on kolme osaa: mato, omena ja pelialue. Tehdään siis jokaiselle näistä oma rakenne. Pelialue sisältää nyt vain kulmien koordinaatit. Omenallekin riittää yksinkertaisesti sijainti. Madolla on oltava kulkusuunta ja sijainti. Madon sijainti on mutkikas asia, koska mato voi olla mutkalla. Yksi mahdollisuus on ilmoittaa jokaisen madon jaokkeen ("pallon") sijainti erikseen. Madon edetessä taaemmat jaokkeet siirtyvät edempien paikalle ja etupää täysin uuteen paikkaan.

Jotta madon saisi liikkumaan sulavasti, joka jaokkeelle tarvitaan kaksi sijaintia: kiinteä tukisijainti, jonka mukaan madon mutkat ja eteneminen toimivat, ja joustavampi sijainti, jota päivitetään sulavasti seuraavaa jaoketta kohti, kunnes on aika siirtää kaikkia jaokkeita eteenpäin. Syy on täysin matemaattinen, ja ongelman voi helposti nähdä, jos tekeekin pelin toisin. Seuraavassa kuvassa kaksi matoa on kulkenut samaa ympyrää. Sininen (ulompi) mato toimii oikein, mutta punainen (sisempi) on kiertynyt häntäpäästään kerälle, koska jaokkeet ovat liikkuneet aina suoraan seuraavaa jaoketta kohti ilman tukisijaintia.

Mato sisältää useita jaokkeita, eikä määrällä ei ole ennalta tunnettua ylärajaa. Yksi hyvä tietorakenne jaokkeiden säilyttämiseen on linkitetty lista, jossa peräkkäiset jaokkeet sisältävät linkit eli osoittimet toisiinsa. Uusia jaokkeita voidaan varata dynaamisesti new-operaattorilla, ja ne on helppo liittää listaan vain asettamalla linkit oikein, kuten myöhemmin matoja käsittelevissä funktioissa tehdään. Listan läpikäynti on helppoa: siirrytään aina seuraavaan jaokkeeseen, kunnes linkki on tyhjä. Ensimmäisen ja viimeisen jaokkeen osoittimet tallennetaan varsinaiseen matorakenteeseen.

// peli.hpp
#ifndef _PELI_HPP
#define _PELI_HPP

namespace peli {
	// Pelin pääfunktio; palauttaa pistemäärän.
	int aja();

	// Pelialueen kulmien koordinaatit ovat kokonaislukuja.
	struct alue {
		int x0, y0, x1, y1;
	};
	// Omenalla on x- ja y-koordinaatit.
	struct omena {
		float x, y;
	};
	// Mato on hieman mutkikkaampi.
	struct mato {
		// Mato muodostuu jaokkeista. Jaokkeella on todellinen sijainti
		// (x, y) ja mateluliikettä varten kiinteä sijainti (x0, y0).
		// Lisäksi jaoke sisältää osoittimen seuraavaan ja edelliseen
		// jaokkeeseen; rakenne on hienolta nimeltään linkitetty lista.
		struct jaoke {
			float x, y;
			float x0, y0;
			jaoke *nenampi, *hannampi;
		};
		// Mato itse sisältää osoittimet alkuun ja loppuun.
		jaoke *nena, *hanta;

		// Madolla on kulkusuunta ja mateluliikkeellä tietty vaihe.
		float suunta, vaihe;
	};
}

#endif

Pelin kiinteät asiat

Matopelissä on monta asiaa, jotka säilyvät läpi pelin mutta joita on hyvä pystyä kehitysvaiheessa muokkaamaan: toiminnan aika-askel, pelialueen koko, madon jaokkeen koko, omenan koko sekä madon nopeus ja käännösnopeus. Ne on selkeintä sijoittaa muuttujiin tai vakioihin, jotta niitä voi helposti muuttaa testatessa ja jotta koodissa on epämääräisten lukujen sijaan selkeitä nimiä, joista nähdään heti, mistä on kyse. Tässä vaiheessa voidaan määrätä toiminnan aika-askel ja pelialueen koko. Alue on mitoitettu niin, että se mahtuu mukavasti 640x480-kokoiseen ikkunaan, kun yksikkö on 32x32 pikseliä kooltaan.

// peli.cpp; paikallisia vakioita
namespace peli {
	// Pienin aikamäärä, jonka kello etenee. Liian lyhyt väli vaatii paljon
	// tehoa. Matopelissä 0.02 (eli 2 / 100) sekuntia on tarpeeksi lyhyt.
	static const float ajan_muutos = 0.02;

	// Pelikentän rajat (x0, y0, x1, y1).
	static const alue pelialue = {-8, -6, 8, 6};
}

Pelitilanteen piirtäminen

Rakenteiden perusteella voidaankin jo toteuttaa funktio pelitilanteen piirtämiseen. Kuten suunnitelmaan kuului, piirretään ensin tausta ja pelialueen reunat, sitten mato ja lopuksi omena. Funktiossa tehdään joitakin laskelmia, jotta pelialue saataisiin koostaan riippumatta ruudun keskelle. Tällä kertaa oletetaan, että koko alue näkyy kerralla, mutta hyvin samantapaisilla laskelmilla saataisiin tehtyä myös laajempi maailma, josta piirrettäisiin aina oikea alue pelihahmon ympäriltä.

Piirtelyn yhteydessä nähdään, kuinka madon linkitetty lista käydään läpi for-silmukalla: aloitetaan madon häntäjaokkeesta, siirrytään aina nenää kohti ja lopetetaan, kun on päädytty tyhjään osoittimeen.

// ohjelma.hpp
#include "peli.hpp"

namespace ohjelma {
	// Funktio pelitilanteen piirtoon. Olennaisia tietoja funktiossa ovat
	// pelialueen rajat, mato ja omena.
	void piirra_peli(peli::alue const& alue, peli::mato const& mato, peli::omena const& omena);
}
// ohjelma.cpp
// Piirtää pelin.
void ohjelma::piirra_peli(peli::alue const& alue, peli::mato const& mato, peli::omena const& omena) {
	piirra_kuva(kuvat::tausta_peli, 0, 0);

	// Määritellään yksikkö madon jaokkeen koon mukaan.
	const float yksikko_x = kuvat::matopallo->w;
	const float yksikko_y = kuvat::matopallo->h;

	// Lasketaan lisättävä pikselimäärä, jotta pelialue tulee ruudun keskelle.
	// (Oletetaan, että alue mahtuu kerralla ruudulle!)
	const float keski_x = ruutu->w / 2 - yksikko_x * (alue.x0 + alue.x1) / 2;
	const float keski_y = ruutu->h / 2 - yksikko_y * (alue.y0 + alue.y1) / 2;

	// Piirretään pelialueen reunat.
	for (int i = alue.x0 - 1; i <= alue.x1; ++i) {
		piirra_kuva(kuvat::reunapala, keski_x + yksikko_x * i, keski_y + yksikko_y * (alue.y0 - 1));
		piirra_kuva(kuvat::reunapala, keski_x + yksikko_x * i, keski_y + yksikko_y * alue.y1);
	}
	for (int i = alue.y0; i < alue.y1; ++i) {
		piirra_kuva(kuvat::reunapala, keski_x + yksikko_x * (alue.x0 - 1), keski_y + yksikko_y * i);
		piirra_kuva(kuvat::reunapala, keski_x + yksikko_x * alue.x1, keski_y + yksikko_y * i);
	}

	// Piirretään mato hännästä nenään.
	for (peli::mato::jaoke *j = mato.hanta; j != 0; j = j->nenampi) {
		piirra_kuva(kuvat::matopallo, keski_x + yksikko_x * j->x, keski_y + yksikko_y * j->y, true);
	}

	// Piirretään omena.
	piirra_kuva(kuvat::omena, keski_x + yksikko_x * omena.x, keski_y + yksikko_y * omena.y, true);

	SDL_Flip(ruutu);
}

Toiminnalliset funktiot

Jokaiselle pelin toiminnolle on selkeintä tehdä oma funktio. Matopelin toimintoja ovat madon luominen, kasvattaminen ja lopussa tuhoaminen, törmäysten tarkistaminen, omenan luonti ja syönti sekä madon liikkuminen.

// peli.cpp; paikallisia funktioita
namespace peli {
	// Funktiot madon luontiin, kasvatukseen ja tuhoamiseen.
	static mato uusi_mato();
	static void jatka_matoa(mato& m);
	static void tuhoa_mato(mato& m);

	// Funktio omenan arpomiseen; parametrina on mato, jottei luoda omenaa
	// madon päälle vaan jonnekin sivummalle.
	static omena arvo_omena(mato const& m);

	// Funktiot madon liikuttamiseen ja törmäystarkistuksiin.
	static void liikuta(mato& m, bool oikealle, bool vasemmalle);
	static bool osuu_omenaan(mato const& m, omena const& o);
	static bool tormaa(mato const& m);
}

Aluksi toiminnalliset funktiot voi toteuttaa niin, että ne eivät tee mitään. Paluuarvon pitää kuitenkin olla mielekäs! Esimerkiksi törmäystarkistus voi ilmoittaa aina, että kaikki on kunnossa, ja omena voi aluksi ilmestyä aina samaan kohtaan. Matokaan ei vielä tässä vaiheessa liiku.

// Liikuttaa matoa.
void peli::liikuta(mato& m, bool oikealle, bool vasemmalle) {
	// PUUTTUU!
}

// Arpoo omenan.
peli::omena peli::arvo_omena(const mato& m) {
	peli::omena o = {3, 3};
	// PUUTTUU!
	return o;
}

// Tarkistaa osumisen omenaan.
bool peli::osuu_omenaan(mato const& m, omena const& o) {
	// PUUTTUU!
	return false;
}

// Tarkistaa madon törmäyksen itseensä ja reunoihin.
bool peli::tormaa(mato const& m) {
	// PUUTTUU!
	return false;
}

Madon muokkaaminen

Peli vaatii testaamista varten madon. Nyt on sopiva aika kirjoittaa funktiot sen luomiseen, pidentämiseen ja tuhoamiseen. Kun mato luodaan, sille täytyy luoda ainakin yksi jaoke, johon nenä- ja häntäosoittimet voivat osoittaa. Tämän jälkeen matoa on helppo pidentää funktiolla, joka lisää jaokkeita. Tuhottaessa täytyy aina siirtyä askel eteen ja sen jälkeen tuhota edellinen jaoke, kunnes jäljellä on enää yksi.

Uuden jaokkeen voisi lisätä madon kumpaan tahansa päähän. Nyt se lisätään etupäähän, koska näin omenoiden syöminen toimii hauskemmin: uusi jaoke tulee omenan kohdalle, jolloin syöntikohdassa on välissä ylimääräinen jaoke, kunnes madon häntä pääsee paikalle ja mato pitenee. Jos jaoke lisättäisiin takapäähän, mato pitenisi heti.

// Luo madon.
peli::mato peli::uusi_mato() {
	mato m;
	// Suunta ja vaihe alustetaan nollaan.
	m.suunta = m.vaihe = 0;

	// Nenä ja häntä ovat alkuun sama jaoke.
	// Kummastakaan suunnasta ei pääse pidemmälle.
	// Jaoke sijaitsee maailman keskellä.
	m.nena = m.hanta = new mato::jaoke;
	m.nena->nenampi = m.hanta->hannampi = 0;
	m.nena->x = m.nena->x0 = (pelialue.x0 + pelialue.x1) / 2;
	m.nena->y = m.nena->y0 = (pelialue.y0 + pelialue.y1) / 2;

	// Lisätään matoon jatkofunktiolla pari jaoketta.
	jatka_matoa(m);
	jatka_matoa(m);
	return m;
}

// Lisää madon etupäähän palan.
void peli::jatka_matoa(mato& m) {
	mato::jaoke *j = new mato::jaoke;
	// Uusi jaoke on ensimmäisenä eli vanhasta nenästä vielä nenään päin.
	// Kopioidaan uuteen jaokkeeseen nenän tiedot ja korjataan osoittimet.
	*j = *m.nena;
	j->hannampi = m.nena;
	m.nena->nenampi = j;
	m.nena = j;
}

// Tuhoaa madon.
void peli::tuhoa_mato(mato& m) {
	// Poistetaan madosta jaokkeita, kunnes nenä ja häntä ovat sama jaoke.
	while (m.nena != m.hanta) {
		// Siirretään nenäosoitinta, tuhotaan vanha jaoke.
		m.nena = m.nena->hannampi;
		delete m.nena->nenampi;
		m.nena->nenampi = 0;
	}
	// Tuhotaan viimeinen jaoke.
	delete m.nena;
	m.nena = m.hanta = 0;
}

Pääfunktion toteutus

Lopultakin on pelin pääfunktion aika! Aiemmin käytiin jo rakenne läpi: alustus, silmukka, lopetus. Matopelin pääfunktio sisältää helppolukuisessa muodossa – funktiokutsuina – selostuksen koko pelin toiminnasta. Alustusten jälkeen käynnistyy pelisilmukka, ja pelin loputtua jäädään vielä odottamaan yhtä näppäimenpainallusta, jotta pelaaja ehtii rauhassa katsella, mikä meni pieleen. Lopussa palautetaan saavutettu pistemäärä ja ohjelma päätyy takaisin valikkoon.

#include <cstdlib>
#include <ctime>

// Pelin pääfunktio.
int peli::aja() {
	std::clog << "peli::aja()" << std::endl;

	// Alustetaan satunnaislukugeneraattori ajan perusteella.
	// Tätä tarvitaan myöhemmin omenoiden arpomiseen.
	std::srand(std::time(0));

	// Luodaan mato ja omena.
	mato m = uusi_mato();
	omena o = arvo_omena(m);

	int tulos = 0;
	bool loppu = false, piirretty = false;

	// Nollataan kello.
	float pelin_kello = ohjelma::sekunnit(true);
	while (!loppu) {
		// Esc-nappi lopettaa pelin.
		if (ohjelma::lue_nappi(ohjelma::NAPPI_ESCAPE)) {
			break;
		}
		// Luetaan muidenkin olennaisten nappien tilat.
		const bool nappi_oikea = ohjelma::lue_nappi(ohjelma::NAPPI_OIKEA);
		const bool nappi_vasen = ohjelma::lue_nappi(ohjelma::NAPPI_VASEN);

		// Nopeuden säilyttämiseksi toistetaan madon liikutusta
		// pienissä erissä, kunnes pelin kello saavuttaa oikean ajan.
		while (pelin_kello + ajan_muutos <= ohjelma::sekunnit()) {
			// Joka kerta liikutetaan matoa ja tehdään keskeiset
			// tarkistukset: törmäys lopettaa pelin, omenan syönti
			// kasvattaa matoa ja tulosta ja tuottaa uuden omenan.
			liikuta(m, nappi_oikea, nappi_vasen);
			if (tormaa(m)) {
				loppu = true;
				break;
			}
			if (osuu_omenaan(m, o)) {
				++tulos;
				jatka_matoa(m);
				o = arvo_omena(m);
			}
			pelin_kello += ajan_muutos;

			// Merkitään, että nykytilaa ei ole piirretty.
			piirretty = false;
		}
		if (piirretty) {
			// Jos tilanne ei ole muuttunut, turha sitä on piirtää.
			// Odotetaan hetki ja katsotaan sitten taas kelloa.
			ohjelma::odota();
		} else {
			// Muuttunut tilanne pitää piirtää.
			ohjelma::piirra_peli(pelialue, m, o);
			piirretty = true;
		}
	}

	// Näytetään lopputilannetta, kunnes käyttäjä painaa nappia.
	ohjelma::tyhjenna_syote();
	if (loppu) {
		ohjelma::odota_nappi();
	}

	// Vapautetaan madolle varattu muisti.
	tuhoa_mato(m);

	std::clog << "peli::aja(): tulos = " << tulos << std::endl;
	return tulos;
}

Samantapainen yksinkertainen pääfunktio (ja -silmukka) toimii kaikenlaisissa peleissä. Mitä suurempi peli on, sitä pienempi pääfunktiosta kannattaa tehdä. Jo tämä funktio on niin pitkä, että sen voisi hyvin jakaa pienemmiksi. Jos käsiteltäviä asioita olisi kymmenkertainen määrä, yhdestä suuresta funktiosta olisi todella vaikea saada selvää, jolloin muutosten tekeminen olisi vaivalloista.

Esimerkkipelin tilanne

(Lataa koodipaketti!)

Tässä vaiheessa peli voisi näyttää tältä:
Kuva pelistä: keskellä on matopallo, sivummalla omena, ympärillä reunat

Madon luova funktio tuottaa kolmen pallon madon, mutta kuvassa näkyy vain yksi pallo, koska kaikki kolme ovat samassa kohti. Onneksi tämä esteettinen virhe korjataan opassarjan seuraavassa osassa. Koska mato ei liiku eikä törmää, pelissä ei voi hävitä. Peli kuitenkin päättyy Esc-näppäimestä.

Lauri Kenttä, 7.11.2009


Kirjoita kommentti

Huomio! Kommentoi tässä ainoastaan tämän oppaan hyviä ja huonoja puolia. Älä kirjoita muita kysymyksiä tähän. Jos koodisi ei toimi tai tarvitset muuten vain apua ohjelmoinnissa, lähetä viesti keskusteluun.

Muista lukea kirjoitusohjeet.
Tietoa sivustosta