Kirjautuminen

Haku

Tehtävät

Oppaat: SDL2: Osa 1 - Käyttöönotto ja grafiikka

  1. Osa 1 - Käyttöönotto ja grafiikka
  2. Osa 2 - Syöte ja tapahtumat

Kirjoittaja: Metabolix (2013).

SDL eli Simple DirectMedia Layer on kirjasto, jolla pystyy helposti muun muassa käsittelemään näppäimistöä ja hiirtä, soittamaan ääniä ja musiikkia ja piirtämään 2D-grafiikkaa sekä alustamaan ikkunan OpenGL-piirtoa ja 3D-grafiikkaa varten.

SDL:n ensimmäinen versio julkaistiin vuonna 1998, ja se saavutti suuren suosion pienten pelien ohjelmoinnissa. Tämän oppaan aiheena taas on uudempi SDL2, jonka ensimmäinen valmis versio, SDL 2.0, julkaistiin vuonna 2013. Uuden version merkittävimmät edut vanhaan nähden ovat 2D-grafiikan laitteistokiihdytys, tuki usealle ikkunalle sekä uudistunut tekstinsyöttö.

Tämä opassarja on vain lyhyt johdatus kirjaston alkeisiin. SDL Wiki sisältää kattavan listan kaikista funktioista ja niiden käyttötavoista, joten jos jokin asia jää oppaasta epäselväksi tai haluat tietää lisää, kannattaa suunnata sinne! Oppaassa käytetään C-kieltä. SDL2:n käyttö on mahdollista myös monilla muilla ohjelmointikielillä, mutta niitä ei käsitellä tässä.

Ensimmäisessä osassa otetaan kirjasto käyttöön, luodaan ikkuna ja piirretään siihen kuva, ja toisen osan aiheita ovat ajan mittaaminen ja tapahtumien käsittely sekä hiiri ja näppäimistö. Näitä asioita tarvitaan lähes kaikissa ohjelmissa, joten oppaat on hyvä lukea joka tapauksessa. Sen sijaan myöhemmät oppaat käsittelevät irrallisempia aiheita, ja niitä voi lukea valikoiden.

Asennus

SDL2 pitää ensin asentaa. Asennustapa riippuu käyttöjärjestelmästä ja kääntäjästä. Monissa Linux-jakeluissa riittää, että asentaa paketinhallinnasta oikean paketin, joka voi olla esimerkiksi sdl2, libsdl2 tai libsdl2-dev. Windowsissa tarvittavat tiedostot saa SDL2:n lataussivulta, ja ne pitää itse purkaa kääntäjän include- ja lib-hakemistoihin, ellei kääntäjälle löydy muualta valmista versiota.

SDL2 itse tukee vain pakkaamattomia BMP-kuvia ja WAV-äänitiedostoja. Pakattujen kuvien ja äänten lataamiseen tarvitaan kirjastoja SDL2_image ja SDL2_mixer, jotka täytyy erikseen asentaa. Muita yleisiä lisäkirjastoja ovat tekstinpiirtokirjasto SDL2_ttf ja verkkokirjasto SDL2_net.

Koodin kääntäminen ja linkitys

Ohjelmaan pitää liittää include-rivillä otsikkotiedosto SDL2/SDL.h, ja main-funktion tulee olla sellainen, kuin C-kielen standardissa määrätään:

#include <SDL2/SDL.h>

int main(int argc, char** argv) {
	// Koodi tulee tänne.
	return 0;
}

Ohjelman linkitykseen tarvitaan kirjasto SDL2. Esimerkiksi Dev-C++:n tai Code::Blocksin projektiasetuksiin täytyy siis lisätä linkkerin parametri -lSDL2. Linuxissa komentorivillä käännöskomento voisi näyttää tältä:

gcc koodi.c -O -o ohjelma.bin -lSDL2

Windowsilla voi linkittää myös kirjaston SDL2main, joka piilottaa komentorivi-ikkunan. Silloin siis linkkerin parametreiksi laitetaan sekä -lSDL2main että -lSDL2.

Lisäkirjastoja käytettäessä tarvitaan niiden otsikkotiedostot ja kirjastotiedostot. Esimerkiksi SDL2_image-kirjaston otsikkotiedosto on SDL2/SDL_image.h ja edelliseen käännöskomentoon lisättävä parametri -lSDL2_image.

