Lumines: Vytváříme hru v React.js 1

Lumines je dnes již klasická hra, která byla původně vyvinuta pro herní konzoli PSP. Od té doby vznikla spousta klonů a implementací. Ta následující je vytvořena za pomocí moderních JavaScriptových technologií: React.js, Flux a Immutable.js.

Seriál: Lumines: Vytváříme hru v React.js (3 díly)

  1. Lumines: Vytváříme hru v React.js 1 27.11.2015
  2. Lumines: Vytváříme hru v React.js 2 4.12.2015
  3. Lumines: Vytváříme hru v React.js 3 11.12.2015

O čem to bude?

React a spol. tu s námi už nějaký ten pátek jsou (však i zde na Zdrojáku vyšla spousta článků na toto téma). I přesto se tato oblast stále velice rychle vyvíjí a pro spoustu situací stále neexistují jasné patterny. Lumines je poměrně unikátní aplikace v této oblasti, a tak jsem při vývoji na nějaký ten problém narážel na každém kroku. Následující články se oněm problémům budou věnovat.

Screenshot ze hry Lumines

Screenshot ze hry Lumines

Jedna z nejzajímavějších vlastností je to, že hra je schopná běžet (v závislosti na stroji a prohlížeči) téměř stabilně při 60 snímcích za sekundu a nabídne tak velice plynulý zážitek ze hry. Co tedy konkrétně běží pod kapotou (a co bude dále rozebráno podrobněji):

  • Grafika je kompletně vykreslená v SVG. To v prohlížečích není tak výkonné a mocné jako třeba WebGL, nicméně je více deklarativní, mnohem snáze se používá a nabízí některé skutečně zajímavé vlastnosti (například pokročilé animace či filtry). A především, je možné na něj aplikovat styly!
  • Samotné rozhraní je postaveno na Reactu. React spolu s SVG vytváří velice zajímavý tandem (i přestože podpora není 100%). Základem je spousta oddělených a znovu využitelných komponent, které umožňují definovat rozhraní velice čistě a přehledně. A především je třeba mít na mysli základní vlastnost Reactu: You don’t mutate the UI, you re-render it. Takže v konečném důsledku hra funguje podobně jako OpenGL/WebGL aplikace, pouze místo kreslení do framebufferu si užíváte pohodlí Reactu a SVG.
  • Jádro aplikace pohání Flux, najdete tu tedy dispatcher, stores a akce, tedy vše, co k tomu patří.
  • Celý stav aplikace je pak uložen do jednoho velkého Immutable objektu, což usnadňuje detekci změn a umožňuje překreslovat jen ty části uživatelského rozhraní, které to skutečně potřebují (všechny React komponenty jsou implementovány jako pure). Právě díky této vlastnosti je hra schopná běžet tak rychle.

Toto je základ hry, nicméně já jsem se rozhodl jít ještě o krok dále a vyzkoušet si všechny ty magické vlastnosti, které byly v kontextu Reactu a Fluxu tolikrát zmíněny. Konkrétně:

  • Díky tomu, že celý stav hry je jeden jediný objekt, je velice snadné ho serializovat a deserializovat. Implementace load/save hry během pauzy je tak otázka pár řádek kódu.
  • Další možností je průběžně sdílet stav hry mezi okny prohlížeče skrz Web Storage a replikovat tak gameplay. Skutečně to funguje, jen reálný výkon pokulhává.
  • Mnohem efektivnější je místo stavu sdílet pouze akce. Stav je jednoznačně určen posloupností předchozích Flux akcí. Je tedy možné a v praxi i velice jednoduché všechny akce zaznamenat a po skončení hry je hráči zpět přehrát (tedy jakási forma replaye). Případně není vůbec problém akce streamovat skrz socket do jiné instance hry a opět tak replikovat gameplay. V tomto případě je výkon už poměrně slušný.

Demo a zdrojáky

Pokud vás předchozí odstavce zaujaly, tak hru si můžete vyzkoušet na této stránce. Zdrojáky jsou dostupné na GitHubu a v odděleném repozitáři jsou pak konkrétně naimplementována dema ukazující ony popsané vlastnosti.

Ještě by se slušelo poznamenat, že většina nápadů i část kódu pochází z článků a devstacku Daniela Steigerwalda.

Popis herního rozhraní

