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

V závěrečném díle o Lumines se podíváme na to, jak je vykresleno uživatelské rozhraní. Budeme se bavit o Reactu, pure komponentách a jak je to s podporou SVG v React.js. Na závěr si ukážeme drobné demo, jak pustit Lumines na serveru, a čeho se tím dá dosáhnout.

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

V předchozích dvou dílech jsme postupně procházeli celý Flux cyklus. Věnovali jsme se dispatcheru, akcím a storům. Posledním zbývajícím prvkem k uzavření cyklu je uživatelské rozhraní neboli view. Flux jako aplikační pattern samozřejmě funguje univerzálně, nicméně od samého počátku byl zamýšlen jako doplněk k Reactu. Lumines v tomto směru není výjimkou.

Pure React komponenty

Všechny komponenty v Lumines jsou takzvaně „čisté“, neboli „pure“ (opět si nejsem jistý, zda pro „pure“ existuje nějaký zažitý český překlad, a proto se budu držet originálního názvu). Pokud jste alespoň trochu obeznámeni s Reactem, tak víte, že každá komponenta má props (pevné hodnoty předané z vnějšku) a state (hodnoty, které si udržuje sama komponenta). „Pure“ komponenta žádný vlastní state nemá. Vše je obsaženo v onom globálním immutable stavu, o kterém byl celý předchozí díl, ze kterého jsou hodnoty předávány skrze props dolu celou hierarchií komponent.

Pure komponenta se chová podobně jako obyčejná funkce. Předáte jí argumenty skrz props a ona vám vrátí „HTML markup“. To je vše.

Komponenty také žádným způsobem nekomunikují se story (v klasickém Fluxu story emitují eventy, kdykoliv v nich dojde ke změně, a komponenty těmto eventům naslouchají). Místo toho je aktualizace celé hierarchie komponent vyvolána ručně pokaždé, když je dispatchnuta nějaká akce (resp. je to synchronizované na 60 snímků za sekundu). Vyvolat z vnějšku update komponenty (a tedy její překreslení) je velice jednoduché. Stačí opakovaně volat React.render() na ten samý mountpoint (DOM element, do kterého je komponenta vykreslena).

class Lumines {
    // ...

    render() {
        React.render(<GameInterface
            scanLine={this.scanLineStore.scanLine}
            state={this.gameStateStore.state}
            // ...
            }} />, this.mountpoint);
    }
}

Kdykoliv je zavoláno Lumines.render() (i třeba z vnějšku hry), komponenty se zaktualizují a celé rozhraní se překresli, aby uživatelské rozhraní odpovídalo globálnímu stavu hry.

Překresli, jen když musíš

Poslední důležitou vlastností pure komponent je schopnost detekovat změnu v přicházejících props a na základě toho se rozhodnout, zda má dojít k překreslení. Pure komponenty se překreslí jen v případě, že skutečně k nějaké změně došlo. Což je naprosto klíčové z hlediska výkonu.

Funguje to tak, že při každé aktualizaci z vnějšku se Jednoduše vezmou přicházející props a porovnají se s těmi aktuálními. Toto se konkrétně děje ve speciální metodě zvané shouldComponentUpdate, která je součástí životního cyklu každé React komponenty.

Za normálních okolností by přesné („hluboké“) porovnání dvou JS objektů bylo drahé. Nicméně díky tomu, že celý náš stav je immutable (složený z immutable objektů), stačí porovnat pouze reference oněch objektů. Pokud se reference liší, jedná se o dva různé immutable objekty.

V diskuzi pod prvním dílem se jeden čtenář ptal, jak toto vlastně funguje, když s každou změnou globálního stavu vznikne úplně nový globální stav, a tedy vše je jiné. Tady je důležité si uvědomit, jak fungují immutable objekty zevnitř. S každou změnou sice vznikne nová instance objektu, ale zároveň se ze znovu použije vše, co se nijak nezměnilo (reference uvnitř immutable objektu ukazují na původní části). Nejlépe to ilustruje následující obrázek:

Aktualizace immutable objketu

Aktualizace immutable objketu (zdroj obrázku)

