Programování Her v JavaScriptu

Programování Her v JS

Canvas Tahák

3. Main Loop

Co je to Main Loop

Ve většině her nám nebudou stačit jen event listenery, ale budeme chtít aby se nějaký kód pravidelně opakoval bez provedení nějaké akce předtím. Řekněme že v naší hře z nějakého důvodu chceme aby se po canvasu sám od sebe pohyboval třeba nějaký obdelník. Obdelník se musí pohybovat sám, pomocí event listenerů to udělat nemůžeme, takže si musíme vytvořit něco co obdelník bude pohybovat. Potřebovali bychom si vytvořit tzv. Main Loop, které bude pravidelně v nějakém časovém intervalu spouštět nějaký kód který bude obdelník po canvasu pohybovat.

Kdybych se vás zeptal jak vytvořit Main Loop, tak byste možná navrhli použít funkci setInterval, pokud ji znáte. Tato funkce by v pravidelném časovém intervalu volala nějakou funkci a v podstatě by takové jednoduché Main Loop vytvořila. Na nějaké jednoduché hry kde nezáleží na časové přesnosti by to možná stačilo, pokud bychom to ale použili někde kde na tom záleží, tak už by nám to nevyhovovalo. Existuje lepší řešení jak Main Loop vytvořit a v této části bych se ho pokusil postupně popsat.

Toto řešení jsem nevymyslel já, ale našel jsem si ho na internetu. Dříve jsem používal své vlastní Main Loop, když jsem ale potom začal dělat hru kde na časové přesnosti záleží, tak jsem zjistil že není přesné a musel jsem hledat jiné řešení. To jsem našel v tomto článku: A Detailed Explanation of JavaScript Game Loops and Timing. Autor tam postupně vysvětluje jak Main Loop vytvořit a má tam i odkaz kde si ho můžete stáhnout a použít ve svém projektu aniž byste si museli programovat svoje vlastní.

První pokus

V této části nebudeme ještě dělat žádnou hru, ale zatím budeme po canvasu jen pohybovat čtverec z jedné strany na druhou a zpátky. Pojďme si pro tuto jednoduchou úlohu tedy vytvořit Main Loop.

V našem Main Loop budeme používat funkci, která se jmenuje requestAnimationFrame. Tato funkce slouží k zaregistrování funkce, kterou prohlížeč později zavolá až bude překreslovat obrazovku. Následující ukázka kódu ukazuje jak Main Loop s pomocí této funkce vytvořit, zatím to ale ještě není přesné.

<!DOCTYPE html>
<head>
    <meta charset="UTF-8">
    <style>
        /* základní nastylování canvasu */
        canvas {
            border: 2px solid #f7f7f7;
            background-color: #252525;
        }
    </style>
</head>
<body>
    <canvas width="400" height="400" id="MujCanvas"></canvas>

    <script>
        // zíkání kontextu ke canvasu a nastavení barvy kterou budeme vykreslovat čtverec
        let canvas = document.getElementById("MujCanvas");
        let ctx = canvas.getContext("2d");
        ctx.fillStyle = "green";

        // v těchto proměnných budeme ukládat pozici čtverce a jestli se pohybuje doprava nebo ne
        let poziceCtverce = 30;
        let pohybovatDoprava = true;

        // tyto konstanty budou reprezentovat minimální a maximální pozici čtverce a jeho rychlost pohybu
        const MIN_POZ_CTVERCE = 30;
        const MAX_POZ_CTVERCE = 320;
        const RYCHLOST_POHYBU = 2;

        // řekneme prohlížeči že při dalším překreslení obrazovky má zavolat funkci která se jmenuje mainloop
        requestAnimationFrame(mainLoop);

        // tuto funkci budeme volat když bude prohlížeč překreslovat obrazovku
        function mainLoop() {
            // zavoláme naši update a draw funkci
            update();
            draw();
            // řekneme prohlížeči že při dalším překreslení obrazovky má opět zavolat funkci mainloop (takže se tato funkce bude volat vždy když bude prohlížeč překreslovat obrazovku)
            requestAnimationFrame(mainLoop);
        }

        // v této funkci budeme postupně měnit pozici čtverce
        function update() {
            // postupně budeme čtverec posunovat doprava nebo doleva a pokud dosáhne maximální nebo minimální pozice tak změníme směr a začneme čtverec posouvat na druhou stranu
            if (pohybovatDoprava) {
                poziceCtverce += RYCHLOST_POHYBU;
                if (poziceCtverce >= MAX_POZ_CTVERCE) pohybovatDoprava = false;
            } else {
                poziceCtverce -= RYCHLOST_POHYBU;
                if (poziceCtverce <= MIN_POZ_CTVERCE) pohybovatDoprava = true;
            }
        }

        // v této funkci budeme čtverec vykreslovat na canvas
        function draw() {
            ctx.clearRect(0, 0, canvas.width, canvas.height);
            ctx.fillRect(poziceCtverce, 50, 50, 50);
        }
    </script>