Alustus

Ohjelman alussa pitää kutsua funktiota SDL_Init, jolle annetaan parametrina tai-operaattorilla yhdisteltynä tieto, mitkä ominaisuudet halutaan alustaa. Symboli SDL_INIT_EVERYTHING kattaa kaikki SDL:n ominaisuudet ja on siis aina varma valinta.

SDL_Init(SDL_INIT_EVERYTHING);

SDL_Init palauttaa luvun 0, jos alustus onnistuu, ja muuten se palauttaa negatiivisen luvun. Tarkemman tiedon virheestä saa funktiolla SDL_GetError().

if (SDL_Init(SDL_INIT_EVERYTHING) != 0) {
	fprintf(stderr, "Virhe: %s\n", SDL_GetError());
	exit(1);
}

Lopetus

Ohjelman lopussa pitää kutsua funktiota SDL_Quit.

SDL_Quit();

SDL_Quit-kutsusta voi tehdä automaattisen, kun käyttää stdlib.h-otsikossa määriteltyä funktiota atexit. Silloin äskeinen SDL_Quit-rivi jätetään pois ja sen sijaan SDL_Init-kutsun jälkeen seuraava rivi:

atexit(SDL_Quit);

SDL sisältää monia samanlaisia pareja kuin SDL_Init ja SDL_Quit: tehdyt asiat täytyy myöhemmin kumota.

Ikkuna ja piirtäjä

Grafiikka on nykyään useimpien ohjelmien perusta. Sitä varten tarvitaan ikkuna (SDL_Window) ja piirtäjä (SDL_Renderer). Ikkuna kuvastaa sitä ruudun aluetta, jolle ohjelma voi piirtää ja jonka kohdalla esimerkiksi hiiren liikkeet koskevat ohjelmaa. Piirtäjä taas säilyttää tietoja, joita piirtämiseen muuten tarvitaan: jos SDL käyttää OpenGL- tai Direct3D-kiihdytystä, piirtäjä sisältää esimerkiksi näihin liittyviä muuttujia. Aina SDL:n piirtäjää ei ole pakko olla, vaan ohjelma voi myös itse piirtää ikkunaan OpenGL:n funktioilla.

Ikkunan ja piirtäjän voi luoda erikseen funktioilla SDL_CreateWindow ja SDL_CreateRenderer tai yhdessä funktiolla SDL_CreateWindowAndRenderer.

SDL_Window *ikkuna;
SDL_Renderer *piirtaja;

// Luodaan koko ruudun täyttävä ikkuna nykyisellä resoluutiolla.
if (SDL_CreateWindowAndRenderer(0, 0, SDL_WINDOW_FULLSCREEN_DESKTOP, &ikkuna, &piirtaja) != 0) {
	fprintf(stderr, "Virhe: %s\n", SDL_GetError());
	exit(2);
}

Jos ohjelman on tarkoitus toimia kokoruututilassa, yllä esitetty tapa ikkunan luontiin on yleensä paras. Resoluution asettamisesta kerrotaan lisää ihan kohta.

Samalla funktiolla voi luoda myös pienempiä ikkunoita.

// Luodaan 640x480 pikselin ikkuna.
SDL_CreateWindowAndRenderer(640, 480, 0, &ikkuna, &piirtaja);

Ikkunan otsikon voi asettaa funktiolla SDL_SetWindowTitle; teksti pitää antaa UTF-8-enkoodattuna, mikä on helppoa, kun vain tallentaa lähdekoodinsa UTF-8-muodossa.

SDL_SetWindowTitle(ikkuna, "SDL2-kokeilu");

Ennen ohjelman loppua piirtäjä ja ikkuna täytyy tuhota funktioilla SDL_DestroyRenderer ja SDL_DestroyWindow.

SDL_DestroyRenderer(piirtaja);
SDL_DestroyWindow(ikkuna);

Ohjelmassa voi olla useampia ikkunoita; kaikki luodaan samalla tavalla.

Ikkunan koko ja näytön resoluutio

Luodun ikkunan koko selviää funktiolla SDL_GetWindowSize.

int leveys, korkeus;
SDL_GetWindowSize(ikkuna, &leveys, &korkeus);