V následujících odstavcích se budu pravidelně odkazovat na jednotlivé herní komponenty. Abychom si ujasnili pojmy, zde je jednoduché schéma popisující herní rozhraní. Používat budu v závislosti na kontextu anglické pojmy či jejich české ekvivalenty (queue = fronta, square = čtverec, block = blok, grid = herní pole…).

Lumines: popis rozhraní

Lumines: popis rozhraní

The Flux

Flux je to, co pohání celou aplikaci. Čtenářům Zdrojáku asi netřeba představovat, takže jen ve stručnosti. Jedná se aplikační architekturu či možná jen o programátorský pattern, který byl popularizován Facebookem v souvislosti s Reactem. Více informací najdete na webové stránce projektu.

Jedna z těch hezkých vlastností Fluxu je to, že pouhým pohledem na jeden obrázek si uděláte docela slušnou představu, jak Flux funguje (a ten obrázek si půjčím z webu projektu).

The Flux

The Flux

Akce reprezentuje událost, která nastane v aplikaci. Store (stores) v sobě obsahuje aktuální stav aplikace (čtverce na herním poli, pozice scan line apod.) a view je view, tedy vykreslené uživatelské rozhraní. Dispatcher to pak vše spojuje dohromady. Abyste si lépe představili, jak funguje Flux v Lumines, tady je jednoduchý příklad:

  1. Hráč stiskne tlačítko A
  2. Akce ROTATE_LEFT je vytvořena a předána dispatcheru, který ji vyšle do všech storů
  3. BlockStore na akci zareaguje a aktualizuje se (padající blok je otočen doleva)
  4. Cele rozhraní je překresleno

Ve srovnání s původním návrhem Fluxu obsahuje Lumines několik zjednodušení. Například zde nejsou žádné action creatory, prostě protože akce jsou velice jednoduché. Story také neemitují žádné change eventy. Rozhraní je překresleno pokaždé, když dojde k nějaké změně.

Základní vlastností Fluxu je jednosměrný proud změn a událostí, které v praxi zjednodušují design aplikace a je tedy snazší se v takové aplikaci vyznat. Lumines je ve své podstatě poměrně nekomplikovaná hra, takže tento přístup je tak trochu kanón na vrabce, nicméně jiné vlastnosti Fluxu jsou o hodně užitečnější (viz úvod).

The actions

Z valné většiny jsou akce v Lumines přímočaré a jsou v podstatě 1:1 namapovány na konkrétní klávesy. Smysl těchto akcí je ovládání hry. Zmíněná akce ROTATE_LEFT je typický příklad, nicméně jsou zde i další: ROTATE_RIGHT, MOVE_LEFT, MOVE_RIGHT, PAUSE, RESTART atd.

Hlavní herní cyklus

Akce UPDATE je oproti tomu dost speciální. Tato akce vytváří iluzi času ubíhajícího ve hře. Je vysílána přibližně 60 krát za sekundu a nese (jako argument) čas, který uplynul od posledního dispatchnutí. Tato akce v podstatě posouvá interní časový pointer, což je samozřejmě naprosto zásadní pro herní mechaniku.

Jednou z komponent, která je touto akci přímo ovlivněna, je scan line (ta čára jedoucí v pravidelných intervalech zleva doprava, „zametající“ herní pole), která je reprezentována v ScanLineStore. Lajna je definována svojí pozicí x a rychlostí. Kdykoliv je UPDATE akce dispatchnuta, tento store zareaguje a posune pozici o součin rychlosti a uplynulého času.

Aby hra bylo co nejplynulejší, jednotlivé dispatche této akce (a tedy i překreslení obrazovky) jsou synchronizovány s prohlížečem. Toho je dosaženo pomocí speciální funkce requestAnimationFrame(). Hlavní herní cyklus tedy vypadá následovně:

const clock = new Clock();
const update = (time) => {
    let elapsed = clock.next(time) / 1000;
    this.dispatch(UPDATE, {time: elapsed});
    this.render();
    requestAnimationFrame(update);
};
requestAnimationFrame(update)

Pokud jste někdy vytvářeli WebGL/OpenGL aplikaci, mělo by vám toto připadat povědomé. Parametr time obsahuje aktuální timestamp a Clock je jednoduchá utilitka, která měří uplynulý čas mezi jednotlivými tiky.

