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

V další kapitole této minisérie se podíváme na tzv. global immutable state. Co to je, k čemu to je dobré a jaké to přináší výzvy. Mluvit se mimo jiné bude o kurzorech, problematice ukládání a obnovování globálního stavu a také o tom, jak psát čitelný kód nad Immutable.js API.

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

Poznámka na začátek. Následující postupy opět silně vycházejí z devstacku Este.js. Bohužel z verze, která byla aktuální někdy před půl rokem, když jsem začal Lumines programovat. Este od té doby kompletně přešlo na Redux, takže tam některé věci, o kterých nyní bude řeč, už nenajdete. Základní princip ale zůstává stejný.

„The global immutable state“

Do tohoto okamžiku jsme mluvili o tom, že aktuální stav je uložen ve storech. Z hlediska aplikační architektury se na tomto faktu nic nemění. Půjdeme-li ale o úroveň abstrakce níže (tedy začneme řešit, jakým způsobem jsou story naimplementovány), zjistíme, že veškerá data nejsou uložena přímo ve storech, ale v tzv. globálním neměnném stavu (v tomto kontextu funguje immutable jako termín, takže se budu držet anglického názvosloví). Dá se na to nahlížet tak, že story používají externí úložiště, tedy něco jako databázi.

Jak jsem zmínil na začátku, tento nápad není můj, převzal jsem ho z Este, a pokud se nemýlím, tak úplně původně se objevil ve frameworku Om.

„The global“

Nejprve se zaměřme na přívlastek globální. To znamená, že veškeré informace definující aktuální stav hry jsou uloženy v jednom jediném JavaScriptovém objektu. Ten se skládá z hierarchie map (key-value struktury, či zjednodušeně JavaScriptové objekty) a kolekcí (můžeme mluvit o polích). Story pak fungují jako jakési pohledy (views) na data. Připomíná to klasickou aplikační architekturu, kdy veškerá data jsou uložená v databázi, a k té se přistupuje skrz modelovou vrstvu.

K čemu je to dobré? Protože se jedná o jediný objekt, je možné ho jednoduše vzít a přesunout někam jinam, či dokonce sdílet napříč aplikacemi. V mém repositáři najdete dvě dema, která tohoto využívají:

  • V tom prvním je aktuální stav automaticky uložen do Web Storage, kdykoliv pozastavíte hru. To znamená, že můžete bez starostí zavřít okno prohlížeče, a když ho zpět otevřete, tak hra detekuje uložený stav, načte ho a umožní vám tak pokračovat, kde jste přestali.
  • To druhé je ještě zajímavější. Kdykoliv dojde k nějaké změně (což znamená pokaždé, když je dispatchnutá nějaká akce), tak je aktuální stav opět uložen do Web Storage. Následně můžete otevřít další okno prohlížeče a začít naslouchat oněm změnám v úložišti. Výsledkem je, že hra je v reálném čase přenášena do druhého okna prohlížeče. Jak byste asi čekali, výkon v tomto případě není nijak oslnivý.

Obě dema jsou velice krátká, což ukazuje sílu tohoto přístupu.

„The immutable“

Nyní se přesuneme k přívlastku immutable. Celý globální stav je implementovaný jako neměnný pomocí knihovny Immutable.js (nepřekvapivě zveřejněnou Facebookem). Popis této knihovny by byl značně nad rámec tohoto článku. Proto nebudu zabíhat do detailů a místo toho vás odkáži na dokumentaci či toto výtečné video, které trefně shrnuje podstatu a především výhody oné knihovny.

Zjednodušeně řešeno: pokud je objekt immutable, tak jakmile ho jednou vytvoříte, tak ho už nezměníte. Viz následující příklad:

var map1 = Map();
var map2 = map1.set('b', 2);
assert(map1 != map2);

To je vše. Pokud se pokusíte změnit immutable objekt, dostanete novou instanci a původní objekt zůstane nezměněn.

Immutable objekty mají spoustu výhod. Na příklad ve vícevláknových programech značně usnadňují synchronizaci. Pro nás je zajímavé především to, že při vykreslování uživatelského rozhraní lze velice jednoduše (a rychle!) detekovat, které části globálního stavu se změnily a tedy které části uživatelského rozhraní by měly být překresleny. Stačí si zapamatovat referenci na původní data, a tu následně porovnat s novými příchozími daty. Toto nám umožňuje překreslit uživatelského rozhraní i 60krát za sekundu, a to právě proto, že vždy překreslujeme jen malou část.