„Čtyřka“ se nám změnila na „osmičku“, ale jak vidíte, nejméně polovina stromu zůstala zachována. Pokud tedy kupříkladu máme v uživatelském rozhraní komponentu, která vykresluje „jedničku“, tak ona komponenta bude tento celý update globálního stavu ignorovat, protože se jí to nijak netýká.

Při návrhu struktury komponent je dobré na tuto vlastnost explicitně myslet, protože pomocí drobných optimalizací lze z tohoto vytěžit maximum. Kupříkladu čtverce v herním poli Lumines nejsou jednoduše vykresleny dvěma vnořenými cykly nýbrž po sloupcích, přičemž sloupec je další samostatná pure komponenta. Vypadá to nějak takto:

export default class GridSquares extends PureComponent {
    render() {
        const {grid} = this.props;
        return (
            <g>
                {grid.map((squares, i) =>
                    <SquareColumn key={i} squares={squares} />
                )}
            </g>
        );
    }
}

Vtip je v tom, že pokud dojde ke změně na herním poli (čtverce byly přidány či odebrány), je tato změna propagována pouze v rámci sloupců, kterých se to týká. Všechny ostatní sloupce zůstanou nedotknuty. Pokud by ale všechny čtverce byly vykresleny najednou v rámci jedné komponenty (dva vnořené cykly), jediný změněný čtverec by způsobil, že najednou všechny čtverce by musely ověřovat, zda došlo k nějaké změně.

Pravda, v tomto konkrétním případě bylo zvýšení výkonu sotva měřitelné, ale lze si z toho odnést, jakým způsobem lze s komponentami pracovat. S větším počtem komponent by taková optimalizace mohla hrát větší roli.

Jak tedy vyrobit pure komponentu tak, aby vše výše zmíněné fungovalo? K dispozici je PureRenderMixin pro starší syntax, případně pro ES6 tu máme základní pure komponentu, od které je možné dědit. Opět doporučuji toto již jednou linkované výborné (a ne příliš) dlouhé video na toto téma.

Inline SVG ve stránce

Pro ty z vás, kteří to možná nevědí, tak SVG je formát vnitřně založený na XML. Vypadá to v podstatě jak HTML (či spíše XHTML), jen samotné tagy jsou jiné. Pokud chcete vložit SVG obrázek do stránky, můžete vždycky použít starý dobrý tag img, nicméně moderní prohlížeče podporují takzvané inline SVG. To znamená, že můžete SVG definici vložit přímo do HTML kódu.

Takto například vykreslíte v SVG kruh přímo do stránky (příklad převzatý z W3Schools):

<!DOCTYPE html>
<html>
<body>

<svg width="100" height="100">
  <circle cx="50" cy="50" r="40" stroke="green" stroke-width="4" fill="yellow" />
</svg>

</body>
</html>

SVG používá vnitřní systém souřadnic, které jsou ve výchozím stavu namapované 1:1 na pixely. Toto chování ale může být změněno pomocí viewBoxu.

<svg viewBox="0 0 160 90" >
</svg>

Takto nadefinujete SVG obrázek, který bude vnitřně 160 bodů široký a 90 bodů vysoký. Při pozicování SVG elementů uvnitř obrázku se budete řídit těmito souřadnicemi. Nicméně skutečná velikost obrázku na obrazovce může být libovolná či dokonce responzivní!

Tohoto je využito i v Lumines. Rozhraní používá přesně dané vnitřní souřadnice, které umožňují nejen pohodlné pozicování herních komponent v poli, ale slouží i k samotným herním výpočtům, jako je detekce kolizí. Skutečné rozlišení na obrazovce je pak na tomto zcela nezávislé. Vnější svg element je roztažen pomocí CSS, aby zabral co největší možnou část z obrazovky. Hra díky tomu bude fungovat na displejích libovolné velikosti a díky vektorové nátuře SVG bude i vždy skvěle vypadat (i na retina displejích).

Tolik teorie. V praxi některé prohlížeče (ehm ehm IE) mají problém s tím korektně SVG obrázek roztáhnout na celou obrazovku a opět je potřeba se obrátit na špinavé hacky. Více na toto téma například zde.

SVG a React