Zachováváme determiničnost

Další speciální akci je REFILL_QUEUE. Jak hra běží, nové a nové bloky jsou odebírány z fronty, což znamená, že fronta musí být pravidelně doplňována. Nové bloky, pokud jsou potřeba, jsou generovány v hlavním herním cyklu (výše) a jsou přidávány do fronty pomocí oné akce REFILL_QUEUE. Definice bloku (tedy jakých barev by ony čtyři čtverce měly být) je poslána jako argument akce.

Toto zní jako něco, co je součástí vnitřní herní logiky. Proč je to tedy řešeno zcela mimo, na takto relativně vysoké úrovni abstrakce?

Problém je, že generování bloků v sobě obsahuje prvek náhody. Nové bloky jsou generovány náhodně. Tím, že odebereme tento prvek z herního jádra, dosáhneme toho, že hra je 100% deterministická. Tedy v jakémkoliv okamžiku hry aktuální stav zcela závisí na posloupnosti předchozích akcí. Pokud byste tedy chtěli zaznamenat hru, je to velice jednoduché. Stačí se napojit na dispatcher a uložit si každou dispatchnutou akci spolu s časovým údajem. Následně pak stačí spustit hru znovu a dispatchnout všechny zaznamenané akce ve správném pořadí a s odpovídajícím časováním (to poslední není podmínkou. Je možné klidně dispatchnout všechny akce dvojnásobnou rychlostí. Stav skutečně závisí pouze na dispatchnutých akcích a informacích, které akce nesou).

Toto by zjevně nefungovalo, kdyby generování bloků bylo přenecháno na vnitřku hry. Samozřejmě by bylo možné nahrát si všechny akce a zapamatovat si tak, co hráč udělal (jaké klávesy stisknul). Nic z toho by ale nedávalo smysl, protože situace na herním poli by byla zcela jiná.

Implementaci zmíněného dema si můžete prohlédnout zde.

The stores

Hra se skládá z několika oddělených logických komponent a každá z nich je reprezentovaná jedním storem. Typickým příkladem je již zmíněný ScanLineStore, který reprezentuje scan line. Pro představu, jaké story v Lumines existují a co reprezentují, zde je opět další schéma:

Lumines Store Scheme

Lumines Store Scheme

Schéma neobsahuje všechny story (nedávalo by to smysl). Konkrétně to jsou ConfigStore (obsahuje aktuální herní konfiguraci), GravityStore (aktuální gravitace, která ovlivňuje rychlost, s jakou čtverce padají) a GameStateStore (aktuální stav hry, např. PLAYING, PAUSED, OVER…).

Všechny story jsou registrované u dispatcheru a reagují na příchozí akce. Z Flux architektury vyplývá ohledně storů několik zajímavých vlastností:

  1. Všechny story mohou být měněny pouze skrz akce.
  2. Všechny akce vstupují do storu skrze jediný bod vstupu (to může někdy záviset na implementaci, důležité je, že se jedná o jasně definované veřejné API).
  3. Není možné dispatchnout novou akci dokud ta aktuální neskončí. To také znamená, že všechny story jsou zaktualizovány najednou, a vždy nechají aplikaci v konzistentním stavu (princip transakce).
  4. Data uvnitř storů jsou přístupná z vnějšku pouze skrz read-only veřejné API.

Důsledkem je to, že v jakýkoliv okamžik je velice jednoduché si udělat představu o tom, co se uvnitř storů děje (což nicméně platí i pro obyčejné správně navržené objekty). Takto vypadá například ScanLineStore zevnitř:

class ScanLineStore extends BaseStore {
    constructor() {
        // Init default values
    }

    handleAction({action, payload}) {
        switch (action) {
            case RESTART:
                // Reset the position 
                break;

            case UPDATE:
                // Move the line
                break;
        }
    }

    get position() {
        // Return the position
    }
}

Dnes je k dispozici spousta různých mutací Fluxu a storů, já jsem se nicméně rozhodl jít vanilla cestou. Všimněte si také ES6 syntax cukříku (destructing, getters).

Používáme waitFor

