Bat Chase -pelin aloitusruutu: batmobile Gothamin yössä

Koodi

WebAssembly ja WebGL

Tee itse pelisi, nyt ilman GOTO 10 -käskyjä

2010-luvun alussa selainyhtiö Mozillalla oli ongelma. NPAPI-nimellä kulkevan selainlaajennosarkkitehtuurin soimaamisesta oli tullut syystäkin trendikästä, koska sen kautta oli mahdollista ajaa turvattomasti matalan tason prosessorikäskyjä selaimessa ilman minkäänlaista tietoturvarajausta. NPAPI-teknologiaan pohjautuva Flash-laajennos oli kuitenkin pönkittänyt itsensä ainoana audio- ja videoratkaisuna selainpelien ja videosoittimien toteuttamiseen, eikä vasta julkaistu HTML5-standardiperhe ollut vielä riittävä korvaamaan kaikkia Flashin käyttökohteita: suorituskykyä vaativat web-pelit olivat käytännössä aina Flashilla toteutettuja.

Mozillan Firefox-selaimen kaatumisanalytiikkajärjestelmän top-listoilla roikkuivat vuodesta toiseen Adoben suljetun lähdekoodin Flash-laajennoksen aiheuttamat ongelmat. Bugien ja tietoturvaongelmien takia NPAPI haluttiin ajaa alas, mutta jotta se olisi mahdollista, tarvittaisiin teknologiastandardit, jotka kattaisivat Flashin puuttumisen tuoman aukon. Mozilla ponkaisi pystyyn Mozilla Games Initiative -projektin, jonka tavoitteena oli tuoda ilman laajennoksia toimivat, natiiviin suorituskykyyn verrattavat avoimet peliteknologiastandardit webbiin.

Mozilla oli jo muutamaa vuotta aiemmin luonut prototyypin WebGL-standardista, joka mahdollisti 3D-kiihdytetyn grafiikan ajamisen selaimessa. JavaScript-kieli tosin aiheutti tälle pullonkaulan: monimutkaisten 3D-maailmojen renderöinti ja fysiikkamallinnus JavaScript-kieleen pohjautuen oli auttamatta liian hidasta. JIT-kääntäminen ei yksinkertaisesti sovellu rajusti reaaliaikaisuutta vaativille peleille, ja sellaisen JavaScript-koodin käyttö, jonka muistinkäyttöä roskienkeruujärjestelmän (garbage collector) ei tarvitse jatkuvasti siistiä, on liian epäidiomaattista.

Lisähaasteena peliteollisuudessa JavaScript-kieleen pohjautuvan peliteknologian kehittämistä ei pidetty uskottavana ratkaisuna, vaan pelimoottorit olivat koodin siirrettävyyden takaamiseksi pääsääntöisesti C- ja C++-kielillä toteutettuja. Mozilla Games Initiativen puitteissa pohdittiin jotain nurinkurista: olisiko mahdollista automaattisesti kääntää olemassa olevat C- ja C++-pelimoottorit JavaScriptiksi? Näin syntyi ensin asm.js-määrittely, joka myöhemmin kehittyi WebAssembly-standardiksi.

Mikä WebAssembly?

Erilaisten ohjelmointikielien suorittamiseksi on kehitetty monentyyppisiä virtuaalikoneita. Esimerkiksi Java, JavaScript-, C#- ja Python-ohjelmia suoritetaan omissa virtuaalikoneissaan (VM), yleensä ensin tulkaten ja sitten ajonaikaisesti optimoivasti kääntäen (just in time eli JIT).