</body>

Problémy s časováním

Předchozí ukázka kódu nebude na každém počítači běžet stejně rychle. Na méně výkonném počítači se může náš čtverec po canvasu pohybovat pomaleji a na nějakém více výkonném počítači který má vyšší obnovovací frekvenci monitoru se čtverec může pohybovat rychleji. Takto to samozřejmě nechceme nechat, musíme tento problém opravit.

Pojďme si do našeho kódu implementovat možnost nastavit FPS (snímky za sekundu). Využijeme toho, že funkce requestAnimationFrame nám do naší funkce může předávat jako parametr aktuální čas. Můžeme tedy kontrolovat kolik času uběhlo a podle toho volat naši update a draw funkci. Následující ukázka kódu ukazuje jen obsah script elementu, vše ostatní je stejné.

let canvas = document.getElementById("MujCanvas");
let ctx = canvas.getContext("2d");
ctx.fillStyle = "green";

let poziceCtverce = 30;
let pohybovatDoprava = true;

const MIN_POZ_CTVERCE = 30;
const MAX_POZ_CTVERCE = 320;
const RYCHLOST_POHYBU = 2;

// v této proměnné si budeme ukládat čas, kdy jsme naposledy volali funkci update a draw
let casPoslednihoSnimku = 0;
// V této proměnné si určíme kolik snímků za sekundu by mělo maximálně proběhnout (klidně si zkuste tuto hodnotu změnit)
let maxFPS = 10;

// řekneme prohlížeči že při dalším překreslení obrazovky má zavolat funkci která se jmenuje mainloop
requestAnimationFrame(mainLoop);

function mainLoop(aktualniCas) {
    // následující podmínka se zeptá jestli už je čas na další snímek a pokud ještě není, tak funkci ukončí
    if (aktualniCas < casPoslednihoSnimku + (1000 / maxFPS)) { // 1000 / maxFPS <= vlastně dělíme 1 sekundu (1000 milisekund) počtem snímků které chceme aby proběhli za sekundu
        // ještě není čas na další snímek, nebudeme dělat nic, jen řekneme prohlížeči aby při dalším překreslení obrazovky opět zavolal funkci mainLoop
        requestAnimationFrame(mainLoop);
        return; // tento příkaz ukončí funkci, další kód uvnitř funkce už se neprovede
    }

    // uložíme si čas posledního snímku
    casPoslednihoSnimku = aktualniCas;

    update();
    draw();
    // řekneme prohlížeči že při dalším překreslení obrazovky má opět zavolat funkci mainloop
    requestAnimationFrame(mainLoop);
}

function update() {
    if (pohybovatDoprava) {
        poziceCtverce += RYCHLOST_POHYBU;
        if (poziceCtverce >= MAX_POZ_CTVERCE) pohybovatDoprava = false;
    } else {
        poziceCtverce -= RYCHLOST_POHYBU;
        if (poziceCtverce <= MIN_POZ_CTVERCE) pohybovatDoprava = true;
    }
}

function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.fillRect(poziceCtverce, 50, 50, 50);
}

Delta Time

V minulém kódu jsme naše Main Loop trochu vylepšili a můžeme si nyní určit kolik snímků by se za sekundu mělo provést. Udělali jsme nějaký pokrok ale i tak ještě naše Main Loop není přesné.

Teď předěláme náš kód tak, aby funkce kterou jsme nazvali update přijímala jako parametr čas uplynulý mezi dvěma snímky. Této hodnotě se říká delta time. Když teď budeme uvnitř update funkce měnit pozici čtverce přičtením nebo odečtením nějaké hodnoty, tak ji nejdříve musíme vynásobit pomocí delta time. Pro ušetření místa na stránce jsou v následující ukázce kódu ukázány jen funkce mainLoop a update. Pokud si kód zkoušíte sami spustit, tak ještě trochu zvyšte rychlost pohybu ať se vám čtverec neposouvá moc pomalu. A můžete si i zvýšit maxFPS třeba na 60.

Pozn.: Pokud v canvasu nevidíte čtverec tak obnovte stránku, protože čtverec odjel moc daleko. To se mohlo stát tím že jste třeba neměli prohlížeč otevřený a prohlížeč tedy přestal vykreslovat obrazovku. Když jste potom prohlížeč otevřeli, tak byl delta time tak velký, že čtverec odjel mimo canvas a v našem příkladu to nemáme nijak ošetřeno.