Joskus pelin ohjelmointi on helpompaa, jos resoluutio on näennäisesti vakio. Funktiolla SDL_RenderSetLogicalSize voi määrätä, että SDL venyttää kaiken piirrettävän vastaamaan tiettyä resoluutiota, vaikka resoluutio ei oikeasti muutu. Jos kuvasuhde ei vastaa ikkunaa, reunoille jää vähän tyhjää. Ennen tämän funktion kutsumista kannattaa asettaa sopiva skaalaustapa.

SDL_RenderSetLogicalSize(piirtaja, 640, 480);

Jos jostain syystä venyttäminen ei käy ja resoluutiota täytyy välttämättä vaihtaa, sen voi määrätä ikkunan luonnin yhteydessä.

// Luodaan koko ruudun täyttävä ikkuna resoluutiolla 640x480.
SDL_CreateWindowAndRenderer(640, 480, SDL_WINDOW_FULLSCREEN, &ikkuna, &piirtaja);

Kuvan lataaminen tekstuuriksi

SDL2 sisältää kuvia kahdessa muodossa: kuvapintoina (SDL_Surface) ja tekstuureina (SDL_Texture). Kuvapinnat ovat pakkaamatonta pikselidataa ja sijaitsevat koneen keskusmuistissa. Niitä voi ladata tiedostosta, muokata ja tallentaa. Jotta kuvia voisi piirtää ruudulle, ne pitää kopioida tekstuureiksi näytönohjaimen muistiin. Tekstuureita ei voi enää käytännöllisesti itse käsitellä, mutta näytönohjain pystyy venyttämään ja pyörittämään niitä ja piirtämään ne ruudulle erittäin nopeasti.

BMP-kuvan voi ladata kuvapinnaksi funktiolla SDL_LoadBMP. Jos lataaminen epäonnistuu, funktiot palauttavat tyhjän osoittimen.

SDL_Surface *kuvapinta;
kuvapinta = SDL_LoadBMP("kuva.bmp");
if (!kuvapinta) {
	fprintf(stderr, "Virhe: %s\n", SDL_GetError());
	exit(3);
}

Pakattuja kuvia kuten PNG- ja JPEG-kuvia voi ladata SDL2_image-kirjaston funktiolla IMG_Load.

// Ohjelman alkuun:
#include <SDL2/SDL_image.h>
// Ladataan PNG-kuva.
kuvapinta = IMG_Load("kuva.png");

Kuvapinnan jäsenistä w ja h selviää kuvan koko.

int leveys, korkeus;
leveys = kuvapinta->w;
korkeus = kuvapinta->h;

Kuvapinnasta saa tekstuurin funktiolla SDL_CreateTextureFromSurface. Tekstuurit ovat sidoksissa tiettyyn piirtäjään. Ennen tekstuurin luontia voi asettaa sille sopivan skaalaustavan.

SDL_Texture *tekstuuri;
tekstuuri = SDL_CreateTextureFromSurface(piirtaja, kuvapinta);

Tekstuuriksi latauksen jälkeen kuvapinta täytyy vapauttaa, tai se jää turhaan tuhlaamaan muistia.

SDL_FreeSurface(kuvapinta);

Myös tekstuuri täytyy tuhota ohjelman lopussa ennen piirtäjän tuhoamista.

SDL_DestroyTexture(tekstuuri);

Piirtäminen

Piirtämisen alussa on viisasta tyhjentää tausta. Seuraava koodi piirtää kaiken mustaksi.

// Tyhjennetään tausta mustaksi.
// _SetRenderDrawColor(piirtaja, r, g, b, alpha);
SDL_SetRenderDrawColor(piirtaja, 0, 0, 0, 255);
SDL_RenderClear(piirtaja);

Edellä oleva tyhjentää koko ikkunan riippumatta resoluutiosta. Sen sijaan pelkästään käytössä olevan piirtoalueen voi tyhjentää, kun piirtää suuren suorakulmion. Tällä tavalla saadaan ruudun käyttämättömät reuna-alueet ja käytössä olevan alueen tausta erottumaan toisistaan.

// Piirretään koko piirtoalueen täyttävä valkoinen laatikko.
SDL_SetRenderDrawColor(piirtaja, 255, 255, 255, 255);
SDL_RenderFillRect(piirtaja, 0);