Jotkut kielet (JavaScript, Python) tulkataan suoraan lähdekielestä, toisissa taas on mahdollista kääntää lähdekieli ensin konemuotoiseksi välikieleksi (intermediate representation eli IR, esim. Java, Python ja C#), jota sitten suoritetaan ja ajonaikaisesti JIT-optimoidaan.

WebAssembly taasen on suunniteltu tulkkaamisen sekä JIT-kääntämisen sijaan uudenlaista paradigmaa varten: streamattuun etukäteiskääntämiseen (ahead of time, AOT). Tässä ideana on, että korkean tason IR-koodin sijasta WebAssembly määrittää matalan tason (mutta tietoturvasyistä korkeasti strukturoidun) IR-koodin, joka on nopea kääntää prosessorin ymmärtämäksi x86- tai ARM-konekieleksi. Tämä käännös voidaan tehdä selaimessa sitä mukaa, kun WebAssembly-ohjelmatiedostoa (.wasm) vielä ladataan web-palvelimelta. Näin ison WebAssembly-ohjelmatiedoston kääntäminen ei hidasta sivun latausta paljoa verkkolatausnopeutta enempää.

WebAssemblya ei ole tarkoitettu käsin kirjoitettavaksi, vaan sitä on tarkoitus tuottaa jollain työkalulla, kuten pitkään jo myös natiivin konekielenkin kohdalla. Jätämme siis tylsän matalan tason spesifikaatioiden kahlaamisen väliin ja keskitymme tarkastelemaan, miten WebAssembly-koodia tuotetaan kääntäjän kautta.

WebAssembly ei myöskään tuo selaimeen mitään uusia funktionaalisia rajapintoja, vaan grafiikan, äänen ja syötteen käsittelemiseksi käytetään edelleen tuttuja JavaScript-spesifikaatioita, kuten WebGL-, Web Audio- sekä HTML DOM -standardit. Tällaisia sovelluksia ohjelmoitaessa hypimme siis joustavasti kahden kielen – C++:n ja JavaScriptin – välillä, toteuttaen laskennallisesti intensiivisen osan C++-kielen puolella ja selainrajapintojen käsittelyn tutussa JavaScript-koodissa.

C- ja C++-ohjelmat nettiselaimessa

Tässä artikkelissa raapaisemme pintaa WebAssembly-teknologiaan käytännön ohjelmoinnin kautta. Toteutamme oman 2D-pelin käyttäen WebGL-teknologiaa C++-kielellä ja käännämme sen Emscripten-työkalulla WebAssembly-muotoon nettiselaimessa ajettavaksi. Lopputuloksen voi laittaa jonnekin web-palvelimelle ja antaa kaverille linkin, jonka kautta hän pääsee pelaamaan välittömästi riippumatta koneensa arkkitehtuurista ja ilman latauksia tai asennuksia.

Artikkeli olettaa aiempaa kokemusta C++:sta sekä HTML:stä ja JavaScriptistä, mutta kuten vanhan ajan itse kirjoitettavat BASIC-ohjelmat, tämäkin pitäisi onnistua aloittelijalta maltillisesti koodi omaan projektiin kopioimalla. Haastavin osa pelin toteutusta on koodin kopiointi oikeassa järjestyksessä .cpp-tiedostoon. C++-kieli vaatii, että jokaisen muuttujan ja funktion esittely sijaitsee kooditiedostossa ennen sitä koodiriviä, jolla siihen viitataan. Muulloin kääntäjä antaa ”use of undeclared identifier” -virheen.

Ohjelmakoodi esitellään sarjana osakokonaisuuksiin jaoteltuja listauksia, jotka on tarkoitettu kirjoitettavaksi omaan projektiin yksi listaus kerrallaan. Jokaisen ohjelmalistauksen lisäämisen jälkeen ohjelman pitäisi olla toimivasti käännettävissä ja testattavissa. Listausten rivinumerot etenevät hyppien ja kertovat, missä kohden lopullista pelikoodia kyseinen ohjelmarivi sijaitsee. Näin rivinumerot auttavat hahmottamaan, mihin väliin mikäkin koodilohko tulee lisätä.

Emscripten on GCC- ja Clang-työkaluperheisiin rinnastettava kääntäjäohjelmistopaketti, joka koostaa C- ja C++-kielillä kirjoitetut ohjelmat kokoelmaksi HTML-, JavaScript- ja WebAssembly-tiedostoja, joita selaimet osaavat näyttää suoraan. Emscripten-kääntäjä pohjautuu LLVM-teknologiaan ja toimii Windows-, macOS- sekä Linux-isäntäkäyttöjärjestelmien kautta ajettuna. Emscripten + WebGL + WebAssembly on se teknologiapaketti, jolla Unreal Engine 3 ja 4, Godot sekä Unity 3D ja muut natiivit pelimoottorit toimivat suoraan nettiselaimessa ajettuna.

Emscriptenin asennus tapahtuu emsdk-nimistä SDK-hallinnointiohjelmaa käyttäen. Kun git on jo asennettuna (ks. Skrolli 2019.1, Git-versionhallinnan perusteet), niin emsdk:n ja Emscriptenin saa asennettua suorittamalla komentoriviltä

git clone https://github.com/emscripten-core/emsdk
cd emsdk
emsdk install latest
emsdk activate latest

Linux- ja macOS-käyttöjärjestelmissä suorita ./emsdk pelkän emsdk-komennon sijaan.

Tämän jälkeen Emscripten on asennettuna koneelle. Emscriptenin käyttö tapahtuu helpoiten astumalla aktiivisessa komentorivissä nk. Emscripten-ympäristöön, jossa kääntäjätyökalut tuodaan saataville PATH-muuttujaan. Tämä tapahtuu ajamalla emsdk_env (Windows) tai source ./emsdk_env.sh (Linux ja macOS).

Jos käynnistät uuden komentorivikehotteen, ylläoleva emsdk_env-toiminto pitää suorittaa uudelleen.

C- ja C++-ohjelmoijat ovat tämän jälkeen kiinni tutussa maailmassa: Emscripteniä ajetaan samoilla vivuilla kuin gcc- tai g++-kääntäjää (tai clang ja clang++) vain kutsumalla niiden sijasta komentoja emcc tai em++. Esimerkiksi Kernighanin ohjelma

#include <stdio.h>
int main() {
printf("hello, world\n");
}

kääntyy nyt ajamalla em++ hello_world.cpp -o hello.html. Tämän komennon ajaminen tuottaa kolme tiedostoa: hello.html, hello.js sekä hello.wasm.

Jos Node.js on tuttu, voit ajaa tämän tuotoksen komentoriviltä suorittamalla

node hello.js

Komento tulostaa konsoliin ”hello, world”. Node.js siis myös tukee WebAssemblyyn pohjautuvia komentoriviohjelmia.

Jos haluat ajaa sivua komentorivin sijaan nettiselaimessa, tarvitset siihen web-palvelimen. Mikä tahansa nginx-, Apache- tai IIS-paketeista toimii, mutta etenkin kehittäessä on helpointa käyttää jotain ad hoc -palvelinta, jolla saa nopeasti tarjoiltua HTML-tiedostoja oman koneen selaimeen. Emscripten-työkaluketju tarjoaa tähän emrun-komennon, joka toimii ajamalla

emrun hello.html

tai

emrun --no_browser . --port 8000

tai Python 3:a käyttämällä

python3 -m http.server 8000

Näistä ensimmäinen komento avaa hello.html-sivun suoraan oletusselaimeen, kun taas toinen ja kolmas komento tarjoilevat koko työkansion selaimelle vierailtavaksi osoitteessa http://localhost:8000/.

Tosikiinnostuneille Emscripten-työkalupakissa on lisäksi tarjolla komento wasm-dis, jolla voi purkaa (disassemble) .wasm-tiedoston tekstimuotoiseksi .wast-tiedostoksi (”WebAssembly text”), jos tekee mieli kurkata konepellin alle.

 (func $is_key_pressed    (param $0 i32) (result i32)  (if   (i32.eqz    (i32.load8_u     (i32.add      (local.get $0)      (i32.const 68976)     )    )   )   (return    (i32.const 0)   )  )  (i32.eqz   (i32.load8_u    (i32.add     (local.get $0)     (i32.const 3440)    )   )  ) )

Peliprojektin C++-funktio is_key_pressed() WebAssembly Text -muodossa wasm-dis-työkalulla avattuna.

”Tee pelejä, älä moottoreita”

Nyt kun osaamme ajaa kääntäjää, hyppäämme suoraan tuomaan pikseliä ruudulle. Artikkelissa esitellään lukijaprojekti, jossa toteutettiin uudelleen niin sanotussa endless runner -muodossa huima autoajokohtaus vanhasta Commodore 64 -klassikkopelistä Batman: The Movie. Juurikin se, jossa pelaaja kaahaa katuja batmobilella väistellen siviiliautoja, ja jota et koskaan päässyt läpi, ja painoit kasettiaseman play-nappia elämien loppuessa hypätäksesi pelin viimeiseen kenttään.

”Bat Chase” – tämä peli ei takuulla unohdu.

Äkkiä voisi ajatella, että pelin tekemiseksi tarvitsisimme pelimoottorin, mutta päinvastoin, jätämme tällaiset turhat abstraktiot ja indirektiot pois. Mitä vähemmän koodissa on wrapperia, moduulia ja pluginia, sitä oleellisempiin asioihin voimme pelin toteutuksessa keskittyä ja sitä selkeämmin näemme WebAssembly-alustan pelinkehityksen metsän puilta.

Batman: The Movie -pelin kansikuva
Batman: The Movie © Ocean (1989)

Strategiamme on toteuttaa 2D-spritejä ja tekstiä piirtävä renderöijä, näppäinsyötteen lukujärjestelmä, musiikin- ja ääniefektien toistaja, scene- sekä tilamalli, sekä pelin päivityssilmukka. Tämä kaikki ja sen päälle vielä Batman-pelin logiikan toteuttaminen sujuu meiltä matalimmalla mahdollisella tasolla ilman kirjastoviidakkoja vain noin viidessäsadassa koodirivissä – pitelepä kaljaani. Lopputuloksena on minimalistinen, mutta silti geneerinen 2D-pelikoodirakenne, jonka elementtejä omaksuen voi lähteä toteuttamaan WebAssembly + WebGL -pohjaisia 2D-pelejä oman mielen mukaan.

Canvas & Context

WebGL-rajapinta kytkeytyy HTML-sivuun <canvas>-elementin kautta. Canvas on fyysisesti webbisivulla tilaa täyttävä piirtoikkuna, johon peli renderöidään 3D-kiihdytettynä WebGL-rajapinnan läpi. Alkuun pääsemiseksi meidän ei tarvitse määritellä HTML-sivua itse, vaan Emscriptenin tarjoama vakiosivu tarjoaa jo tähän soveltuvan <canvas>-tagin. Omaksi työksemme jää määritellä kyseisen elementin koko sekä alustaa WebGL-renderöintikonteksti canvas-elementin päälle. Koska HTML-sivumme on tarkoitus sisältää vain WebGL-piirrettyä sisältöä eikä muita HTML-elementtejä, suurennamme canvasin peittämään koko HTML-sivun.

HTML-selaimen canvas-elementti erottelee kaksi kokokäsitettä: canvasin sivulla näkyvän koon (”CSS size”) ja WebGL-kontekstin renderöintipinnan koon (”WebGL render target size”) toisistaan. Selain venyttää WebGL-pinnan, vakiona bilineaarisesti, canvasin määrittelemälle kuvapinta-alalle. Näitä määrittelyjä kirjoittaessa on hyvä pitää mielessä, millaisen koordinaatiston haluaa asettaa pelille. Tässä demossa käytämme kiinteänkokoista pelikoordinaatistoa, joka täsmää piirtopinnan koon kanssa (mielivaltaisesti valittu 569×388 pikseliä), ja annamme selaimen venyttää tämän kuvan koko selainikkunaan. Ohjelmakoodimme alkaa:

1#include <emscripten/html5.h>2#include <webgl/webgl2.h>25#define GAME_WIDTH 56926#define GAME_HEIGHT 38827#define STREET_HEIGHT 1602829#define MIN(x, y) ((x) < (y) ? (x) : (y))30#define MAX(x, y) ((x) > (y) ? (x) : (y))201void init_webgl() {202  EM_ASM(document.body.style = 'margin: 0px; overflow: hidden; background: #787878;');203  EM_ASM(document.querySelector('canvas').style['imageRendering'] = 'pixelated');204  double scale = MIN(EM_ASM_DOUBLE(return window.innerWidth) / GAME_WIDTH, EM_ASM_DOUBLE(return window.innerHeight) / GAME_HEIGHT);205  emscripten_set_element_css_size("canvas", scale* GAME_WIDTH, scale*GAME_HEIGHT);206  emscripten_set_canvas_element_size("canvas",  GAME_WIDTH, GAME_HEIGHT);207208  EmscriptenWebGLContextAttributes attrs;209  emscripten_webgl_init_context_attributes(&attrs);210  attrs.alpha = 0;211  attrs.majorVersion = 2;212  emscripten_webgl_make_context_current( emscripten_webgl_create_context("canvas", &attrs));…244}445int main() {446  init_webgl();…461}

Listaus 1. batman.cpp – WebGL-kontekstin luonti.

Käännä tämä ohjelma Emscriptenillä käyttäen vipuja -lGL -sMIN_WEBGL_VERSION=2 -sMINIMAL_RUNTIME=1. Näistä direktiiveistä ensimmäinen linkittää mukaan WebGL-kirjaston; toinen kertoo tälle, että tarkoitus on nimenomaan käyttää WebGL 2 -versiota ja kolmas ottaa käyttöön yksinkertaisen HTML-sivun, johon saamme helposti koko ruudun kokoisen canvas-elementin. Eli siis

em++ batman.cpp -lGL -sMIN_WEBGL_VERSION=2 -sMINIMAL_RUNTIME=1 -o batman.html

Lopputuloksen saat helpoiten käynnistymään selaimessa ajamalla emrun batman.html. Emrun-palvelimen ajo loppuu näppäinyhdistelmällä Ctrl-C.

Jos haluat, että peli toimii vanhemmilla selaimilla (erityisesti Safarin vanhat versiot), voit kokeilla jättää WebGL-kontekstin versioon 1.

Heti funktion init_webgl() alussa esitellään yksi Emscripten-ympäristön ainutlaatuisista ominaisuuksista: EM_ASM()- sekä EM_ASM_DOUBLE()-makrot sallivat C- tai C++-ohjelman sisällyttää JavaScript -koodia sisäänsä inline-muodossa. EM_ASM_DOUBLE()-makro ajaa annetun JavaScript-koodipätkän ja palauttaa double-tyyppisen arvon, EM_ASM() taasen palauttaa void. (Saatavilla on myös versio EM_ASM_INT()). EM_ASM-alkuiset käskyt ovat käytännöllisiä pika-apuja lyhyiden JavaScript-rajapintapätkien ajamiseen erityisesti prototyyppivaiheessa, tosin isompiin lohkoihin suositellaan Emscriptenin JavaScript-kirjastotiedostoja, joihin tutustutaan hieman myöhemmin.

Listauksen 2 rivi 204 laskee suurimman skaalauskertoimen, jonka verran kuvaa suurennetaan siten, että se täyttää selainikkunan, säilyttäen kuvasuhteen (aspect ratio).

Canvas-elementin CSS-koon sekä renderöintipinnan koon määrityksen jälkeen funktio alustaa WebGL 2 -kontekstin käyttämällä Emscriptenin WebGL 2 -järjestelmäkutsuja. WebGL 2 vastaa toiminnallisuudeltaan OpenGL ES 3.0 -rajapintaa, joten jos tämä on hallussa, niin Emscriptenin läpi WebGL 2 -rajapinnan ohjelmointi tulee näyttämään todella tutulta. Skrolleissa 2013.2, 2013.3, 2013.4 ja 2014.1 tutustuttiin OpenGL-ohjelmointiin.

Funktion init_webgl() lopussa luotu grafiikkakonteksti asetetaan aktiiviseksi kutsulla emscripten_webgl_make_context_current(). Tämä vastaa EGL/WGL/GLX/CoreGL-standardeista tuttua *_make_current()-abstraktiota, jossa säiekohtaisesti täsmälleen yksi grafiikkakonteksti on kerrallaan käytössä. Kaikki tämän jälkeen kutsutut gl*-alkuiset komennot kohdistuvat tähän aktiiviseen kontekstiin.

Tässä vaiheessa lukijalle alkanee valjeta, että Emscripten tarjoaa oman kokonaisen C-kielisen järjestelmäkirjastomaailmansa. Emscriptenin omat järjestelmäfunktiot alkavat pääsääntöisesti sanalla emscripten_. Näiden referenssidokumentaatio löytyy osoitteesta emscripten.org.

Piirtosilmukka

Jos ajat yllämainitun ohjelman, tulos on lepakonmusta canvasruutu. Testataan koodin toimivuutta hieman laajentamalla siihen piirtosilmukka, joka vaihtaa canvas-elementin väriä.

3#include <emscripten/em_math.h>248EM_BOOL game_tick(double t, void *) {*     glClearColor(emscripten_math_sin(t/500.0), 0.f, 0.f, 1.f);*     glClear(GL_COLOR_BUFFER_BIT);261  return EM_TRUE; // Jatka peliloopin ajoa262}445int main() {446  init_webgl();447  emscripten_request_animation_frame_loop(&game_tick, 0);…461}

Listaus 2. batman.cpp – Pelisilmukka. Tähdellä merkityt rivit ovat testikoodia, jotka voi poistaa lopullisesta ohjelmasta.

Tämän pitäisi tuottaa Gothamin ambulanssin hälytysvalojen tahdissa punaisena vilkkuva canvas-ruutu. Koodissa vilahtavat WebGL/OpenGL ES -rajapinnan ruuduntyhjennysfunktiot: glClearColor() määrittää RGBA-värin, jolla ruutu pyyhitään, asteikolla [0.0, 1.0], ja glClear() tyhjentää ruudun.

Koodi sisältää myös muutaman uuden Emscripten-järjestelmäkutsun. JavaScript-ohjelmoijat huomaavat näissä jotain tuttua: emscripten_math_sin()-funktio suorittaa JavaScript-kielen Math.sin()-funktion ja emscripten_request_animation_frame_loop() taas käynnistää requestAnimationFrame()-pohjaisen animaatiosilmukan. Tälle funktiolle annetaan määreenä oma takaisinkutsufunktio, tässä game_tick(), jota selain alkaa toistuvasti kutsua näytön virkistystaajuuden tahdissa animaatiosilmukan toteuttamiseksi. Selain tarjoilee funktiolle game_tick() parametrina seinäkelloajan t, joka ilmoittaa nettisivun käynnissä olleen ajan millisekunteina. Toisin kuin natiivit C- ja C++-ohjelmat, Emscriptenillä selaimessa ajetut ohjelmat eivät (usein) pääty silloin, kun main-funktio loppuu, vaan ohjelma jatkaa asynkronisesti suoritusta niin kauan, kuin selainsivu on auki.

Funktion game_tick() tulee palauttaa EM_TRUE, jos animaation halutaan jatkuvan. Jos funktio palauttaa EM_FALSE, niin animaatiosilmukka – ja siten tässä tapauksessa koko ohjelma – päättyy.

Shaderit+verteksit

WebGL-rajapintaa käytettäessä piirtäminen tapahtuu aina vertex- sekä fragment shader -ohjelmia käyttäen. Tarvitsemme siis koodipätkän, joka kääntää GLSL-kielisen 2D-kuvia piirtävän shader-ohjelman näytönohjaimen käyttöön. Käytämme vain yhtä shader-ohjelmaa, jota tulemme soveltamaan sekä 2D-spritejen että tekstin piirtämiseen ruudulle. Lisäämme seuraavan koodin:

115GLuint compile_shader(GLenum type, const char *src) {116  GLuint shader = glCreateShader(type);117  glShaderSource(shader, 1, &src, NULL);118  glCompileShader(shader);119  return shader;120}121122GLuint create_program(GLuint vertexShader, GLuint fragmentShader) {123  GLuint program = glCreateProgram();124  glAttachShader(program, vertexShader);125  glAttachShader(program, fragmentShader);126  glBindAttribLocation(program, 0, "pos");127  glLinkProgram(program);128  glUseProgram(program);129  return program;130}131132GLuint vertexBuffer, matrixPosition, colorPosition;201void init_webgl() {…213  static const char vertex_shader[] =214    "attribute vec4 pos;"215    "varying vec2 uv;"216    "uniform mat4 mat;"217    "void main(){"218      "uv=pos.xy;"219      "gl_Position=mat*pos;"220    "}";221  GLuint vs = compile_shader(GL_VERTEX_SHADER,  vertex_shader);222223  static const char fragment_shader[] =224    "precision lowp float;"225    "uniform sampler2D spriteTexture;"226    "uniform vec4 constantColor;"227    "varying vec2 uv;"228    "void main(){"229      "gl_FragColor=constantColor*texture2D( spriteTexture,uv);"230    "}";231  GLuint fs = compile_shader(GL_FRAGMENT_SHADER, fragment_shader);232  GLuint program = create_program(vs, fs);233  matrixPosition = glGetUniformLocation(program, "mat");234  colorPosition = glGetUniformLocation(program, "constantColor");235236  glEnable(GL_BLEND);237  glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);…244}