function mainLoop(aktualniCas) {
    if (aktualniCas < casPoslednihoSnimku + (1000 / maxFPS)) {
        requestAnimationFrame(mainLoop);
        return;
    }

    // určíme si delta time (čas mezi aktuálním a minulým snímkem)
    let delta = aktualniCas - casPoslednihoSnimku;

    casPoslednihoSnimku = aktualniCas;

    // nyní voláme update funkci i s delta time (Převádíme ho na sekundy, ale pokud nechcete tak to dělat nemusíte. Myslím si ale že je komfortnější nastavovat rychlost pohybu za sekundu než za milisekundu.)
    update(delta/1000);
    draw();
    requestAnimationFrame(mainLoop);
}

// update funkce nyní přijímá jako parametr delta time
function update(delta) {
    if (pohybovatDoprava) {
        poziceCtverce += RYCHLOST_POHYBU * delta; // k pohybu čtverce teď musíme rychlost pohybu vynásobit i hodnotou delta time
        if (poziceCtverce >= MAX_POZ_CTVERCE) pohybovatDoprava = false;
    } else {
        poziceCtverce -= RYCHLOST_POHYBU * delta; // k pohybu čtverce teď musíme rychlost pohybu vynásobit i hodnotou delta time
        if (poziceCtverce <= MIN_POZ_CTVERCE) pohybovatDoprava = true;
    }
}

Volání update funkce se stále stejnou hodnotou

Pohybování čtverce po canvasu už teď díky delta time není závislé na počtu snímků za sekundu. U minulé ukázky kódu jsem ale přidal poznámku že delta time občas může být dost velký. U naší jednoduché aplikace kde se pohybuje čtverec tam a zpátky by to zas tak nevadilo, jen bychom si museli trochu upravit update funkci. Pokud ale budeme programovat nějakou hru kde budeme třeba zjišťovat nějaké kolize mezi objekty, tak tam už by nám moc velký delta time mohl vadit.

Zkusíme tedy naše Main Loop poupravit tak, aby se update funkce volala vždy se stejnou hodnotou.

let canvas = document.getElementById("MujCanvas");
let ctx = canvas.getContext("2d");
ctx.fillStyle = "green";

let poziceCtverce = 30;
let pohybovatDoprava = true;

const MIN_POZ_CTVERCE = 30;
const MAX_POZ_CTVERCE = 320;
const RYCHLOST_POHYBU = 100;

let casPoslednihoSnimku = 0;
let maxFPS = 60;

// delta time si teď budeme ukládat do proměnné zde, abychom měli uloženo kolik času máme do update funkce ještě předat
let delta = 0;
// určíme si kolik času chceme posílat do update funkce
let casovyKrok = 1000 / 60;

requestAnimationFrame(mainLoop);

function mainLoop(aktualniCas) {
    if (aktualniCas < casPoslednihoSnimku + (1000 / maxFPS)) {
        requestAnimationFrame(mainLoop);
        return;
    }

    delta += aktualniCas - casPoslednihoSnimku;
    casPoslednihoSnimku = aktualniCas;

    // pokud bychom v naší aplikaci prováděli nějaké operace u kterých by nevadilo že by byl delta time občas velký, tak bychom si třeba mohli vytvořit i begin funkci která se bude volat jen jednou, v našem příkladu to ale nepotřebujeme

    // následující cyklus while bude volat update funkci tak dlouho dokud bude delta čas větší než čas který chceme update funkci předávat
    while (delta >= casovyKrok) {
        // update funkci teď voláme vždy se stejnou hodnotou, takže už se nestane že by musela pracovat s příliš velkým delta časem
        update(casovyKrok/1000);
        delta -= casovyKrok;
    }
    draw();
    requestAnimationFrame(mainLoop);
}

function update(delta) {
    if (pohybovatDoprava) {
        poziceCtverce += RYCHLOST_POHYBU * delta;
        if (poziceCtverce >= MAX_POZ_CTVERCE) pohybovatDoprava = false;
    } else {
        poziceCtverce -= RYCHLOST_POHYBU * delta;
        if (poziceCtverce <= MIN_POZ_CTVERCE) pohybovatDoprava = true;
    }
}

function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.fillRect(poziceCtverce, 50, 50, 50);
}

Spirála Smrti

Možná jste si už mysleli že je naše Main Loop konečně perfektní. Bohužel, ještě tomu tak není. Abychom zamezili předávání update funkci moc velký delta čas, tak jsme v předchozí ukázce kódu volali update funkci vícekrát když to bylo potřeba. Pokud by ale uživatel třeba neměl moc výkonný počítač, tak by se update funkce volala čím dál vícekrát a tím by se nám navíc více a více zvyšoval delta time a nakonec by se nám program zasekal, říká se tomu spirála smrti.

Abychom se spirále smrti vyhnuli, tak budeme počítat kolikrát se update funkce uvnitř while cyklu volala. Pokud počet volání překročí nějaký stanovený limit, tak while cyklus ukončíme a provedeme nějakou akci.