Píšeme high level kód nad immutable daty

Immutable objekty jsou cool a i API poskytované knihovnou Immutable.js je velice mocné. Přesto ale může být samotná práce s takovými objekty dost neohrabaná. Máme tu jeden globální objekt sdílený mezi všemi story, z nichž všechny by ten stav rády měnily. Každá změna nicméně vytvoří nový objekt, který si musíme zapamatovat a referenci na něj opět distribuovat mezi všechny story.

Aktualizace globálního stavu pomocí kurzorů

Chytří hoši se rozhodli toto vyřešit pomocí kurzorů. Již jsem jednou zmínil, že globální stav funguje podobně jako databáze. Pak kurzor by v této analogii odpovídal konkrétnímu připojení do dané databáze, nicméně omezenému na jedinou tabulku. I toto připodobnění není zcela přesné, protože v případě JavaScriptového objektu nemůžeme mluvit o tabulkách (objekt postrádá jakékoliv schéma a může se nořit do libovolné hloubky). Kurzor jednoduše ukazuje na konkrétní místo v hierarchii onoho objektu a místo SQL dotazů se používají aktualizační funkce.

Lumines obsahuje velice jednoduchou implementaci kurzorů převzatou z Este. Nechť náš globální objekt má následující strukturu:

{
    'ScanLineStore': {
        'position': 0
    }
}

Kurzor bychom pak používali takto:

const positionCursor = state.cursor(['ScanLineStore', 'position'], 0);
while (positionCursor() < 10) {
    positionCursor(position => position + 1);
}

Kurzor ukazuje přímo na pozici scan line, což je jednoduchý integer. Zavolání kurzoru jako funkce nám vrátí samotnou hodnotu. Pokud chcete hodnotu změnit, předejte kurzoru aktualizační funkci, která jako argument dostane aktuální hodnotu a její návratová hodnota se bere jako hodnota nová.

Možná ne všichni čtenáři jsou zvyklí číst nový ES6 zápis funkcí. Takto by to vypadalo v klasičtějším stylu:

const positionCursor = state.cursor(['ScanLineStore', 'position'], 0);
while (positionCursor() < 10) {
    positionCursor(function (position) {
        return position + 1;
    });
}

V Lumines kurzory obvykle neukazují na jednotlivé hodnoty, nýbrž na celé podobjekty, které typicky odpovídají jednotlivým storům. Tedy každý store má svůj kurzor (či eventuálně více), skrz který může měnit globální stav.  Protože každý kurzor ukazuje pouze na jedno konkrétní místo, je store efektivně odstíněn od zbytku globálního objektu. Což je rovněž žádoucí.

Z hlediska programátorského pohodlí je toto značný krok kupředu, leč to stále není ideální.

Data Access Objects

Samotná herní logika Lumines není komplikovaná, ale také ne zcela triviální. Problém s immutable API je ten, že je pořád značně ukecané, a pokud se pomocí něj pokusíme vyjádřit nějaký vnitřní herní algoritmus, značně tím trpí čitelnost výsledného kódu. Jednoduše řečeno, myšlenka algoritmu zůstane skryta pod všemi těmi řetězeními a callbacky.

Přesuňme se zpět k naší databázové analogii. V běžných databázových aplikacích také není obvyklé psát business logiku přímo v SQL dotazech. Typickým přístupem je přikrýt databázi další vrstvou abstrakce. Konkrétní řešení jsou různé (repositáře, Data Access Objekty, ORMs…), ale cíl je vždy stejný.

A přesně toto jsem se rozhodl aplikovat i v Lumines. Základní herní koncepty jsem obalil Data Access Objekty (ze všech možností se mi toto zdálo jako nejvhodnější pojmenování), které poskytují nad Immutable.js čisté a čitelné API. Jako příklad uvedu DAO obalující padající blok. Viz následující úryvek kódu:

class Block extends ImmutableDao {
    // ...

    get dropped() {
        return this.cursor().get('dropped');
    }

    // ...
}

Všimněte si další ES6 syntaxe: getteru. Ověřit, zda byl blok upuštěn, je v tuto chvíli velmi přímočaré:

if (block.dropped) {
    // ...
}

Pro představu, tady je o něco komplexnější úryvek kódu:

const update = (time, gravity) => {
    this.waitFor([gravityStore]);
    const {block} = this;

    if (block.dropped) {
        block.update(time, gravity);
        if (willCollide()) {
            decompose();
        }
    } else if (block.willEnterNewRow(time) && willCollide()) {
        decompose();
    } else {
        block.update(time, gravity);
    }
};

I přestože samotná informace je uložená v immutable objektu, tak výsledný kód vypadá velmi normálně. Immutabilita a globální stav byly přesunuty do nižší vrstvy a stal se z nich implementační detail, o který se nemusím zajímat, když píšu svůj high level kód.

Tedy skoro.

Nebezpečí vnořených kurzorů

Kdykoliv pracujete s více kurzory najednou, nikdy nesmíte zapomenout, že ve skutečnosti měníte jeden jediný objekt. Řekněme, že máme dva kurzory ukazující na dva různé seznamy (obsaženy nicméně v jednom globálním objektu). Nyní chceme přesunout některé prvky (určené funkcí predicate) z jednoho seznamu do druhého.

list1Cursor(l1 => 
    l1.filter(el => {
        if (!predicate(el)) {
            list2Cursor(l2 => l2.push(el));
            return false;
        } else return true;
    });
);

Toto fungovat nebude, a abychom pochopili proč, musíme se podívat na to, jak jsou vlastně kurzory zevnitř udělány. Následuje pseudo kód kurzoru inicializovaného pro konkrétní cestu v globálním objektu:

let state, path; // nějakým způsobem inicializované z vnějšku

function cursorExample(update) {
    if (update) {
        state = state.updateIn(path, update);
    }
    return state.getIn(path);
};

Na kódu by nemělo být nic překvapivého. Update funkce, pokud je definována, se aplikuje na aktuální stav, čímž vznikne nový stav. My si ten nový stav zapamatujeme (resp. referenci na něj). A to je přesně ten okamžik, kdy výše uvedený příklad selže.

Jak iterujeme přes první seznam, jednotlivé vyfiltrované prvky jsou přidávané do druhého seznamu. Tím se nám mění globální stav. Problém je, že vnější update funkce pořád drží referenci na ten úplně původní globální stav. Což znamená, že jakmile doběhne a všechny prvky jsou ze seznamu odebrané, tak tyto změny jsou aplikovány na onen původní stav. Nikoliv na ten nový, ve kterém druhý seznam obsahuje přidané prvky. Jakmile si v závěrečném kroku uložíme referenci na aktualizovaný stav, tyto změny jsou nenávratně ztraceny.

Zákeřné na tomto celém je to, že tato vlastnost je velmi dobře schovaná. Fakt, že oba kurzory ve skutečnosti ukazují na ten samý objekt a tedy by neměly být použity takovýmto způsobem, není z kódu nijak zjevný. A už vůbec ne, pokud zavedeme další úroveň abstrakce, jak bylo popsáno výše. Dopustit se takové chyby je tedy poměrně snadné a najít, co přesně se pokazilo, zabere dost času.

Bohužel se nám tímto pokazila ona iluze vyššího pohodlného API, protože musíme neustále myslet na omezení nižší vrstvy. Částečným řešením by mohlo být předělat kurzory tak, aby byly schopné samy detekovat vnořená volání. Například výchozí implementace Flux dispatcheru toto přesně dělá. Není možné dispatchnout novou akcí, dokud není vyřízená ta předchozí.

Immutable Records

Immutable.js knihovna nabízí spoustu různých datových struktur a jednou z nich je i Record. Jedná se v podstatě o mapu (JS objekt či asociativní pole, chcete-li), u kterého ale můžete definovat seznam povolených klíčů (properties) a jejich výchozích hodnot. V podstatě se jedná o JS objekt s pevně daným a vynuceným schématem.

Jednou z příjemných vlastností je to, že je možné přistupovat k hodnotám skrz ES6 gettery. Tedy místo otravného obj.get('property') se použije mnohem kratší obj.property. To je hezká zkratka, ale důsledky jsou mnohem dalekosáhlejší. Díky tomuto je totiž dovoleno používat ES6 desctructing (což, jak se ukazuje, je jedna z mých nejoblíbenějších ES6 vychytávek). Jako příklad uvedu funkci, která vrátí, zda se na daných souřadnicích herního pole nalézá nějaký čtverec:

function isFree({x, y}) {
    // ...
}

Definice parametrů tímto způsobem tak trochu připomíná použití interface z vyšších jazyků. Tato funkce de facto říká: „jako vstupní parametr beru cokoliv, ale musí to mít x a y souřadnice“. S většinou immutable struktur toto nefunguje, s Recordy ale ano.

Z těchto důvodů je v Lumines několik entit naimplementováno jako Recordy. To ale před nás staví další výzvu.

Ukládání a obnovování globálního stavu s vlastními immutable strukturami

Lumines podporuje uložení a obnovení globálního stavu, který drží veškeré aktuální herní informace (jak bylo popsáno na začátku, jedná se o jednoduchý způsob, jak distribuovat aktuální herní stav). Ukládání a obnovení je postaveno na Immutable.js API (konkrétně funkcích toJS() a fromJS()), které umožní konverzi mezi immutable strukturami a běžnými JavaScriptovými strukturami (objekty a pole).

Převod do běžného JavaScriptu je přímočarý:

  • všechny struktury s textovými klíči (princip asociativního pole) jsou převedeny na standardní JS objekty
  • všechny struktury s numerickými klíči jsou převedeny na standardní pole

Převod zpět je taktéž přímočarý. Objekty jsou převedeny na Mapy a pole na Listy, což jsou základní immutable struktury. Co když úplně původní immutable objekt nebyl Mapa, ale něco jiného? Třeba právě Record? A nebo třeba normální objekt (ano, do immutable struktury je možné uložit běžný mutable objekt, i když to nedává moc smysl).

Bohužel specifické datové typy immutable struktur jsou v průběhu ukládání a obnovení nenávratně ztraceny. Jako řešení nabízí Immutable.js API možnost použít ručně definovaný reviver, který umožňuje zasáhnout do obnovovacího procesu a například ovlivnit i výstupní datový typ. To je ale stěží použitelné řešení, protože se tím tak nějak předpokládá, že v okamžiku obnovy znáte kompletní strukturu obnovovaného objektu.

Pravděpodobně nejčistší řešení by bylo nechat každý jednotlivý objekt (v našem případě store) samostatně rozhodnout o způsobu ukládání a obnovení. Aktuální verze Este (aktuální ke dni vydání článku, brzy může být vše jinak) to takto přesně řeší. Nicméně tím je část toho celého kouzla globální immutable objektu nenávratně pryč.

Tématická vsuvka: První izomorfní React/Flux demo, se kterým jsem přišel v dobách středověku (asi tak před rokem a půl) do styku, řešilo přenos stavu mezi serverem a klientem přesně takto. Každý store jednoduše definoval serializační a deserializační rutiny (pokud se dobře pamatuji, v jejich terminologii se tomu říkalo hydrate/dehydrate store). Pak přišla móda kurzorů, „state-less“ storů a global immutable stavu, který se prostě vzal, tak jak byl, a přesunul ze serveru na klienta. Bohužel jak ukazují i tyto odstavce, měl tento přístup své limity, takže momentálně jsme zase zpátky na začátku.

Mně, jako beznadějnému idealistovi, se samozřejmě toho kouzla vzdát nechtělo, a tak jsem přišel s kompromisním řešením. V Lumines se tento problém dotýká jen Recordů, a u těch je výhoda, že mají jasně definované schéma. Můj reviver tedy funguje tak, že prochází jednotlivé objekty, a pokud seznam klíčů (properties) odpovídá schématu nějakého Recordu, vytvoří místo obyčejné Mapy onen Record. Vypadá to asi takto:

const squareKeys = (new Square()).keySeq();
if (value.keySeq().equals(squareKeys)) {
    return new Square(value);
}

Podobně jako Square mám nadefinované i další Recordy.

Toto řešení je stěží univerzální, ale v tomto případě funguje poměrně dobře.

V příštím závěrečném díle se budeme věnovat generování uživatelského rozhraní pomocí Reactu a SVG a také si ukážeme konkrétní implementaci jednoho ze zmíněných demíček.

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: 3

Přehled komentářů

zahory
Tobiáš Potoček Re:
Oldis
Zdroj: https://www.zdrojak.cz/?p=16636