Listaus 3. batman.cpp – Shader-ohjelma.

Kaavio: vertex shader ja fragment shader
2D-renderöijän shader-työvaiheet.

Näytönohjain suorittaa vertex shader -koodin jokaista verteksiä (tässä spriten neljää nurkkapistettä) kohti ja pixel shader -koodin jokaista piirrettävänä olevan spriten kuvapikseliä kohden.

Toteutamme vertex shaderin, joka soveltuu 2D-kuvien piirtoon: shader lukee yhtä positioattribuuttia pos ja transformoi sen haluttuun kohtaan ruudulla käyttämällä 4×4-matriisia mat. Määritämme tämän matriisin arvot piirtofunktiossa juuri ennen jokaista piirtokutsua. Positioattribuutista pos johdetaan vielä tekstuuriyksikölle UV-koordinaatit uv.

Fragment shader taasen laskee jokaisen pikselin väriarvon lukemalla kuvapisteitä tekstuurista spriteTexture ja moduloimalla niiden värejä halutulla vakioväriarvolla constantColor. Tällä moduloinnilla mahdollistamme erivärisen tekstin piirtämisen, mutta tätä parametria voisi myös käyttää esimerkiksi alfa-häivytysefektin toteuttamiseen, fade to black -efektiin tai muuhun kuvapikselien sävytykseen.

Alustuksen lopussa otamme käyttöön renderöijän alfasekoitusominaisuuden rivillä 236. Tämän avulla PNG-kuvissa ja fonteissa oleva läpinäkyvyysdata (alfakanava) otetaan huomioon spritejä piirrettäessä.

Spriterenderöijän alustuskoodi on geometriapuskuria vaille valmis. Sen alustamme muutamalla rivillä seuraavasti:

201void init_webgl() {…238  glGenBuffers(1, &vertexBuffer);239  glBindBuffer(GL_ARRAY_BUFFER, vertexBuffer);240  const float pos[] = { 0, 0, 1, 0, 0, 1, 1, 1 };241  glBufferData(GL_ARRAY_BUFFER, sizeof(pos), pos,  GL_STATIC_DRAW);242  glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, 0);243  glEnableVertexAttribArray(0);244}

Listaus 4. batman.cpp – Geometriapuskuri.

Koodi luo vertexBuffer-objektin joka sisältää datan kahdesta kolmiosta koostuvan yksikköneliön piirtämiseen (1. kolmio koordinaatit (0,0) →(1,0) → (0,1) sekä 2. kolmio koordinaatit (0,1) → (1,0) → (1,1)).

Koska ohjelmassa ei käytetä muita geometriapuskureita, asetamme tämän VBO:n aktiiviseksi heti alustusvaiheessa. Nyt meillä on valmiina GPU-ohjelma, joka piirtää HTML-canvasille matriisilla transformoituja tekstuurisamplattuja suorakaiteita, mutta jotta voisimme kokeilla sitä käytännössä, tarvitsemme vielä koodipätkän, joka lataa PNG-kuvan näytönohjaimen tekstuurimuistiin.

PNG-kuvat

Yksi korkean tason selainohjelmoinnin anteja on, että selaimesta löytyy suoraan valmiina toiminnallisuus kuvien lataamiseksi. Meidän ei siis tarvitse integroida jotain C- tai C++-pohjaista kuvienlatauskirjastoa, kuten libpng, stb_image, tai DevIL. Sen sijaan toteutamme C++:sta JavaScriptin puolelle sillan, jossa käskytämme selainta lataamaan kuvan halutusta URL-osoitteesta ja syöttämään lopputuloksen WebGL-rajapintaa käyttäen suoraan GPU-muistiin.

Voisimme käyttää edellä esiteltyä EM_ASM()-blokkia, mutta koska JavaScript-koodirivejä on enemmän kuin pari, teemme uuden tiedoston library_batman.js, johon kirjoitamme JavaScript-kirjastofunktioita:

1mergeInto(LibraryManager.library, {2  upload_flipped: function(img) {3    GLctx.pixelStorei(0x9240/*GL_UNPACK_FLIP_Y_WEBGL*/, true);4    GLctx.texImage2D(0xDE1/*GL_TEXTURE_2D*/, 0, 0x1908/*GL_RGBA*/, 0x1908/*GL_RGBA*/, 0x1401/*GL_UNSIGNED_BYTE*/, img);5    GLctx.pixelStorei(0x9240/*GL_UNPACK_FLIP_Y_WEBGL*/, false);6  },78  load_image__deps: ['upload_flipped'],9  load_image: function(glTex, url, outWidth, outHeight) {10    let img = new Image();11    img.onload = () => {12      HEAPU32[outWidth>>2] = img.width;13      HEAPU32[outHeight>>2] = img.height;14      GLctx.bindTexture(0xDE1/*GL_TEXTURE_2D*/, GL.textures[glTex]);15      _upload_flipped(img);16    };17    img.src = UTF8ToString(url);18  },65});

Listaus 5. library_batman.js – Kuvatiedoston lataus.

Tämä JavaScript-tiedosto on Emscriptenin JS-kirjastotiedosto, ja se sisältää C++-ohjelman kanssa yhteen linkattavia JavaScript-funktioita. Nämä funktiot kirjoitetaan aina blokin mergeInto(LibraryManager.library, { ... }); sisään. Kirjastotiedosto tulee ottaa käyttöön ohjelman linkitysvaiheessa antamalla em++-komennolle parametri --js-library library_batman.js.

Tiedosto määrittelee kaksi funktiota: upload_flipped sekä load_image. Näistä ensimmäisen rooli on syöttää annettu JavaScript-kuvaelementti GPU-tekstuurimuistiin. Jotta tämä näkyisi oikein päin, haluamme kopioida kuvan siten, että se peilataan Y-suunnassa (koordinaatistokäytäntöjen vuoksi), joten funktio texImage2D() on vuorattu pixelStorei()-asetuskäskyjen sisään.

Funktiossa load_image tapahtuu monta mielenkiintoista askelta. Luomme HTML Image -elementin ja asetamme sille onload-latauskäsittelijän. Lopuksi lataus käynnistyy, kun asetamme kuvaelementin src-attribuuttiin kuvan osoitteen merkkijonona. C- ja C++-maailmasta JavaScript -kieleen kutsuttaessa C-merkkijonot ovat osoittimia eli JavaScriptin Number-arvoja, joten ne pitää muuttaa JavaScript-merkkijonoiksi kutsumalla Emscriptenin sisältämää järjestelmäfunktiota UTF8ToString(). Tätä kutsutaan yleisesti ohjelmointikielten väliseksi tyyppimuunnokseksi (type marshalling).

Kuvatiedosto tulee latautumaan selaimessa asynkronisesti. onload-funktio toteuttaa koodinpätkän, joka ajetaan, kun kuvan lataus on valmis. Ensimmäisenä tallennamme kuvan leveyden ja korkeuden C++-puolelle kahden osoitinmuuttujan määrittämiin muistiosoitteisiin, mikä tapahtuu kirjoittamalla Emscriptenin varattuun muistitaulukkosanaan HEAPU32. Näin tiedämme myöhemmin 2D-renderöijässä, minkä kokoisena kuva pitää piirtää ruudulle. Funktiopari bindTexture() ja _upload_flipped() taasen toteuttaa itse kuvan syöttämisen GPU-muistiin. Huomaa ylimääräinen merkki _ sanan _upload_flipped edessä. Tämä johtuu C-nimikonventiosta, jolla hoidetaan nimikonflikteja C- ja JS-kielten välillä, samoin kuten natiivikääntäjät hoitavat nimikonflikteja C:n ja C++:n sekä natiivin konekielen välillä.

Koska upload_flipped-funktiota kutsutaan funktiosta load_image, JS-tiedosto sisältää Emscripten-linkkerille dependency-määreen listauksen 5 rivillä 8. Tämä auttaa linkkeriä selvittämään tarvittavan funktiokutsupuun linkitysvaiheessa, jotta funktio upload_flipped ei jää pois käännöksestä.

Funktiota load_image sen sijaan tullaan kutsumaan C++-koodista. Voimme kutsua tässä tiedostossa määriteltyjä funktioita C++-maailmasta, kunhan esittelemme ne siellä ensin extern ”C” -nimikonventiota käyttäen. Näin saamme yhdistettyä ohjelmaamme sekä C++- että JavaScript-koodia.