function mainLoop(aktualniCas) {
    if (aktualniCas < casPoslednihoSnimku + (1000 / maxFPS)) {
        requestAnimationFrame(mainLoop);
        return;
    }

    delta += aktualniCas - casPoslednihoSnimku;
    casPoslednihoSnimku = aktualniCas;

    // nyní budeme navíc počítat kolikrát se update funkce zavolala
    let pocetUpdateVolani = 0;
    while (delta >= casovyKrok) {
        update(casovyKrok/1000);
        delta -= casovyKrok;

        pocetUpdateVolani++;
        // pokud počet update volání překročí limit který jsme si stanovili, tak zavoláme funkci panic a ukončíme while cyklus
        if (pocetUpdateVolani > 240) {
            panic();
            break; // tento příkaz ukončí while cyklus
        }
    }
    draw();
    requestAnimationFrame(mainLoop);
}

function panic() {
    // v našem případě při překročení maximálního limitu volání update funkce jen vynulujeme delta time
    delta = 0;
}

Interpolace vykreslování

Pokud bychom zjistili že se nám hra občas nevykresluje úplně hladce (občas se nám něco ve hře posune o něco víc, někdy o něco míň), tak by možná pomohlo interpolovat vykreslování. V delta proměnné většinou po ukončení volání update funkce zbyde nějaký čas, který je menší než čas který se předává do update funkce. Předání procenta času (delta / čas který se předává update funkci) draw funkci nám umožní interpolovat vykreslování.

Toto už je jen taková věc která nám může hru trochu vylepšit z vizuální stránky, není to ale úplně potřeba. Pokud interpolaci vykreslování použijeme, tak musíme brát v potaz to, že naše draw funkce bude o jedno volání update funkce pozadu.

let canvas = document.getElementById("MujCanvas");
let ctx = canvas.getContext("2d");
ctx.fillStyle = "green";

// nyní si musíme ukládat poslední pozici čtverce protože ji potřebujeme při vykreslování
let posledniPoziceCtverce = 30;
let poziceCtverce = 30;
let pohybovatDoprava = true;

const MIN_POZ_CTVERCE = 30;
const MAX_POZ_CTVERCE = 320;
const RYCHLOST_POHYBU = 100;

let casPoslednihoSnimku = 0;
let maxFPS = 60;

let delta = 0;
let casovyKrok = 1000 / 60;

requestAnimationFrame(mainLoop);

function mainLoop(aktualniCas) {
    if (aktualniCas < casPoslednihoSnimku + (1000 / maxFPS)) {
        requestAnimationFrame(mainLoop);
        return;
    }

    delta += aktualniCas - casPoslednihoSnimku;
    casPoslednihoSnimku = aktualniCas;

    let pocetUpdateVolani = 0;
    while (delta >= casovyKrok) {
        update(casovyKrok/1000);
        delta -= casovyKrok;

        pocetUpdateVolani++;
        if (pocetUpdateVolani > 240) {
            panic();
            break;
        }
    }

    // nyní předáváme draw funkci procento času z jednoho časového kroku který ještě nebyl vykonán
    draw(delta/casovyKrok);
    requestAnimationFrame(mainLoop);
}

function panic() {
    delta = 0;
}

function update(delta) {
    // předtím než pozici čtverce změníme si ji uložíme abychom ji mohli použít při vykreslování
    posledniPoziceCtverce = poziceCtverce;

    if (pohybovatDoprava) {
        poziceCtverce += RYCHLOST_POHYBU * delta;
        if (poziceCtverce >= MAX_POZ_CTVERCE) pohybovatDoprava = false;
    } else {
        poziceCtverce -= RYCHLOST_POHYBU * delta;
        if (poziceCtverce <= MIN_POZ_CTVERCE) pohybovatDoprava = true;
    }
}

// naše draw funkce nyní přijímá jako parametr procento, které bude používat k vykreslování čtverce
function draw(interpPercentage) {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    // vykreslíme čtverec na pozici: předešlá pozice + (aktuální pozice - předešlá pozice) * interpPercentage
    ctx.fillRect(posledniPoziceCtverce - (poziceCtverce - posledniPoziceCtverce) * interpPercentage, 50, 50, 50);
}

Kvíz

Dostali jste se na konec části, která byla zaměřena na Main Loop. Snad jste pochopili jak Main Loop funguje a pokud ne, tak jste se alespoň dozvěděli k čemu slouží. Jak už jsem psal na začátku této části, pokud si nechcete programovat svoje vlastní Main Loop, tak si ho můžete stáhnout odsud: A Detailed Explanation of JavaScript Game Loops and Timing. Na závěr je tu pro vás ještě kvíz.

Some question