Vzhledem k tomu, že SVG se v rámci stránky chová v podstatě stejně jako HTML, nic nebrání použití Reactu k vykreslování SVG obrázků. Chová se to přesně tak, jak byste čekali, jen máme nyní k dispozici podstatně silnější nástroje pro vytváření uživatelského rozhraní.

Jednou z nejlepších vlastností Reactu je to, že umožňuje rozdělit uživatelské rozhraní do samostatných izolovaných komponent. Takto například vypadá komponenta reprezentující jeden čtverec:

import React from 'react';
import classNames from 'classnames';

import PureComponent from './PureComponent.js';
import {SQUARE_SIZE} from '../game/dimensions.js';

export default class Square extends PureComponent {
    render() {
        const classes = classNames({
            'lumines-square': true,
            'lumines-square-dark': this.props.color,
            'lumines-square-light': !this.props.color,
            'lumines-square-scanned': this.props.scanned
        });

        return (
            <rect x={this.props.x} y={this.props.y}
                width={SQUARE_SIZE} height={SQUARE_SIZE}
                className={classes} />
        );
    }
}

Pár věci k povšimnutí:

  • Jak bylo popsáno v předchozí části, tato komponenta je pure. Žádný vnitřní stav, vše přichází skrz props.
  • Používají se tu CSS třídy. Přesně tak, SVG podporuje stylování, což znamená, že spoustu designových věcí je možné přesunout do další oddělené vrstvy. Samotné SVG tedy především definuje, co má být na obrazovce, a kde se to má nacházet, zatímco CSS se stará o to, jak to má vypadat.
  • Užitečná utilitka classNames() umožňuje elegantně vygenerovat seznam tříd, které mají být aplikovány na komponentu. Původně toto bylo přímo součástí Reactu, ale nedávno to bylo přesunuto do samostatného balíčku.

Další užitečná komponenta umožňuje pohodlné pozicování objektů na herním poli:

class Move extends PureComponent {
    render() {
        return (
            <g transform={'translate(' + this.props.x + ' ' + this.props.y + ')'}>
                {this.props.children}
            </g>
        );
    }
}

A takto se používá:

<Move x={GUTTER} y={GUTTER + 2 * SQUARE_SIZE}>
    <Queue queue={this.props.queue} />
</Move>

Tato komponenta jednoduše obalí všechny vnitřní SVG elementy pomocí SVG group a aplikuje na ní transformaci, která přesune ony elementy na požadované souřadnice.

Obecně vzato se celé uživatelské rozhraní skládá ze samostatných vhodně (sémanticky) pojmenovaných komponent, takže v konečném důsledku je celá definice velice elegantní a snadno se s ní pracuje. Prostě React.

Lumines používá i některé pokročilé grafické vlastnosti SVG jako například Gaussovo rozostření. Padající blok je vertikálně rozostřen a síla efektu je závislá na aktuální rychlosti.

<g className="lumines-block" style={{filter: "url('#blur')"}}>
    <defs dangerouslySetInnerHTML={{__html:
        '<filter color-interpolation-filters="sRGB" id="blur" x="0" y="0">' +
        '    <feGaussianBlur in="SourceGraphic" stdDeviation="0,' + block.speed / 150 + '"/> ' +
        '</filter>'}} />
    {block.squares.map((color, i) =>
        <Square key={i} color={color}
            x={block.x + getBlockSquareX(i)}
            y={discretize(block.y + getBlockSquareY(i))} />
    )}
</g>

Bohužel tento příklad zároveň ukazuje největší nevýhodu při práci s SVG v Reactu. Podpora SVG není stoprocentní. V některých situacích není jiné cesty než použít ošklivý atribut dangerouslySetInnerHTML.

Ještě něco ke stylům

Pokud jste si skutečně zkusili Lumines zahrát, možná jste si všimli, že se v pravidelných intervalech mění barevné schéma celé hry. Toho je dosaženo pomocí pár elegantních triků, které nyní zmíním.

Napřed potřebujeme obecnou definici barevného schématu. Vzhledem k tomu, že můžeme používat CSS (a tedy i různé preprocesory), není to nic komplikovaného.

.color-scheme(@dark, @light: #fafafa) {
  // ...
  .lumines-square-light {
    fill: @light;
  }
  .lumines-square-dark {
    fill: @dark;
  }
  // ...
}