Někdy je nutné vynutit pořadí, v jakém jsou story aktualizovány. Například můžeme chtít, aby se napřed zaktualizovala scan line a teprve pak herní pole. Toho můžeme dosáhnout pomocí nepříliš známé metody dispatcheru waitFor(). (Opět se bavíme o vanilla implementaci Fluxu přímo od Facebooku).

V okamžiku, kdy je potřeba provést aktualizaci uvnitř storu na základě akce, ale napřed si musíme být jistí, že jiný store byl již aktualizován, můžeme jednoduše zavolat tuto funkci a předat jí požadovaný store jako argument. V případě popsaném v předchozím odstavci by SquareStore obsahoval na začátku zpracování UPDATE akce řádek dispatcher.waitFor(ScanLineStore). Tím by bylo zaručeno, že ScanLineStore přijde na řadu vždy první.

Název funkce by mohl naznačovat, že v sobě obsahuje element asynchronicity či aktivního čekání (jako například během synchronizace vláken). To ale není pravda (kromě toho, JavaScript je jednovláknový, to je potřeba mít neustále na mysli). Situace je ve skutečnosti o hodně jednodušší. Dispatcher pouze zkontroluje, zda store, na který potřebujeme čekat, je už zaktualizovaný aktuální akcí, a pokud není, tak ho zaktualizuje.

Ve výchozím stavu pořadí, v jakém jsou story aktualizovány, není garantováno a ani definováno. Díky waitFor je nicméně velice jednoduché si udržet v aplikaci pořádek a mít neustálý přehled o tom, co se děje.

Má to ale své mouchy…

Cyklické závislosti

Flux jako koncept je velice jednoduchý na pochopení. Jakmile ho ale zkusíte reálně aplikovat, můžete rychle narazit na problémy. Jedna z komplikací, se kterou jsem se já setkal, jsou cyklické závislosti mezi story. Vezměme si tuto situaci:

  • GameStateStore obsahuje aktuální stav hry (PAUSED, PLAYING, OVER…)
  • SquareStore obsahuje informaci o čtvercích na herním poli (umístění a barva)

SquareStore zareaguje na UPDATE akci pouze v okamžiku, kdy aktuální stav hry je PLAYING (akce je ignorována, když je hra pozastavená či již skončila). SquareStore tedy závisí na GameStateStore. Na tu druhou stranu, pokud během aktualizace hra skončí (což se děje ve SquareStore; čtverce na herním poli dosáhnou horní hranice obrazovky), GameStateStore toto musí detekovat a změnit status na OVER. GameStateStore tedy závisí na SquareStore. Aby to bylo zcela jasné, zde je konkrétní situace, ke které může dojít:

  1. Je dispatchnutá akce UPDATE
  2. SquareStore zareaguje na tuto UPDATE akci jako první a nejprve si ověří (v GameStateStore), zda hra běží (neskončila či není pozastavená). Řekněme, že běží.
  3. SquareStore aktualizuje všechny čtverce na herním poli a ukáže se, že jeden čtverec dosáhl horní hranice. Hra by tedy měla skončit.
  4. GameStateStore zareaguje na tuto UPDATE akci jako druhý, zkontroluje SquareStore, a protože podmínky pro konec hry jsou splněny, přepne stav na OVER.

Máme tu zjevnou cyklickou závislost. Oba story musí mít přístup jeden k druhému. Proč je to problém? Ve většině implementací jsou jednotlivé story udělané jako JavaScriptové moduly, které jsou v případě potřeby načítány pomocí require (či import). Tyto moduly vytváří strom závislostí dané aplikace a tedy cykly už z definice nepřipadají v úvahu. Co teď?

Update: jeden z čtenářů v diskuzi upozornil na to, že ve skutečnosti import (či i require s drobným omezením) cyklické závislosti nativně podporuje. Následující odstavce přesto zůstávají relevantní ve smyslu, že popisují atypickou situaci, se kterou se lze občas setkat a je třeba se s ní vyrovnat. Pouze výsledné implementační řešení mohlo být jednodušší.

Asi základní námitka by mohla být, že je to záležitost špatného návrhu. Protože hra ve skutečnosti skončí ve SquareStore (to je to místo, kde dojde ke konci hry), tato informace by měla být obsažena v tomto storu. Tedy tyto dva story by měly být sjednoceny do jednoho. Bohužel, všechny ostatní story trpí tímto neduhem rovněž, a musely by také být spojeny. V konečném důsledku by se 95% celé aplikace smrsklo do jediného storu.