Funktioissa viitataan muuttujaan GLctx. Tämä muuttuja tarkoittaa sitä WebGL-kontekstia, jonka asetimme aktiiviseksi aiemmin kutsumalla funktiota emscripten_webgl_make_context_current().

Jatkaen batman.cpp-tiedostoa:

142extern "C" void load_image(GLuint glTexture, const char *url, int *width, int *height);143144GLuint create_texture() {145  GLuint tex;146  glGenTextures(1, &tex);147  glBindTexture(GL_TEXTURE_2D, tex);148  glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);149  glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);150  glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S,  GL_CLAMP_TO_EDGE);151  glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T,  GL_CLAMP_TO_EDGE);152  return tex;153}154155void draw_image(GLuint glTexture, float x, float y, float width, float height, float r=1.f, float g=1.f, float b=1.f, float a=1.f) {156  const float pixelWidth = 2.f / GAME_WIDTH;157  const float pixelHeight = 2.f / GAME_HEIGHT;158  float spriteMatrix[16] = {159    width*pixelWidth,      0,                      0, 0,160    0,                     height*pixelHeight,     0, 0,161    0,                     0,                      1, 0,162    (int)x*pixelWidth-1.f, (int)y*pixelHeight-1.f, 0, 1163  };164165  glUniformMatrix4fv(matrixPosition, 1, 0, spriteMatrix);166  glUniform4f(colorPosition, r, g, b, a);167  glBindTexture(GL_TEXTURE_2D, glTexture);168  glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);169}

Listaus 6. batman.cpp – Tekstuurin luonti ja piirto.

Tässä funktio create_texture() luo uuden GL-tekstuurin ja muuttaa sen parametreja paremmin 2D-spritejen piirtoon sopivaksi poistamalla vakiona päällä olevan mipmappaamisen ja texture wrap -ominaisuuden, jotka GL-kielessä on tarkoitettu enemmänkin 3D-mallien teksturointia ajatellen.

draw_image()-funktio taasen sisältää hieman raakaa matriisimatematiikkaa. spriteMatrix on affiini kaksiulotteinen translaatio- ja skaalausmatriisi, joka transformoi verteksipuskurin yksikköneliön koordinaatit pyydettyyn kohtaan ruutua. GL-standardissa ruudun XY-koordinaatit on normalisoitu [-1.0, 1.0] -välille, eli koordinaatti (-1,-1) vastaa canvasin vasenta alanurkkaa ja (1,1) vastaa canvasin oikeaa ylänurkkaa. Pelikoordinaatistossamme taas (0,0) on vasen alanurkka ja (GAME_WIDTH, GAME_HEIGHT) oikea ylänurkka, joten tämä 4×4-matriisi (josta jätämme käyttämättä kolmatta Z-ulottuvuutta) kuvaa siis affiinilla muunnoksella pelikoordinaatiston normalisoituun GL-koordinaatistoon yhdellä matriisin ja vektorin tulolla (mat*pos-lauseke vertex shader -koodissa).

Funktio glUniformMatrix4fv() syöttää matriisin shaderkoodiin, glUniform4f() sen sijaan syöttää halutun tekstuurin modulointivärin fragment shaderille (muuttujaan constantColor). Funktio glBindTexture() aktivoi halutun kuvan, jota piirretään ja glDrawArrays() lähettää itse piirtokutsun. Näin saamme spriten näkyviin ruudulle.

Kokeillaan tätä koodia käytännössä. Ladataan kuvatiedosto ja muutetaan aiempaa animaatiosilmukkaa piirtämään kuva seuraavasti:

*GLuint testImage;*int testImageWidth, testImageHeight;248EM_BOOL game_tick(double t, void *) {*  draw_image(testImage, 0, 0, testImageWidth, testImageHeight);261 return EM_TRUE; // Jatka peliloopin ajoa262}445int main() {446  init_webgl();447  emscripten_request_animation_frame_loop(&game_tick, 0);*  testImage = create_texture();*  load_image(testImage, "title.png", &testImageWidth, &testImageHeight);461}

Listaus 7. batman.cpp – Kuvan piirtokokeilu. Tähdellä merkityt rivit ovat testikoodia, jotka voi poistaa lopullisesta ohjelmasta.

Kuvatiedostot sijoitetaan samaan kansioon batman.html-tiedoston kanssa, niin selain osaa ladata ne oikeasta osoitteesta.

Yllä oleva testikoodi on tärkeä etappi pelimme kehityksessä: 2D-renderöijä ja animaatiosilmukka on nyt valmis. Renderöijä on yksinkertainen mutta täysin riittävä. Ammatikseen grafiikkaa koodaava arvostelisi sitä tehottomaksi, sillä jokaisen spriten piirtoon omistetaan yksi piirtokäsky. Grafiikkakoodauksen klassinen taidonnäyte onkin toteuttaa spritetekstuurien atlasointi sekä piirtokutsujen instansointi ja/tai puskurointi, jolloin yhdellä piirtokutsulla saadaan useampia spritejä piirrettyä – siitä sopiva jatkokehitysidea, jos mieli tekee skaalata rutiini piirtämään useita tuhansia spritejä ruudulle samanaikaisesti.

Kuvien lataus

Koska pelit koostuvat useista datatiedostoista, tärkeä osa pelimoottoreita on peliassetteja hallinnoiva resurssimanageri. Tässäkin pelissä tarvitsemme useita PNG-kuvia ja äänitiedostoja, mutta koska pelimme on niin pieni, voimme luetella kaikki pelin tarvitsemat assetit jo koodausvaiheessa ja ne voidaan kaikki ladata muistiin heti pelin alkaessa. Siispä kirjoitamme maailman yksinkertaisimman kuvatiedostoja lataavan ”resurssijärjestelmän”:

32struct Image {33  const char *url;34  GLuint glTexture;35  int width, height;36};3738Image images[] = {39  {},40  { "title.png" },41  { "scorebar.png" },42  { "road.png" },43  { "batman.png" },44  { "life.png" },45  { "car1.png" },46  { "car2.png" },47  { "car3.png" },48  { "car4.png" },49  { "car5.png" },50  { "car6.png" },51  { "car7.png" },52  { "car8.png" }53};54// Samassa järjestyksessä kuin images-taulukossa:55enum {56  IMG_TEXT = 0,57  IMG_TITLE,58  IMG_SCOREBAR,59  IMG_ROAD,60  IMG_BATMAN,61  IMG_LIFE,62  IMG_CAR1, // ..., IMG_CAR863};445int main() {  [ ... ]448  for(Image &i : images) {449    i.glTexture = create_texture();450    if (i.url) load_image(i.glTexture, i.url, &i.width, &i.height);451  }…461}

Listaus 8. batman.cpp – Kuvatiedostojen resurssijärjestelmä.

Tämän monimutkaisempaa abstraktiota emme tarvitse. Tietue Image pitää kirjaa kuvatiedostosta: sen URL-osoitteen, GL-resurssikahvan, sekä kuvan leveyden ja korkeuden pikseleinä. Muodostamme Image-tietueista taulukon images, joka sisältää kaikki pelin käyttämien kuvien tiedostonimet. Taulukon ensimmäinen indeksi 0 jätetään tyhjäksi, koska käytämme sitä myöhemmin määrittelemään ohjelmallisesti luotuja merkkijonoja. Teemme vielä näitä kuvatiedostojen indeksejä vastaavat IMG_*-tunnisteet, jotta voimme koodissa viitata luettavasti images[IMG_BATMAN] sen sijaan, että kirjoittaisimme images[4].

main()-funktion alkuun lisäämme silmukan, joka luo tarvittavat GL-tekstuurit ja käynnistää jokaisen kuvatiedoston latauksen web-palvelimelta. Näin kuvat ovat käytettävissä pelilogiikan renderöintiä varten, tosin asynkronisesti, jolloin kuvat putkahtavat hitaalla nettiyhteydellä näkyviin pelin jo käynnissä ollessa. Jos haluaisimme, niin voisimme toteuttaa latausruudun, joka odottaa että kaikkien kuvien lataus on valmistunut ennen pelin käynnistämistä, mutta näin pienellä pelillä se ei maksa vaivaa.

Näyttämömalli

Pelimaailman objekteista pitää kirjaa näyttämötietorakenne (scene data structure). Sellainen löytyy jokaisesta pelistä ja se on suosituimmin kompositioon tai joskus harvemmin perintään pohjautuva. Kompositiojärjestelmissä vilisevät termit kuten Entity-Components system (EC), Entity-Component-System (ECS) tai Entity-Component-Attributes (ECA), joilla tarkoitetaan käytännössä samaa asiaa. Tällaisia järjestelmiä on ollut käytössä reilut parikymmentä vuotta. Perintäpohjaiset järjestelmät olivat suosituimpia vuosituhannen taitteessa, mutta ovat sittemmin kuihtuneet pois suosiosta.

Näyttämötietorakenteen filosofiaan liittyvät erilaiset organisointi- ja hakujärjestelmät, jotka ovat joko spatiaalisia tai indeksoituja. Spatiaaliset hakujärjestelmät organisoivat objekteja 2D- tai 3D-sijaintinsa perusteella, ja niillä nopeutetaan erilaisia geometrisia kyselyjä, esimerkiksi ”hae tähän säteeseen/laatikkoon/palloon törmäävät kappaleet” tai ”kulje näyttämön vihollisobjektit läpi järjestyksessä pelaajaa lähimmästä kauimpaan”. Indeksoidut hakujärjestelmät taasen nopeuttavat tiettyjen objektien hakua näyttämöstä ei-spatiaalisilla ehdoilla (”hae pelaajaobjekti”, ”hae aktiivinen kamera”).

Tässä minipelissämme emme lähde tällaisiin humputuksiin, koska erilaisia peliobjekteja on niin vähän ja peliobjektityyppejäkin on vain kaksi: sprite tai merkkijono. Toteutamme kuitenkin tagijärjestelmän, jolla voimme luettavasti viitata tiettyihin peliobjekteihin (”pelaajan auto”, ”ennätyspistelaskuri”, jne.), mutta emme rakenna siitä minkäänlaista hakuoptimointia.

Polymorfismia tarjoilee unionimalli, jossa käytämme yhtä tietuetta Object kuvaamaan molemmantyyppisiä peliobjekteja. Näyttämötietorakenteen virkaa pitää yksinkertaisesti std::vector<Object>-tyyppinen taulukko.

Peliobjekteilla on massa ja nopeus. Tekstimerkkijonoilla sen sijaan on rgba-väri ja tarvittavat fonttitiedot: fontin ID, koko ja tekstin merkkiväli.

Tagijärjestelmä sekä apufunktiot find_sprite() ja find_sprite_index() antavat helpon tavan löytää näyttämöltä tarvitsemamme peliobjektit sitten, kun toteutamme pelilogiikkaa.

Sarjakuvaruutu: Robin ja Batman
Batman tietää näyttämöjärjestelmien sudenkuopat.
4#include <vector>65enum Tag {66 TAG_NONE = 0,67 TAG_PLAYER,68 TAG_ENEMY,69 TAG_ROAD,70 TAG_LIFE1,71 TAG_LIFE2,72 TAG_LIFE3,73 TAG_SCORE,74 TAG_HIGH_SCORE,75 TAG_MINUTES,76 TAG_SECONDS77};7879struct Object {80  float x, y;81  int img;82  Tag tag;8384  // Jos img == IMG_TEXT:85  char text[64];86  float r,g,b,a;87  int fontId, fontSize, spacing;8889  // Jos img != IMG_TEXT:90  float mass, velx, vely;91};9293std::vector<Object> scene;9495int find_sprite_index(Tag tag) {96  for(size_t i = 0; i < scene.size(); ++i)97    if (scene[i].tag == tag) return i;98  return -1;99}100101Object *find_sprite(Tag tag) {102  int i = find_sprite_index(tag);103  return (i >= 0) ? &scene[i] : 0;104}105106void remove_sprite_at_index(int i) {107  scene.erase(scene.begin() + i);108}109110void remove_sprite(Tag tag) {111  int i = find_sprite_index(tag);112  if (i >= 0) remove_sprite_at_index(i);113}

Listaus 9. batman.cpp – Scene- eli näyttämötietorakenne.

Näin voimme kirjoittaa find_sprite(TAG_PLAYER) löytääksemme pelaajan Batman-autoa vastaavan spriten. Nämä hakufunktiot ovat lineaarisia kompleksisuudeltaan, joten jos pelissä on useita satoja objekteja, niiden suorituskyky ei enää ole kovin hyvä. Kun spritejä on alle sata, suorituskyky on kuitenkin enemmän kuin riittävä.

Tilakone ja näyttämön renderöinti

Näyttämö- eli scene-järjestelmän lisäksi pelien ytimessä toimii aina jonkinlainen tilakonejärjestelmä, käytännössä useampia. Nämä ovat pelikoodeissa joko implisiittisiä boolean-farmeja tai sitten jonkinlaisia abstrahoituja tilakoneluokkia. Tähän peliin toteutamme suositun huone-käsitteen (room), joka lienee konkreettisimmin näkyvillä vanhoissa Sierran ja LucasArtsin seikkailupeleissä.

246void (*currentRoom)(float t, float dt) = 0;247248EM_BOOL game_tick(double t, void *) {249  static double prevT;250  float dt = MIN(50.f, (float)(t - prevT));251  prevT = t;252253  if (currentRoom) currentRoom(t, dt);254255  for(Object &o : scene) {256    if (o.img != IMG_TEXT) draw_image(images[o.img].glTexture, o.x, o.y, images[o.img].width, images[o.img].height);…259  }…261  return EM_TRUE; // Jatka peliloopin ajoa262}

Listaus 10. batman.cpp – Huonejärjestelmä.

Funktio-osoitin currentRoom pitää kirjaa tämänhetkisestä huoneesta. Jokaisella huoneella on oma päivitysfunktio, jota kutsutaan pelin pääsilmukasta. Päivitetty game_tick()-funktio sisältää myös näyttämön sprite-renderöijän: yksinkertaisesti iteroimme näyttämötaulukon läpi ja piirrämme jokaisen näyttämöllä olevan spriten ruudulle. Tämä tarkoittaa sitä, että spritejen piirtojärjestys määräytyy naiivisti taulukon järjestyksen mukaan. Monimutkaisemmissa peleissä on suosittua lajitella objekteja syvyysjärjestykseen, ”z order”, tai soveltaa syvyyspuskurointia, ”z buffer”. (Tällöin funktion remove_sprite_at_index() voi optimoida toimimaan vakioaikaisesti käyttämällä ns. swap-and-pop-idiomia.)

Jokaiselle huoneelle tarjoillaan kahta muuttujaa: seinäkelloaikaa t sekä ajan muutosta dt (delta t) edellisen kuvan piirtohetkestä. Muuttujaa dt käytämme animaationopeuden vakioimiseen riippumatta siitä, virkistääkö pelaajan näyttö 60 tai 120 hertsillä tai jollain muulla päivitysnopeudella. Tärkeänä huomiona rivillä 250 rajaamme delta-ajat maksimissaan 50 millisekuntiin (min. 20 fps), sillä muuten animaatio rikkoutuisi pitkien delta-aikojen takia käyttäjän navigoidessa hetken aikaa muilla selaintabeilla pelaamisen ohessa.

Toteutamme peliin kaksi huonetta: title ja game. Title-huoneessa näytetään pelin alkukuvaa, kun taas pelihuoneessa itse peli on käynnissä. Peliin on mahdollista laajentaa omia huoneita, esimerkiksi game over-, pause- tai high score -ruutu.

266void update_title(float t, float dt) {…269}301void update_game(float t, float dt) {…411}412413void enter_title() {414  scene.clear();415  scene.push_back((Object) { .x=0.f, .y=0.f, .img =  IMG_TITLE });416  currentRoom = update_title;417}423void enter_game() {424  scene.clear();425  scene.push_back({ .x=0.f,    .y=0.f,   .img=IMG_ROAD,   .tag=TAG_ROAD });426  scene.push_back({ .x=4096.f, .y=0.f,   .img=IMG_ROAD,   .tag=TAG_ROAD });427  scene.push_back({ .x=100.f,  .y=120.f, .img=IMG_BATMAN, .tag=TAG_PLAYER, .mass=0.05f, .velx=0.05f });428  scene.push_back({ .x=0.f,    .y=314.f, .img=IMG_SCOREBAR });429  scene.push_back({ .x=380.f,  .y=330.f, .img=IMG_LIFE,   .tag=TAG_LIFE1 });430  scene.push_back({ .x=440.f,  .y=330.f, .img=IMG_LIFE,   .tag=TAG_LIFE2 });431  scene.push_back({ .x=500.f,  .y=330.f, .img=IMG_LIFE,   .tag=TAG_LIFE3 });432  currentRoom = update_game;…443}

Listaus 11. batman.cpp – Pelin eri huoneet.

Molemmilla huoneilla on oma enter- ja update-funktio. Enter-funktion vastuulla on päivittää näyttämölle kyseisen huoneen sisältö, ja update-funktio toteuttaa huoneelle pelilogiikan. Tämä koodi esittelee myös, kuinka määrittelemme objekteja näyttämölle: uuden objektin lisääminen onnistuu näppärästi yhdellä rivillä koodia. Huomaa, kuinka objektien tiedot on ”kovakoodattu”: geneerisemmässä järjestelmässä nämä saatettaisiin lukea jostain datatiedostosta.

Nyt voit kokeilla kutsua main()-funktion lopussa joko enter_title()-funktiota tai enter_game()-funktiota ja saat erilaisen pelinäkymän. enter_game()-funktion kutsuminen antaa jo hätkähdyttävästi peliltä näyttävän asetelman!

Pelinäkymä: batmobile kadulla ja pistetaulu

Näppäimistökäsittelijä

Peliruudun Batmobile odottaa jo Bruce Waynea rattiin. Selainpeleissä syöte voi tulla vaikka hiirellä, kosketusnäytöllä tai gamepadilla, mutta näin arvoisaan peliin toteutamme tietenkin PC master race -ohjauksen eli puskuroimattoman näppäimistösyötteen. Siihen ei montaa koodiriviä tuhlaannu.

5#include <emscripten/dom_pk_codes.h>9uint8_t keysOld[0x10000] = {}, keysNow[0x10000] = {};1011EM_BOOL key_handler(int eventType, const EmscriptenKeyboardEvent *keyEvent, void *userData) {12  uint16_t code = (uint16_t)emscripten_compute_dom_pk_code(keyEvent->code);13  keysNow[code] = (eventType == EMSCRIPTEN_EVENT_KEYDOWN) ? 1 : 0;14  return EM_FALSE; // älä estä näppäimen vakiotoimintoa selaimessa15}1617bool is_key_pressed(DOM_PK_CODE_TYPE code) {18  return keysNow[code] && !keysOld[code];19}2021bool is_key_down(DOM_PK_CODE_TYPE code) {22  return keysNow[code];23}248EM_BOOL game_tick(double t, void *) {…260  memcpy(keysOld, keysNow, sizeof(keysOld));261  return EM_TRUE; // Jatka peliloopin ajoa262}263264void enter_game();265266void update_title(float t, float dt) {267  if (is_key_pressed(DOM_PK_ENTER) || is_key_pressed(DOM_PK_SPACE))268    enter_game();269}445int main() {…457  emscripten_set_keydown_callback( EMSCRIPTEN_EVENT_TARGET_DOCUMENT, 0, 0, key_handler);458  emscripten_set_keyup_callback( EMSCRIPTEN_EVENT_TARGET_DOCUMENT, 0, 0, key_handler);459460  enter_title();461}

Listaus 12. batman.cpp – Selaimen näppäinviestien kuuntelu.

Näppäimistöä luemme rekisteröimällä kuuntelijafunktion selaimen keydown- ja keyup-viesteille, joita lähetetään kun käyttäjä painaa näppäimen alas tai päästää sen ylös. Emscripten tarjoilee tähän valmiit järjestelmäfunktiot emscripten_set_*_callback(), jonka pohjalta saamme kutsuja funktioon key_handler. Tämän funktion sisällä kutsu emscripten_compute_dom_pk_code() muuttaa näppäimistökoodin 16-bittiseksi koodinumeroksi, jonka tallennamme näppäintaulukkoon keysNow. Nämä physical key (PK) -näppäinkoodit eivät siis riipu käyttäjän näppäimistön kieliasetuksista (locale), mikä sopii peleille hyvin. Koodinumerot muuten vastaavat suoraan vanhan kunnon DOS-ajan AT-näppäimistöjen määrittämiä scan-koodeja, 0xE0-extended lippua myöten. INT 09h elää ja voi hyvin!

Pelisilmukan ohessa pidämme kirjaa näppäinten uusista ja vanhoista tiloista kopioimalla keysNow-taulukon sisällön keysOld-taulukkoon kutsumalla C-koodareille tuttua funktiota memcpy(). Näin pystymme erottelemaan tuoreet näppäinpainallukset vanhoista edellisiltä animaatioframeilta. Pelilogiikassamme voimme nyt käyttää funktiota is_key_pressed() testaamaan, onko jokin näppäin painettu pohjaan juuri nyt (”edge trigger”), sekä funktiota is_key_down() kertomaan, onko jokin näppäin pidettynä pohjassa, mahdollisesti jo pidemmän ajan (”level trigger”). Logiikkaa jatkamalla olisi mahdollista tuottaa myös funktio is_key_released(), jos sellaiselle olisi tarvetta.

Funktio update_title() on päivitetty esimerkkinä käyttämään näitä funktiota. Jos pelaaja painaa title-ruudussa välilyöntiä tai rivinvaihtoa, peli siirtyy game-huoneeseen.

Batmobile go!

Näppäimistöfunktiot apunamme osaamme nyt toteuttaa pelilogiikan auton liikuttamiseen. Auton liikettä käsitellään jokaisella pelin animaatioframella game-huoneessa. Tarvitsemme peliin yksinkertaisen 2D-fysiikkamallinnuksen, joten sen sijaan että asettaisimme nuolinäppäimet liikuttamaan suoraan pelaajan X- ja Y-koordinaatteja, laitammekin ne vaikuttamaan pelaajan X- ja Y-nopeusmuuttujiin. Näin toimimalla saamme hyvin sovitettua yhteen sen, että pelaajan auton sijaintiin vaikuttavat nuolinäppäinten lisäksi myös törmäykset muiden autojen kanssa.

Jokaisella peliframella nopeusmuuttujia ”integroidaan” sijaintimuuttujiin. Tämä kuten kaikki muukin animaatiosidonnainen logiikka tapahtuu muuttujalla dt skaalaten (kuljettu matka = nopeus · aika) animaationopeuden vakioimiseksi.

Pelimoottoreissa käytetään usein jonkinlaista kameraobjektin käsitettä. Kuulostaa hifistelyltä, joten toteutamme tässä kohden pienen kameratrikin. Sen sijaan että nuolinäppäin oikealle liikuttaisi pelaajan autoa eteenpäin ruudulla ja koodaisimme kameran seuraamaan sitä, liikutammekin kaikkia muita maailman objekteja vasemmalle ja pidämme pelaajan auton X-koordinaatin koko ajan paikallaan. Tämä tuottaa illuusion liikkeestä, jossa kamera olisi liimattuna pelaajan autoon. Lopputulosta ei voi erottaa aidosta, sillä Newtonin oppien mukaisesti liikkeessä tai sen puutteessa on vain kyse referenssipisteestä. (3D-moottoreissa puhuttaisiin view space- ja world space -koordinaatistoista.)

Tätä kameratrikkiä toteuttaessa tarvitsemme niin sanotun wraparound-logiikan pelin taustakuville: kun taustakuva katoaa ruudulta vasemmalle, niin sitä täytyy siirtää oikealle niin, että se voi hetken kuluttua taas skrollata vastaan. Näin tuotamme efektin loputtomiin saumattomasti toistuvasta taustakuvasta.

298#define SIGN(x) ((x) > 0.f ? 1.f : ((x) < 0.f ? -1.f : 0.f))299#define CLAMP(x, min, max) ((x) < (min) ? (min) : ((x) > (max) ? (max) : (x)))300301void update_game(float t, float dt) {302  Object *player = find_sprite(TAG_PLAYER);303304  // Jarruta pelaajan y-nopeutta (kitka)305  player->vely -= SIGN(player->vely) * MIN(fabsf(player->vely), 0.004f * dt);306307  // Lisää pelaajan nopeutta näppäimillä308  if (is_key_down(DOM_PK_ARROW_UP)) player->vely += dt * 0.008f;309  if (is_key_down(DOM_PK_ARROW_DOWN))  player->vely -= dt * 0.008f;310  if (is_key_down(DOM_PK_ARROW_LEFT))  player->velx -= dt * 0.003f;311  if (is_key_down(DOM_PK_ARROW_RIGHT)) player->velx += dt * 0.001f;312313  // Rajaa maksiminopeus314  player->velx = CLAMP(player->velx,  0.f, 0.55f);315  player->vely = CLAMP(player->vely, -0.3f, 0.3f);316317  // Liiku y-suunnassa ja rajaa y-koordinaatti pelialueen sisään318  player->y = CLAMP(player->y + player->vely * dt, 0.f, STREET_HEIGHT);319320  // Kameratrikki: pelaajan x-nopeus liikuttaa kaikkia peliobjekteja vasemmalle.321  // Wrappaa myös taustakuvia toistumaan loputtomiin322  for(Object &o : scene) {323    if (o.tag == TAG_ROAD || o.tag == TAG_ENEMY) o.x -= player->velx * dt;324    if (o.tag == TAG_ROAD && o.x < -images[IMG_ROAD].width) o.x += 2*images[IMG_ROAD].width;325  }…411}

Listaus 13. batman.cpp – Pelaajan auton liikuttaminen.

Siviiliauto-spriteSiviiliauto-spriteSiviiliauto-spriteSiviiliauto-sprite

Turistiautot

Pelin haaste muodostuu vaarallisen holtittomasti tiellä heittelehtivistä siviiliautoista. Tähän tarvitsemme satunnaislukugeneraattorin. C-standardikirjastosta löytyy vanha tuttu rand(), jota voisi käyttää, mutta JavaScript tarjoilee meille myös funktion Math.random(), joka antaa mukavasti satunnaisen liukuluvun puoliavoimelta väliltä [0.0, 1.0[. Tähän pääsemme käsiksi järjestelmäkutsulla emscripten_math_random().

Pidämme kirjaa laskurista spawnTimer, joka pienenee sitä mukaa, kun pelaaja etenee kadulla. Sen pudotessa nollaan on aika lisätä uusi auto esteeksi, ja laskuri palautetaan sopivasti valittuun lukuun odottamaan seuraavan auton luontia. Rajoitamme yhtäaikaisten autojen maksimimäärää suhteessa pelaajan senhetkiseen pistemäärään (jonka laskennan toteutamme valmiiksi viimeisenä). Näin peli vaikeutuu pistemäärän lisääntyessä.

Uuden auton ulkoasu valitaan satunnaisesti kahdeksasta eri kuvasta ja sen X- ja Y-koordinaatit ja nopeudet myös satunnaistetaan. Vihollisautojen massa on 1.0 painoa, kun taas Batmobile on uhkaavasta ulkonäöstään huolimatta pelkkää paperia ja painaa vain 0.05 painoyksikköä. Tämä tuottaa haastetta autojen törmäyksiin pelin mielekkyyden kannalta.

Vastustajien autojen liikutus tapahtuu iteroimalla koko näyttämö läpi ja poimimalla sieltä tagilla TAG_ENEMY varustetut objektit. Autot liikkuvat biljardipallofysiikan mukaisesti eteenpäin omaa nopeuttaan. Kadun laitaan törmätessä niiden nopeus peilautuu takaisin katua kohti. Juuri kuten pelaaja-auton kohdalla, nopeuden integrointi sijaintiin tapahtuu taas ottaen aikamuuttuja dt huomioon, jotta autojen nopeus on invariantti virkistystaajuuden suhteen.

Kun vastusauto katoaa liian kauaksi (kaksi ruudun mittaa) näkyvästä peliruudusta, se poistetaan näyttämöltä. Näin tehdään tilaa uusille generoitaville autoille. Tässä siivoamisessa ei kannata olla ihan liian aggressiivinen, jotta saadaan aidonnäköinen efekti jossa peli muistaa hetken aikaa jo ohitetut autot esimerkiksi pelaajan törmätessä tai pysähtyessä.

Autojen poistamisessa näyttämöltä käytetään huomionarvoista idiomia: autot poistetaan scene-vektorista samalla, kun sitä vielä iteroidaan. Tämän takia vähennämme indeksiä i---iteroinnin aikana, jotta emme hyppää yli seuraava elementtiä. Tämä on tehokas tapa harventaa taulukkoa.

271// Palauttaa satunnaisliukuluvun [min, max[.272float rndf(float min, float max) {273  return min + (float)emscripten_math_random() * (max - min);274}275276// Palauttaa satunnaiskokonaisluvun [min, max[.277int rndi(int min, int max) {278  return min + (int)(emscripten_math_random() * (max - min));279}295float spawnTimer, score;301void update_game(float t, float dt) {…326  // Uusien autojen lisäys327  spawnTimer -= 2.f * player->velx * dt;328  if (spawnTimer < 0.f && scene.size() < 15 + score / 10000) {329    spawnTimer = rndf(0.f, MIN(2500, 25.f + 22000000.f / score));330    scene.push_back((Object) {331      .x = GAME_WIDTH * 1.5f, .y = rndf(0.f,  STREET_HEIGHT),332      .img = IMG_CAR1 + rndi(0, 8), .tag = TAG_ENEMY, 333      .velx = rndf(0.15f, 0.45f), .vely = rndf(-0.07f, 0.07f)334    });335  }336337  // Vastustajien autojen liikutus338  for(size_t i = 0; i < scene.size(); ++i) {339    Object &o = scene[i];340    if (o.tag != TAG_ENEMY) continue;341    // Liiku eteenpäin342    o.x += o.velx * dt;343    // Liiku pystysuunnassa, mutta pysy kadun sisällä344    o.y = CLAMP(o.y + o.vely * dt, 0.f, STREET_HEIGHT);345    // Peilaa y-nopeus jos auto törmää kadun reunaan346●    if ((o.y <= 0 && o.vely < 0) || (o.y >= STREET_HEIGHT && o.vely > 0)) o.vely = -o.vely;347    // Poista auto jos se on liian kaukana ulkona ruudusta348    if (fabsf(o.x) > 2*GAME_WIDTH) remove_sprite_at_index(i--);349  }…411}423void enter_game() {…433  spawnTimer = 0.f;434  score = 0.f;…443}

Listaus 14. batman.cpp – Siviiliautojen luonti ja liikutus.

Siviiliauto-spriteSiviiliauto-spriteSiviiliauto-sprite

Taustamusiikki

Äänimaailma on vähintään puolet peliä, joten sitä ei sovi unohtaa. Natiiviohjelmissa äänikirjaston toteutus veisi päiviä, mutta web-selaimet tarjoavat niin korkean tason äänentoisto-ominaisuudet, että olemme valmiita muutamassa minuutissa.

Web-maailmassa on kaksi tapaa toistaa ääntä, joko käyttämällä Audio-elementtiä tai Web Audio API -rajapinnan kautta. Molemmat ovat kompakteja päästä alkuun, tosin ominaisuuksiltaan harmittavan puutteellisia ja monipuolisessa web-peliaudiomoottorissa täytyy luovasti sekakäyttää molempia. Onneksi tässä 2D-pelissä meille riittää pelkästään <audio>-tagin ominaisuudet.

Toisin kuin taustamusiikin, peliefektien toistaminen on aikakriittistä. Kun Master Bruce jyrää kansalaisten autoja, haluamme ääniefektin kuuluvan reaaliajassa välittömästi eikä viiveellä. Tämä tarkoittaa, että meidän on ladattava halutut ääniefektit selaimen muistiin etukäteen, jotta niitä ei lähdetä noutamaan nettipalvelimelta vasta silloin, kun törmäys tapahtuu. Siispä audiojärjestelmämme vaatii toistofunktion play_audio() lisäksi toisen funktion preload_audio(), joka käynnistää äänitiedostojen lataamisen etukäteen. Jokainen pelin käyttämä äänitiedosto ladataan muistiin heti sivun auettua.

134extern "C" void preload_audio(int audioId, const char *url);135extern "C" void play_audio(int audioId, bool loop);136137enum {138  AUDIO_BG_MUSIC = 0,139  AUDIO_COLLISION1 // ..., AUDIO_COLLISION8140};445int main() {…452  const char * const audioUrls[] = { "batman.mp3", "c1.wav", "c2.wav", "c3.wav", "c4.wav", "c5.wav", "c6.wav",  "c7.wav", "c8.wav" };453  for(int i = 0; i < sizeof(audioUrls)/sizeof(audioUrls[0]); ++i)454    preload_audio(i, audioUrls[i]);455  play_audio(AUDIO_BG_MUSIC, /*loop=*/true);…461}

Listaus 15. batman.cpp – Äänijärjestelmän C++-koodi.

Siviiliauto-sprite
1mergeInto(LibraryManager.library, {19  preloaded_audio: {},20  preload_audio__deps: ['preloaded_audio'],21  preload_audio: function(id, url) {22    let audio = new Audio(UTF8ToString(url));23    _preloaded_audio[id] = audio;24    audio.preload = 'auto';25  },2627  play_audio: function(id, loop) {28    let audio = _preloaded_audio[id];29    audio.loop = !!loop;30    audio.play().catch(e => {31      let deferPlay = () => {32        audio.play().then(() => {33          document.body.removeEventListener('keydown', deferPlay);34        });35      };36      document.body.addEventListener('keydown', deferPlay);37    });38  },65});

Listaus 16. library_batman.js – Äänijärjestelmän JavaScript-koodi.

Funktio preload_audio allokoi uuden Audio-objektin ja käynnistää sen latauksen välittömästi. Annettu URL-osoite assosioidaan halutulle ID-numerolle, jota C++-koodi voi käyttää äänitiedostoon viittaamiseksi.

Funktio play_audio sallii äänitiedoston toistamisen joko loputtomiin toistuvana tai kertaluontoisesti. Tätä funktiota monimutkaistaa hieman nettiselainten tietoturvamalli: selaimet eivät salli äänen spontaania toistamista sivuilta, joiden kanssa käyttäjällä ei ole ollut aiempaa interaktiota. Tällä estetään tausta-popup-ikkunoiden äänispammia. Joudumme siis varautumaan tilanteeseen, jossa äänen toistolupaa ei ole vielä sivulle ”avattu” ja viivästyttämään audioklippien toistoa, kunnes käyttäjä painaa jotain näppäintä sivulla. Pelin title-huone osoittaa tässä hyötynsä: pelimusiikki käynnistyy varmasti viimeistään silloin, kun pelaaja painaa näppäintä aloittaakseen pelin.

Äänisoitin osaa toistaa sekä taustamusiikin että ääniefektit. Tosin tuetut formaatit (wav, mp3, ogg, aac) vaihtelevat hieman selaimen versiosta ja iästä riippuen. Yksi huomionarvoinen rajoitus tässä soittimessa on, että näin ladattuna yhdestä audiotiedostosta ei voi olla samanaikaisesti monta rinnakkaista äänikopiota toistumassa. Tämän rajoitteen poistaminen onnistuu joko niin sanotusti poolaamalla audio-elementtejä tai Web Audio -rajapinnan kautta.

Ka-Pow!

Kaksi törmäävää autoa ja niiden päällekkäisyysalueet
Kuvassa y-akselin suuntaisesti tapahtunut törmäys, koska y_overlap < x_overlap.

Pelaajan Batmobile ei reagoi vielä kadulla ajaviin autoihin. Tämän korjaamiseksi tarvitsemme törmäystarkistusrutiinin. Se on laskennallisesti intensiivisin osa peliämme, koska joudumme kokeilemaan kaikki näyttämöllä olevat objektiparit läpi mahdollisten törmäysparien löytämiseksi. Tällainen ”kaikki parit” -iteraatio tapahtuu käyttäen niin sanottua pyramidisilmukkaa, jossa kaksi indeksiä i ja j kulkevat objektit läpi ehdon i > j puitteissa. Näin emme vahingossa käsittele objektipareja kahdesti.

Törmäystarkistuksessa funktiossa get_overlap_amount() käytetään yksinkertaisinta axis aligned bounding box -testiä (AABB). Tämä perustuu X- ja Y-akselien suuntaiseen ”1D-projektioon” ja on erikoistapaus yleisestä Separating Axis Test -teoreemasta (SAT), joka sanoo että kaksi konveksia kappaletta on erillään toisistaan täsmälleen silloin, jos niiden välille voidaan piirtää suora (3D:ssä taso), jonka eri puolille kappaleet jäävät.

Kaikki konveksien 2D- ja 3D-kappaleiden törmäykset ovat salaa 1D-törmäyksiä, joissa törmäystä tarvitsee käsitellä vain törmäyksen normaalivektorin suuntaisesti. Koska tässä pelissä objektit ovat XY-koordinaatiston suuntaisia suorakaiteita, niin tiedämme, että törmäyssuunnat ovat aina koordinaatistoakselien suuntaisia. Vakaan törmäystarkistuksen toteuttamiseksi on tärkeää huolellisesti valita näistä kahdesta se akseli, jonka suhteen kappaleet limittyvät vähiten. Törmäyksen tapahtuessa kappaleita työnnetään poispäin toisistaan törmäysakselin suuntaisesti niin, että ne eivät enää leikkaa, sekä niiden nopeuksia päivitetään niin, että kappaleet jatkavat matkaansa eri suuntiin.

Kaavat nopeuksien muutokselle saadaan ratkaisemalla yhtälöpari liikemäärän ja liike-energian säilymislakien perusteella. Suosittelen tässä Chris Heckerin vanhaa, vuosina 1996–1997 julkaistua neliosaista klassikkoartikkelisarjaa ”Physics” Game Developer -lehdestä sekä Google-hakusanoja ”2D Rigid Body Dynamics” tai ”1D Collision Response”.

Törmäyksen tapahtuessa soitetaan Hollywood-dramaattinen ääniefekti ja vähennetään lepakolta yksi elämä. Tämä on tärkeää toteuttaa iteraatiosilmukan ulkopuolella, jotta iteraattori-indeksit i ja j pysyvät eheinä.

Pienenä armonpullana Batmobile muuttuu puolen sekunnin ajaksi haavoittumattomaksi törmäyksen jälkeen. Tästä pitää kirjaa lastHitTime-muuttuja. Elämien pudotessa nollaan palataan takaisin title-huoneeseen ilman suurempia fanfaareja.

2D-arcadefysiikkaa toteuttaessa AABB-törmäystestin lisäksi käytetään yleisesti joko geneeristä konveksien kappaleiden SAT-testiä, Minkowski-erotuksiin perustuvaa GJK-testiä (Gilbert, Johnson, Keerthi) tai bitmask mipmap -pohjaista pikselintarkan törmäyksen toteuttavaa raster fill -rutiinia. Arcadepeleistä fysikaalisiin 2D-peleihin siirtyessä numeerisesti vakaat simulaatiot sen sijaan kannattaa ulkoistaa ”isoille tykeille”, eli Box2D- tai Chipmunk2D-kirjastoille.

281// Laskee spriten keskipisteen282void get_center_pos(const Object &o, float &cx, float &cy) {283  cx = o.x + images[o.img].width / 2.f;284  cy = o.y + images[o.img].height / 2.f;285}286// Laskee kahden spriten X- ja Y-leikkauksen287void get_overlap_amount(const Object &a, const Object &b, float &x, float &y) {288  x = MIN(a.x + images[a.img].width  - b.x, b.x + images[b.img].width  - a.x);289  y = MIN(a.y + images[a.img].height - b.y, b.y + images[b.img].height - a.y);290}291292float lastHitTime;293int lives;294void enter_title();301void update_game(float t, float dt) {…350  bool player_collided = false;351  for(size_t i = 1; i < scene.size(); ++i) {352    if (scene[i].tag != TAG_ENEMY && scene[i].tag !=  TAG_PLAYER) continue;353    for(size_t j = 0; j < i; ++j) {354      if (scene[j].tag != TAG_ENEMY && scene[j].tag != TAG_PLAYER) continue;355      Object &a = scene[i], &b = scene[j];356      float a_cx, a_cy, b_cx, b_cy, x_overlap, y_overlap;357      get_overlap_amount(a, b, x_overlap, y_overlap);358      if (x_overlap > 0.f && y_overlap > 0.f) { // SAT359        get_center_pos(a, a_cx, a_cy);360        get_center_pos(b, b_cx, b_cy);361        float xdir = SIGN(b_cx - a_cx) * x_overlap * 0.5f;362        float ydir = SIGN(b_cy - a_cy) * y_overlap * 0.5f;363        float xveldiff = 2.f * (b.velx - a.velx) / (a.mass + b.mass);364        float yveldiff = 2.f * (b.vely - a.vely) / (a.mass + b.mass);365        if (x_overlap <= y_overlap) { // X-suuntainen366          a.x -= xdir; b.x += xdir; // Erota autot X367●          if (xdir * xveldiff <= 0.f) {368            a.velx += b.mass * xveldiff;369            b.velx -= a.mass * xveldiff;370            if (a.tag == TAG_PLAYER || b.tag ==  TAG_PLAYER) player_collided = true;371          }372        } else { // Y-akselin suuntainen törmäys373          a.y -= ydir; b.y += ydir; // Erota autot Y374●          if (ydir * yveldiff <= 0.f) {375            a.vely += b.mass * yveldiff;376            b.vely -= a.mass * yveldiff;377            if (a.tag == TAG_PLAYER || b.tag ==  TAG_PLAYER) player_collided = true;378          }379        }380      }381    }382  }383384  if (player_collided) {385    play_audio(AUDIO_COLLISION1 + rndi(0, 8), /*loop=*/false);386    if (t - lastHitTime > 500) {387      lastHitTime = t;388      remove_sprite((Tag)(TAG_LIFE1 + --lives));389      if (lives <= 0) {390        enter_title();391        return;392      }393    }394  }…411}423void enter_game() {…435  lastHitTime = 0;436  lives = 3;…443}

Listaus 17. batman.cpp – Fysiikkamallinnus.

Peli on törmäystarkistuksen jälkeen toiminnallisesti valmis, mutta pelin pistetaulu ammottaa vielä tyhjyyttään. Loppusilauksena lisäämme tekstikentät pisteille sekä tulostamme pelin keston MM:SS-muodossa.

Natiivipelimoottori integroisi tässä kohtaa FreeType 2 -kirjaston, mutta web-selaimessa tarvittava toiminnallisuus löytyy taas, arvaat jo, sisäänrakennettuna. Valitettavasti web-standardeista ainakin toistaiseksi puuttuu tähän käyttöön paras mahdollinen rajapinta, mutta hieman soveltamalla saamme kuitenkin vektorifontin rasteroitua kuvatekstuureiksi.

Tekstin piirtäminen toimii seuraavasti: sijoitamme web-serverille C64-fonttitiedoston ttf-muodossa. Käyttämällä web-selaimen <canvas>-rajapintaa voimme piirtää jokaisen yksittäisen fontin merkin omaan tekstuuriinsa, minkä jälkeen voimme käyttää jo aiemmin toteuttamaamme draw_image()-funktiota piirtämään yksittäiset merkit. Wanhat graffakoodarit mutisevat partoihinsa, mutta tässä strategiassa etuna on se, että toteutus on lyhyt ja mahtuu nätisti lehden sivulle. Taidokkaammat ohjelmoijat voivat kokeilla kykyjään ja toteuttaa järjestelmän, joka atlasoi piirretyt merkit lennosta ajonaikaisesti hallittavaan fonttiatlastekstuuriin (Google-hakusanaksi suositellaan kirjoittajan kehittämää algoritmia ”MaxRects”.) Tällöin kokonaisia merkkijonoja voidaan piirtää yhdellä piirtokäskyllä, mikä tehostaa viestintää CPU:lta GPU:lle.

Toteutamme kaksi funktiota JavaScript-maailmassa: load_font() lataa ttf-tiedoston halutusta URL-osoitteesta ja assosioi sen haluttuun ID-numeroon. Funktiota upload_unicode_char_to_texture() taasen käytetään piirtämään yksittäinen Unicode-merkki GPU-tekstuuriin. Tämä funktio on äärimmäisen hidas, luokkaa kymmeniä millisekunteja, joten tämän takia rakennamme C++-puolelle minimaalisen välimuistitietorakenteen, joka pitää kirjaa jo aiemmin rasteroiduista merkeistä. Näin jokainen piirretty merkki rasteroidaan vain yhden kerran silloin, kun sitä näytetään ensimmäistä kertaa ruudulla, ja suorituskyky säilyy tehokkaana. Välimuistitietorakenteeksi otetaan vanha kunnon std::map<>, jossa avaimena toimivat fontin ID, Unicode-merkin koodi, sekä fontin koko.

Merkkijonon kirjoitusfunktio draw_text() piirtää kokonaisen lauseen tekstiä ruudulle käyttämällä haluttuja parametreja. Fontin väriä voi vaihtaa, ja käytämme myöhemmin tätä ominaisuutta pienenä huomioefektinä ennätyspisteitä piirrettäessä.

Lopputulos ei ole ihan pomminvarma – isoin puute tässä mekanismissa on, että tekstin merkkien välistys (engl. kerning) on kiinteä. Järjestelmämme ei siis tunnista, että ’i’ pienenä merkkinä mahtuu kapeampaan tilaan kuin esimerkiksi leveä merkki ’m’. Myös esimerkiksi merkin ’g’ korkeuden kanssa voi olla haasteita. C64-fontilla nämä eivät ole ongelma, koska se on muutenkin tasalevyinen niin sanottu monospace-fontti, mutta esimerkiksi Arial tai Times New Roman eivät piirtyisi yhtä nätisti.

Fonttitiedoston sisältämän välistystiedon lukemiseen ei valitettavasti selaimessa ole olemassa sopivaa rajapintaa. Välistystiedot ja merkkien korkeudet on kuitenkin mahdollista itse haistella käyttämällä JavaScriptin canvas-elementtiä hieman monimutkaisemmin, mutta tekstipalstan marginaali on valitettavasti liian pieni tämän koodin esittelyyn. (Algoritmi onnistuu merkkipareja rasteroimalla ja niiden bounding box -leveyttä tutkailemalla.)

Toinen huomionarvoinen puute on, että funktio draw_text() ei tue rivinvaihtoja, vaan jokaista tekstiriviä piirrettäessä funktiota täytyy kutsua uudestaan. Tosin tämän rajoitteen poistaminen onnistuu näppärältä ohjelmoijalta tarvittaessa helposti.

C64-fontin kirjaimetC64-fontin numerot ja erikoismerkit
6#include <map>171extern "C" void load_font(int fontId, const char *url);172extern "C" bool upload_unicode_char_to_texture(int id, int ch, int size);173174enum {175  FONT_C64 = 0176};177178// (fontId, unicodeChar, size) -> GL texture179std::map<std::tuple<int, int, int>, Image> glyphs;180Image *find_or_cache_font_char(int fontId, int unicodeChar, int size) {181  auto t = std::make_tuple(fontId, unicodeChar, size);182  auto iter = glyphs.find(t);183  if (iter != glyphs.end()) return &iter->second;184185  Image i = { .glTexture = create_texture(), .width = size, .height = size };186  if (upload_unicode_char_to_texture(fontId, unicodeChar, size)) {187    glyphs[t] = i;188    return &glyphs[t];189  }190  return 0;191}192193void draw_text(float x, float y, float r, float g, float b, float a, const char *str, int fontId, int size, float spacing) {194  for(; *str; ++str) {195    Image *i = find_or_cache_font_char(fontId, *str, size);196    if (i) draw_image(i->glTexture, x, y, i->width, i->height, r, g, b, a);197    x += spacing;198  }199}445int main() {…456  load_font(FONT_C64, "c64.ttf");…461}

Listaus 18. batman.cpp – Fonttien lataus ja piirto, C++-koodi.

1mergeInto(LibraryManager.library, {39  loadedFonts: {},40  load_font__deps: ['loadedFonts'],41  load_font: function(fontId, url) {42    new FontFace(`font${fontId}`, `url(${UTF8ToString(url)})`).load().then(face => {43document.fonts.add(face);44_loadedFonts[`font${fontId}`] = 1;45    });46  },4748  upload_unicode_char_to_texture__deps: ['upload_flipped'],49  upload_unicode_char_to_texture: function(fontId, unicodeChar, fontSize) {50    if (!_loadedFonts[`font${fontId}`]) return 0;51    let canvas = document.createElement('canvas');52    canvas.width = canvas.height = fontSize;53    let ctx = canvas.getContext('2d');54    ctx.fillStyle = 'black';55    ctx.globalCompositeOperator = 'copy';56    ctx.globalAlpha = 0;57    ctx.fillRect(0, 0, canvas.width, canvas.height);58    ctx.globalAlpha = 1;59    ctx.fillStyle = 'white';60    ctx.font = fontSize + `px font${fontId}`;61    ctx.fillText(String.fromCharCode(unicodeChar), 0, canvas.height);62    _upload_flipped(canvas);63    return 1;64  },65});

Listaus 19. library_batman.js – Fonttien lataus, JavaScript-koodi.

Pelin pistetaulu

Pisteenlasku

Pelijulkaisua odottavan fanilauman huutojen saattelemana juoksemme pelilogiikkamme loppusuoralle. Nyt otamme vihdoin käyttöön alkuperäisen suunnitelmamme, jonka mukaan pelikentän objektit ovat joko spritejä tai merkkijonoja. Merkkijonot määritellään asettamalla objektin .img-muuttujan arvoksi IMG_TEXT. Lisäämme nyt game-huoneeseen neljä merkkijonoa, jotka kuvaavat pistetaulun ruutuja.

Samalla laajennamme update_game()-funktion päivittämään näitä pistetaulun arvoja. Lisäksi palaamme takaisin game_tick()-funktioon ja lisäämme sinne kutsun draw_text()-funktioon kaikkien merkkijono-objektien kohdalla, jotta ne tulevat oikein piirretyiksi.

7#include <stdio.h>248EM_BOOL game_tick(double t, void *) {…255  for(Object &o : scene) {256    if (o.img != IMG_TEXT) draw_image(images[o.img].glTexture, o.x, o.y,images[o.img].width, images[o.img].height);257    else258      draw_text(o.x,o.y,o.r,o.g,o.b,o.a,o.text,o.fontId,o.fontSize,o.spacing);259  }…262}296float gameStartTime, highscore = 5000;301void update_game(float t, float dt) {…395  // päivitä pelaajan pisteet ja piste-ennätys396  score += player->velx * dt;397  highscore = MAX(score, highscore);398399  // kirjoita uusi pistetilanne merkkijonoksi400  sprintf(find_sprite(TAG_SCORE)->text, "%06d0", (int)score/10);401402  // kirjoita piste-ennätys merkkijonoksi ja vilkuta tekstiä puna-valkoisena jos ennätys on meidän403  Object *o = find_sprite(TAG_HIGH_SCORE);404  sprintf(o->text, "%06d0", (int)highscore/10);405  o->g = o->b = (highscore == score && fmod(t, 1000.f) <500.f) ? 0 : 1;406407  // päivitä peliaika mm:ss -muodossa408  int gameSeconds = (int)((emscripten_performance_now() - gameStartTime) / 1000.0);409  sprintf(find_sprite(TAG_MINUTES)->text, "%02d", gameSeconds / 60);410  sprintf(find_sprite(TAG_SECONDS)->text, "%02d", gameSeconds % 60);411}419Object create_text(float x, float y, Tag tag) {420  return (Object) { .x=x, .y=y, .img=IMG_TEXT, .tag=tag, .r=1.f, .g=1.f, .b=1.f, .a=1.f, .fontId=FONT_C64, .fontSize=20, .spacing=15 };421}422423void enter_game() {…437  scene.push_back(create_text(165.f, 364.f, TAG_SCORE));438  scene.push_back(create_text(165.f, 332.f, TAG_HIGH_SCORE));439  scene.push_back(create_text(465.f, 364.f, TAG_MINUTES));440  scene.push_back(create_text(510.f, 364.f, TAG_SECONDS));441442  gameStartTime = emscripten_performance_now();443}

Listaus 20. batman.cpp – Pistelasku.

Pelimme on nyt viimeistä käännöskomentoasi vaille valmis. Metacritic myrskytköön, kuinka ylpeä sinun kehtaa olla! Maailmankuulut peliarvostelijat suitsuttavat luomustasi ja peliäsi jaellaan warez-purkeissa ja -korpuilla. Unelmasi on tähtiin ikuistettu!

Sequel warning

Pelikoodissa on pinta-alaa jatkokehitykselle todella moneen suuntaan. Edellisten kappaleiden lomassa mainittiin jo useita paranteluideoita, tässä vielä muutama lisää.

Pelilogiikkaa voisi laajentaa lisäämällä tielle esteitä tai kerättäviä powerup-esineitä, tarjoamalla lisäelämiä tiettyjen pistemäärien jälkeen tai monimutkaistamalla AI-autojen ajologiikkaa, jotta niiden liike ei olisi niin ennalta-arvattavaa. AI-autot näyttäisivät myös huomattavasti elävämmiltä, jos autoista piirtäisi muutaman erilaisen animaatioframen kuvastamaan renkaiden pyörimistä.

Pelinäkymä, pisteet 38911
Score to beat: 38911.

WebSockets-rajapintaa (tai tätä kirjoitettaessa vielä luonnosvaiheessa olevaa WebTransportia) käyttämällä peliin voi kehittää tuen moninpelille. Toinen pelaaja voisi kontrolloida esimerkiksi siviiliautojen liikettä vastustajana, tai kaksi pelaaja-autoa voisi ottaa kaahauskilpailua Micro Machinesin tyyliin.

Pistemääristä huomioitavaa on, että selain ei muista piste-ennätystä sivulla uudelleen vierailtaessa. Ennätysten tallennus paikallisesti olisi äärimmäisen helppo toteuttaa käyttämällä local storage -rajapintaa. XHR JSON API -viestintään pohjaava globaali ennätyspistetaulu toisi pelille offline-kilpailullisuutta. Toisaalta pelissä voi nykyisillä säännöillä huijata pisteitä helposti – tämän löytäminen jätettäköön pelaajalle pääsiäismunaksi.

Pelissä on sellainen puute, että selainikkunan koon muuttaminen ei päivitä canvas-elementin kokoa, vaan sivu on tällöin ladattava uudelleen. Tämä korjautuu helposti selainikkunan koon muutoksia kuuntelemalla emscripten_set_resize_callback()-järjestelmäfunktiolla.

Vielä lopuksi mainittakoon, että web-renderöinnin tulevaisuus suuntaa seuraavina vuosina upouuteen WebGPU-rajapintaan. Tämä pelikoodi toimisi hyvänä harjoitusalustana WebAssembly-pohjaisen WebGPU-renderöijän toteuttamiselle.