Tady konkrétně používáme parametrizovaný LESS mixin. Nyní si nagenerujeme požadovaná barevná schémata.

.lumines-blue {
    .color-scheme(#3daee6);
}
.lumines-red {
    .color-scheme(#f83447);
}
.lumines-green {
    .color-scheme(#52A573);
}
// ...

Zjevně není problém přidávat další.

Nyní potřebujeme něco, co nám určí aktuální barevné schéma. Možností je více, ale v mém případě se barevné schéma mění jednoduše každou minutu, a proto jsem tuto zodpovědnost přenechal na TimeStore.

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

get color() {
    let colors = ['blue', 'red', 'green', 'orange', 'yellow', 'teal'];
    return colors[(Math.floor(this.elapsed / 60) % colors.length)];
}

V tomto případě aktuální barevné schéma není přímo součástí immutable stavu. Samotná informace je obyčejný řetězec, který je jako takový předán přímo kořenové komponentně.

React.render(<GameInterface
    // ...
    color={timeStore.color}
    // ...
/>, this.mountpoint);

A takto se pak aplikuje uvnitř kořenové komponenty:

render() {
    return (
        <svg className={'lumines lumines-' + this.props.color}>
            // ...
        </svg>
    );
}

Bonusové demo: Lumines na serveru

Nyní přichází na řadu otázka, k čemu to vše bylo dobré. Ukážeme si malé demo, v němž jedna herní instance Lumines je sdílena napříč několika prohlížeči pomocí Node.js serveru. Výsledek si můžete prohlédnout na tomto videu.

Demo se skládá z klientské a serverové (Node.js) části. Serverová část bude fungovat jako pojící prvek mezi všemi běžícími instancemi. Princip bude takový, že server bude přijímat Flux akce od vysílající instance a přesměrovávat je na všechny přijímající instance. Celá komunikace poběží přes Web Sockety.

Disclaimer: Následující kód je skutečně jen drobné proof-of-concept demo. Nejedná se o robustní aplikaci určenou k produkčnímu nasazení. V celém kódu tedy předpokládám pouze čisté úmysly ze strany uživatelů a nebudu ošetřovat speciální stavy.

Začněme ze serverovou stranou. Hlavním úkolem bude jednoduše přesměrovávat (či lépe vysílat) všechny příchozí akce pomocí socketů. Pro usnadnění práce použijeme existující implementaci Web Socketů ws.

import {Server} from 'ws'

const server = new Server({port: 9091});
server.on('connection', client => {
    client.on('message', data => {
        server.clients.forEach(client => client.send(data));
    });
});

Zatím nic překvapivého. Kdykoliv dostaneme zprávu (Flux akci), tak ji vezmeme a odešleme všem připojeným klientům (což znamená, že ji pravděpodobně dostane zpátky i ten klient, od kterého zpráva původně pochází. To ale nevadí, onen vysílající klient ji bude prostě ignorovat).

Co se ale stane, když se nový klient připojí uprostřed již probíhající hry? Začne přijímat vysílané akce, ale jeho herní stav bude zcela jiný. Je tedy nezbytné každému novému klientovi napřed zaslat aktuální stav a teprve potom začít s přeposíláním akcí. A abychom tohoto docílili, musí být aktuální stav dostupný i na serveru. Takže nyní přišel čas spustit Lumines v Node.js prostředí.

Jedná se o JavaScriptovou aplikaci, takže by to neměl být problém. Bohužel současná implementace počítá s prostředím prohlížeče (např. dostupnost DOM stromu, Web Storage atd.). Není to ideální řešení, ale pro teď si vystačíme s jednoduchým trikem: nasimulujeme prostředí prohlížeče v Node.js pomocí mock-browser.

import mockBrowser from 'mock-browser'
const MockBrowser = mockBrowser.mocks.MockBrowser;
GLOBAL.window = MockBrowser.createWindow();
GLOBAL.document = MockBrowser.createDocument();
const abstractBrowser = new mockBrowser.delegates.AbstractBrowser({window});
GLOBAL.navigator = abstractBrowser.getNavigator();
GLOBAL.localStorage = abstractBrowser.getLocalStorage();

Skrz GLOBAL lze v Node.js definovat skutečné globální proměnné, které budou dostupné odkudkoliv (takže za normálních okolností nepoužívat!). Nyní už můžeme aktualizovat náš serverový kód:

const lumines = new Lumines();
const server = new Server({port: 9091});

server.on('connection', client => {
    client.on('message', data => {
        const {action, payload} = JSON.parse(data);
        lumines.dispatch(action, payload);
        server.clients.forEach(client => client.send(data));
    });

    client.send(JSON.stringify(lumines.getState()));
});

Každá příchozí akce bude dispatchnuta do lokální instance Lumines. Tedy v okamžiku, kdy se připojí nový klient, mu můžeme zaslat aktuální stav. Díky tomu, že sockety běží přes TCP a že JavaScript je jednovláknový, nemusíme se starat o synchronizaci a máme zaručeno, že všechny zprávy budou odeslány a přijaty ve správném pořadí.

Nyní se přesuňme na klienta. Uživatelské rozhraní bude obsahovat dvě tlačítka, které umožní uživatelovi zvolit, zda chce akce vysílat či přijímat.

Nejprve inicializujeme Lumines. Tato část je totožná pro oba případy.

const lumines = new Lumines(document.getElementById('lumines'));

Následuje režim vysílání:

document.getElementById('broadcast').onclick = () => {
    const ws = new WebSocket(socketUrl);
    ws.onopen = () => {
        lumines.register(action => {
            ws.send(JSON.stringify(action));
        });
        lumines.start();
    };
};

Opět nic komplikovaného. Jakmile se podaří otevřít nový socket, zaregistrujeme se jako další listener u dispatcheru a spustíme hru, aby uživatel mohl začít hrát. Veškeré dispatchnuté akce budou odeslány přes socket.

Režim přijímání je o něco komplikovanější, protože se musíme zvlášť postarat o první příchozí zprávu, která bude obsahovat aktuální stav.

document.getElementById('listen').onclick = () => {
    const ws = new WebSocket(socketUrl);
    ws.onopen = () => {
        ws.onmessage = event => {
            lumines.setState(JSON.parse(event.data));

            ws.onmessage = event => {
                const {action, payload} = JSON.parse(event.data);
                lumines.dispatch(action, payload);
                lumines.render();
            };

            lumines.render();
        };
    };
};

A to je vše. Pokud jste si pustili video, mohli jste si všimnout, že 3. a 4. instance už zdaleka neběžely tak plynule. Tedy jsou zde zjevné limity ohledně výkonu, ale pořád to běží o hodně lépe, než sdílení aktuální stavu skrz Web Storage. Druhá věc je to, že demo na záznamu obsahuje drobný bug, kvůli kterému synchronizace akcí nebyla zcela přesná. Aktuální verze (ta, kterou jsme si právě prošli) už funguje korektně.

Kompletní zdrojové kódy jsou dostupné na GitHubu.

Závěr

Tak toliko o Lumines. Zdaleka se nejedná o typickou React aplikaci a tedy je dost dobře možné, že se s problémy, se kterými jsem zápasil já, nikdy nesetkáte. Na druhou stranu některé zmíněné koncepty (jako např. kombinace Reactu a SVG) mají podle mě v sobě skryto mnoho potenciálu a v budoucnosti s nimi bude ještě spousta zábavy.

Otázka také zní, zda pro tuto konkrétní aplikaci by nebyl tradičnější přístup vhodnější. Na tu druhou stranu, kombinace React + Flux + Immutable.js přináší na stůl některé nesporně unikátní vlastnosti, jak jsme si ukázali v závěrečném demu. Jsou ale skutečně užitečné?

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

Přehled komentářů

JS Overprogramming
defectus Re: Overprogramming
Tobiáš Potoček Re: Overprogramming
JS Re: Overprogramming
Tobiáš Potoček Re: Overprogramming
vojta tranta Pozor na key atribut
Tobiáš Potoček Re: Pozor na key atribut
Ivan Nový Imuttable objekty? Koncept starý 2500 let
Zdroj: https://www.zdrojak.cz/?p=16762