Kuvia piirretään funktioilla SDL_RenderCopy ja SDL_RenderCopyEx. Funktiot toimivat muuten samalla tavalla, mutta jälkimmäisellä voi myös pyörittää kuvaa.

Funktioille annetaan tieto siitä, mikä osa kuvasta piirretään; yksinkertaisinta on piirtää koko kuva. Lisäksi kerrotaan, mihin kohti ja minkä kokoisena kuva piirretään. Jotta kuva ei venyisi, täytyy käyttää sen oikeaa kokoa, jonka voi selvittää funktiolla SDL_QueryTexture tai ottaa muistiin jo latausvaiheessa. Kun kuvaa ei pyöritetä (kulma on 0), kuvan vasen yläkulma on määrätyissä koordinaateissa. Pyöritys tapahtuu funktiolle ilmoitettavan keskipisteen ympäri, jolloin käännetty kuva ei välttämättä osu enää määrättyihin koordinaatteihin; tähän seikkaan palataan vähän myöhemmin.

// Funktion SDL_RenderCopyEx parametrit:
// - piirtaja
// - tekstuuri
// - piirrettävä osa kuvasta (SDL_Rect); 0 = koko kuva
// - kohdealue eli kuvan paikka (x, y) ja koko (w, h)
// - kulma, jonka verran kuvaa käännetään, asteina myötäpäivään
// - piste (SDL_Point*), jonka ympäri kuvaa käännetään; 0 = keskipiste
// - peilataanko kuva; SDL_FLIP_NONE | SDL_FLIP_HORIZONTAL | SDL_FLIP_VERTICAL

// Paikka asetetaan suoraan, koko haetaan tekstuurin tiedoista.
SDL_Rect kohde = {10, 25};
SDL_QueryTexture(tekstuuri, 0, 0, &kohde.w, &kohde.h);

// Seuraavilla tavoilla kuvaa ei pyöritettäisi:
// SDL_RenderCopy(piirtaja, tekstuuri, 0, &kohde);
// SDL_RenderCopyEx(piirtaja, tekstuuri, 0, &kohde, 0, 0, SDL_FLIP_NONE);

// Piirretään kuva 30 astetta myötäpäivään käännettynä.
double kulma = 30;
SDL_RenderCopyEx(piirtaja, tekstuuri, 0, &kohde, kulma, 0, SDL_FLIP_NONE);

Kulma annetaan piirtofunktiolle asteina, kun taas C:n matematiikkafunktiot käyttävät radiaaneja. Yleensä kannattaa säilyttää kulmat ohjelmassa radiaaneina ja muuttaa ne asteiksi piirtovaiheessa esimerkiksi seuraavalla funktiolla:

double rad2deg(double t) {
	return t * 180 / 3.14159265358979323846;
}
// Esimerkki:
double piirtokulma = rad2deg(pelihahmon_suunta);

Piirtämisen lopuksi täytyy kutsua funktiota SDL_RenderPresent, jotta piirros tulee näkyviin.

SDL_RenderPresent(piirtaja);

Kuvan pyöritys keskipisteen ympäri

Usein halutaan, että kuvan keskipiste on ruudulla tietyssä kohdassa. Piirtofunktiolle taas pitää antaa kuvan kulman koordinaatit (ennen pyöritystä). Keskipisteen koordinaateista saadaan kulman koordinaatit, kun vähennetään puolet kuvan koosta. Esimerkiksi tässä kuvan keskipiste tulee kohtaan (10, 25) pyörityksestä riippumatta.

int leveys, korkeus;
SDL_QueryTexture(tekstuuri, 0, 0, &leveys, &korkeus);

int x = 10, y = 25;
SDL_Rect kohde = {
	x - leveys / 2,
	y - korkeus / 2,
	leveys,
	korkeus
};

double kulma = 30;
SDL_RenderCopyEx(piirtaja, tekstuuri, 0, &kohde, kulma, 0, SDL_FLIP_NONE);

Laskun voi laittaa omaan funktioon:

int SDL_RenderCopyEx_Keskitetty(SDL_Renderer *piirtaja, SDL_Texture *tekstuuri, SDL_Rect kohde, double kulma) {
	kohde.x -= kohde.w / 2;
	kohde.y -= kohde.h / 2;
	return SDL_RenderCopyEx(piirtaja, tekstuuri, 0, &kohde, kulma, 0, SDL_FLIP_NONE);
}
// Muualla:
SDL_Rect kohde = {x, y, leveys, korkeus};
SDL_RenderCopyEx_Keskitetty(piirtaja, tekstuuri, kohde, kulma);

Skaalaustapa

Kun kuvia venytetään tai käännetään, pikselien uudet värit voidaan valita eri tavoin. Yksi tapa – SDL:n oletustapa – on vain valita väri siitä pikselistä, joka on alkuperäisessä kuvassa lähimpänä uutta kohtaa. Muita tapoja ovat lineaariset suodatukset sekä hienompi anisotrooppinen suodatus, jota kaikki järjestelmät eivät tue. Käytettävä tapa valitaan funktiolla SDL_SetHint.

// Vain lähin pikseli:
SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "nearest");
// Lineaarinen suodatus eli ympäröivien pikseleiden painotettu keskiarvo:
SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "linear");
// Hienoin mahdollinen vaihtoehto, siis ehkä anisotrooppinen suodatus:
SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "best");

Kun tekstuuri luodaan, siihen tallentuu tieto, kumpaa tapaa käytetään, eikä valintaa voi enää sen tekstuurin kohdalla muuttaa. SDL_SetHint-funktiota pitää siis kutsua ennen tekstuurin luomista eli ennen SDL_CreateTextureFromSurface-kutsua. Valinta koskee myös funktiota SDL_RenderSetLogicalSize.

Lähimmän pikselin käyttö on perusteltua esimerkiksi silloin, kun piirretään pikseligrafiikkaa, jota ei venytetä eikä pyritetä ja jonka täytyy osua tarkalleen reunat vastakkain. Useimpiin muihin tilanteisiin sopii paremmin jompikumpi muista vaihtoehdoista. Anisotrooppinen suodatus vaatii koneelta enemmän mutta tuottaa muita paremman tuloksen silloin, kun kuva skaalataan alle puolikkaaksi tai yli kaksinkertaiseksi.

Esimerkkiohjelma

Seuraava ohjelma lataa kuvan tiedostosta kuva.bmp ja piirtää sen ruudulle neljänä kappaleena. Ohjelman koodit ovat käytännössä tästä oppaasta, paitsi piirtämisen ympärille on lisätty silmukka, jotta piirros näkyy ruudulla muutaman sekunnin.

#include <SDL2/SDL.h>
#include <stdlib.h>
#include <stdio.h>

int SDL_RenderCopyEx_Keskitetty(SDL_Renderer *piirtaja, SDL_Texture *tekstuuri, SDL_Rect kohde, double kulma) {
	kohde.x -= kohde.w / 2;
	kohde.y -= kohde.h / 2;
	return SDL_RenderCopyEx(piirtaja, tekstuuri, 0, &kohde, kulma, 0, SDL_FLIP_NONE);
}