To sám o sobě není zase takový problém. Kód není nutné členit zrovna do storů. Stačil by jeden store a uvnitř něj by se aplikovaly jiné způsoby na dělení odpovědnosti. Pravděpodobně by se i jednalo o nejjednodušší a nejčistší řešení. Nicméně já jsem se z akademických důvodů rozhodl sledovat tuto cestu až do (hořkého) konce.

Předem je potřeba si uvědomit jednu důležitou věc. Mluvíme tu ve skutečnosti o dvou různých typech kruhové závislosti.

  1. Ta první souvisí s pořadím, v jakém jsou story aktualizovány pomocí dané akce (action dependency graf). Toto pořadí je vynuceno pomocí waitFor() a pokud store A čeká na store B, zcela jasně B nemůže čekat na A v tu samou chvíli (ve světě mimo JavaScript bychom měli deadlock). V naší situaci je ale pořadí naprosto jasné: napřed je potřeba „odbavit“ SquareStore a teprve pak GameStateStore. Může se stát, že v rámci jedné akce A čeká na B, a v rámci jiné akce B čeká na A. To je ale v pořádku. Dokonce i vývojáři ve Facebooku říkají, že je možné mít různé grafy závislostí pro různé akce.
  2. Druhý typ je na úrovni modulů. Tedy které story musí k sobě přistupovat navzájem. To se nás týká, protože SquareStore musí mít přístup do GameStateStore a GameStateSTore musí mít přístup do SquareStore. Nyní si ale připomeňme, že story mohou být změněny pouze skrz akce, a že jejich obsah je přístupný pouze skrz veřejné read-only API. Tedy když říkám, že jeden store musí mít přístup do druhého, mám na mysli čtení. Mluvíme tedy o read dependency grafu.

Abych se vypořádal s výše uvedeným, rozhodl jsem se naimplementovat story jako jednotlivé instance (ne jako static moduly). Tyto instance jsou pak všechny obsaženy v jednom poolu, který je přístupný všem storům. Tedy story můžou naprosto libovolně číst mezi sebou navzájem.

To může znít jako pěkná ošklivost, je ale třeba mít na paměti:

  • Story můžou navzájem od sebe pouze číst, nemůžou se navzájem měnit.
  • Veškeré změny přicházejí skrz akce a pořadí, v jakém jsou story aktualizovány, je jasné dáno.

Na toto je možné se dívat jako na množinu proměnných na jedné úrovni. Někdy už z definice hierarchie neexistuje.

Poslední poznámka na toto téma: Tento problém kruhových závislostí skutečně existuje. Na Githubu na toto téma dokonce můžete najít issue. Závěrem může být, že Flux se skutečně nehodí do všech situací.

Ještě jedna poznámka: jednou z rad, která může zaznít (a kterou jsem dostal na StackOverflow), je jednoduše dispatchnout novou akci GAME_OVER  v okamžiku, kdy dojde ke konci hry. Podle mě je to nesmysl a je to důsledkem nepochopení celé myšlenky Fluxu. Základní vlastností je, že se vyhneme kaskádovým updatům, které činí aplikaci nepřehlednou. Novou akci lze dispatchnout pouze v okamžiku, kdy ta předchozí skončí (dispatcher to dokonce ani nedovolí).

Příště se podíváme na globální immutable stav.

Student závěrečného ročníku Matematicko-fyzikální fakulty, který se momentálně živí Nette/PHP a ve volném čase se věnuje všemu možnému.

Věděli jste, že nám můžete zasílat zprávičky? (Jen pro přihlášené.)

Komentáře: 17

Přehled komentářů

petrbolf Flux versusu Redux
HonzaMarek Re: Flux versusu Redux
Tobiáš Potoček Re: Flux versusu Redux
HonzaMarek Re: Flux versusu Redux
Ondřej Žára Kruhová závislost
Tobiáš Potoček Re: Kruhová závislost
steida Re: Kruhová závislost
Tobiáš Potoček Re: Kruhová závislost
steida
Oldis
Oldis Re:
Jarda
HonzaMarek Re:
Jarda Re:
HonzaMarek Re:
Tobiáš Potoček Re:
Jarda Re:
Zdroj: https://www.zdrojak.cz/?p=16464