int main(int argc, char** argv) {
	// Alustetaan kirjasto.
	if (SDL_Init(SDL_INIT_EVERYTHING) != 0) {
		fprintf(stderr, "Virhe: %s\n", SDL_GetError());
		exit(1);
	}

	// Asetetaan SDL_Quit suoritettavaksi ohjelman lopussa.
	atexit(SDL_Quit);

	// Luodaan koko ruudun täyttävä ikkuna nykyisellä resoluutiolla.
	SDL_Window *ikkuna;
	SDL_Renderer *piirtaja;
	if (SDL_CreateWindowAndRenderer(0, 0, SDL_WINDOW_FULLSCREEN_DESKTOP, &ikkuna, &piirtaja) != 0) {
		fprintf(stderr, "Virhe: %s\n", SDL_GetError());
		exit(2);
	}

	// Asetetaan ikkunalle otsikko.
	SDL_SetWindowTitle(ikkuna, "SDL2-kokeilu 1");

	// Asetetaan paras skaalaustapa ja näennäisesti 640x480-resoluutio.
	SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "best");
	SDL_RenderSetLogicalSize(piirtaja, 640, 480);

	// Ladataan kuva tiedostosta kuva.bmp.
	SDL_Surface *kuvapinta;
	kuvapinta = SDL_LoadBMP("kuva.bmp");
	if (!kuvapinta) {
		fprintf(stderr, "Virhe: %s\n", SDL_GetError());
		exit(3);
	}

	// Tehdään kuvasta tekstuuri ja vapautetaan kuvapinta.
	SDL_Texture *tekstuuri;
	tekstuuri = SDL_CreateTextureFromSurface(piirtaja, kuvapinta);
	SDL_FreeSurface(kuvapinta);

	// Piirretään viiden sekunnin ajan tai kunnes ohjelma suljetaan (Alt+F4).
	while (!SDL_QuitRequested() && SDL_GetTicks() < 5000) {
		// Tyhjennetään tausta mustaksi.
		SDL_SetRenderDrawColor(piirtaja, 0, 0, 0, 255);
		SDL_RenderClear(piirtaja);

		// Piirretään koko piirtoalueen täyttävä valkoinen laatikko.
		SDL_SetRenderDrawColor(piirtaja, 255, 255, 255, 255);
		SDL_RenderFillRect(piirtaja, 0);

		// Haetaan kuvan leveys ja korkeus tekstuurin tiedoista.
		SDL_Rect kohde = {0, 0};
		SDL_QueryTexture(tekstuuri, 0, 0, &kohde.w, &kohde.h);

		// Piirretään kuva piirtoalueen vasempaan yläkulmaan.
		kohde.x = 0;
		kohde.y = 0;
		SDL_RenderCopy(piirtaja, tekstuuri, 0, &kohde);

		// Piirretään kuva toisella funktiolla ensimmäisen viereen.
		kohde.x = kohde.w;
		kohde.y = 0;
		SDL_RenderCopyEx(piirtaja, tekstuuri, 0, &kohde, 0, 0, SDL_FLIP_NONE);

		// Piirretään kuva 45 astetta myötäpäivään käännettynä.
		// Käännön takia kuva ei ole oikeasti koordinaateissa (0, 0)!
		kohde.x = 0;
		kohde.y = 0;
		double kulma = 45;
		SDL_RenderCopyEx(piirtaja, tekstuuri, 0, &kohde, kulma, 0, SDL_FLIP_NONE);

		// Piirretään kuva 35 asteen kulmassa keskelle ruutua (320, 240).
		// Käytetään omaa piirtofunktiota, joka korjaa sijainnin niin,
		// että annetut koordinaatit tarkoittavat kuvan keskipistettä.
		kohde.x = 320;
		kohde.y = 240;
		kulma = 35;
		SDL_RenderCopyEx_Keskitetty(piirtaja, tekstuuri, kohde, kulma);

		// Näytetään piirrokset.
		SDL_RenderPresent(piirtaja);

		// Säästetään tehoa odottamalla 100 millisekuntia (0,1 sekuntia).
		SDL_Delay(100);
	}

	// Tuhotaan tekstuuri, piirtäjä ja ikkuna.
	SDL_DestroyTexture(tekstuuri);
	SDL_DestroyRenderer(piirtaja);
	SDL_DestroyWindow(ikkuna);

	// Ohjelma päättyy. SDL_Quit-kutsu laitettiin atexit-rivillä automaattiseksi.
	return 0;
}

Jälkien siivoaminen – miksi ja milloin?

Oppaassa sanotaan, että ikkuna pitää sulkea ja tekstuurit pitää tuhota lopussa, ja omien jälkien siivoaminen onkin hyvä tapa. Käytännössä kuitenkin ohjelman sulkeutuessa useimmat tällaiset asiat tapahtuvat myös itsestään, ellei käyttöjärjestelmässä tai ajureissa ole bugeja. Niinpä siivoamisesta tulee aivan välttämätöntä vasta silloin, kun esimerkiksi tekstuureita luodaan ja tuhotaan useaan kertaan ohjelman aikana: jos tuhoaminen unohtuu, muisti täyttyy ja ohjelma voi kaatua.


Kommentit

johku90 [15.09.2013 16:29:19]

#

Windowsia käyttäville korostaisin vielä, että -lSDL2main täytyy linkittää ennen -lSDL2:sta. Näin ainakin minulla, muuten tuli linkittämisen yhteydessä erroreita.

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