Programování jednoduché hry v čistém HTML a Javascriptu

Původní příspěvek na https://siderite.dev/blog/programming-simple-game-in-pure-html-javascript.

Kód pro tuto sérii příspěvků lze nalézt na https://github.com/Siderite/ Doplňkové

Pomáhal jsem kamarádovi se základním programováním a uvědomil jsem si, že jsem tak uchvácen nejnovějšími výstřelky a vývojovými technikami, že jsem zapomněl na jednoduché programování, pro zábavu, pouze se základními principy a nástroji, které jsou k dispozici „mimo box". Tento příspěvek mi bude demonstrovat, jak jsem si pokazil psaní hry pouze pomocí HTML a Javascriptu.

Mise en place

Tato francouzská fráze se v profesionálním vaření používá k označení přípravy ingrediencí a náčiní před zahájením vlastního vaření. Před zahájením vývoje naší hry to budeme potřebovat:

  • popis:hra zobrazí barvu a hráč si musí vybrat z nabídky dalších barev tu, která se doplňuje
    • dvě barvy se doplňují, pokud se při smíchání vzájemně ruší, což vede k „barvě“ ve stupních šedi, jako je bílá, černá nebo nějaký odstín šedé. Počkejte! Byla to metafora v Padesáti odstínech šedi?
  • technologický zásobník:HTML, Javascript, CSS
    • Příchuť Javascriptu:ECMAScript 2015 (také známý jako ES6)
    • používání modulů:ne – to by bylo hezké, ale moduly poslouchají CORS, takže je nebudete moci spustit pomocí prohlížeče z místního souborového systému.
    • testování jednotky:ano, ale musíme to udělat co nejjednodušeji (bez externích knihoven)
  • IDE pro vývoj:Visual Studio Code
    • je to zdarma a pokud se vám to nelíbí, můžete ke stejnému výsledku použít Poznámkový blok
  • ovládání zdroje:Git (na GitHubu)

Instalace kódu Visual Studio

Instalace kódu VS je stejně jednoduchá jako stažení instalačního programu a jeho spuštění.

Poté vyberte možnost Otevřít složku, vytvořte složku projektu (říkejme jí doplňková) a klikněte na Vybrat složku.

Vanilla instalace vám pomůže se zvýrazňováním syntaxe, doplňováním kódu, formátováním kódu.

Struktura projektu

Pro začátek budeme potřebovat následující soubory:

  • complementary.html – skutečná stránka, kterou prohlížeč otevře
  • complementary.js – kód Javascript
  • complementary.css – šablona stylů CSS

Další soubory budou přidány později, ale toto je nejzákladnější oddělení problémů:kód a data v souboru .js, struktura v .html a prezentace v .css.

Začíná se kódovat

Nejprve propojme tři soubory dohromady napsáním nejjednodušší struktury HTML:

<html>
    <head>
        <link rel="stylesheet" href="complementary.css"/>
        <script src="complementary.js"></script>
    </head>
    <body>

    </body>
</html>

To dává prohlížeči pokyn, aby načetl soubory CSS a JS.

V souboru Javascript zapouzdřujeme logiku do třídy Game:

"use strict";
class Game {
  init(doc) {
    this._document = doc;
    this._document.addEventListener('DOMContentLoaded',this.onLoad.bind(this),false);
  }
  onLoad() {

  }
}

const game=new Game();
game.init(document);

Deklarovali jsme třídu (nový koncept v Javascriptu ES6) a metodu zvanou init, která přijímá doc. Myšlenka je taková, že po načtení skriptu se vytvoří nová hra a inicializační funkce obdrží aktuální dokument, aby mohla interagovat s uživatelským rozhraním. Událost DOMContentLoaded jsme použili k volání onLoad pouze tehdy, když byl model objektu dokumentu stránky (DOM) zcela načten, jinak by se skript spustil před načtením prvků.

Také ne použití metody vazby na funkci. addEventListener očekává funkci jako obsluhu události. Pokud zadáme pouze this.onLoad, spustí funkci, ale s this kontext události, což by bylo okno, nikoli náš herní objekt. this.onLoad.bind(this), na druhé straně, je funkce, která bude spuštěna v kontextu naší hry.

Nyní se podívejme, jak chceme hru hrát:

  • musí být zobrazena vodicí barva
    • to znamená, že barvu je třeba vygenerovat
  • musí být zobrazen seznam barev, ze kterých si můžete vybrat
    • je třeba vygenerovat barvy
    • jedna barva musí být doplňková k barvě vodítka
    • barevné prvky musí reagovat na kliknutí myší
  • výsledek musí být vypočten ze zvolené barvy
    • musí být zobrazen výsledek volby uživatele
    • bude nutné vypočítat skóre

Tím získáme strukturu uživatelského rozhraní hry. Dodejme:

  • vodící prvek
  • prvek seznamu voleb
  • prvek skóre
<html>
    <head>
        <link rel="stylesheet" href="complementary.css"/>
        <script type="module" src="complementary.js"></script>
    </head>
    <body>
        <div id="guideColor"></div>
        <div id="choiceColors"></div>
        <div id="score"></div>
    </body>
</html>

Všimněte si, že si nemusíme vybírat, jak budou vypadat (to je CSS) nebo co dělají (to je JS).

Jedná se o přístup shora dolů, počínaje očekáváním uživatelů a poté vyplňováním dalších a dalších podrobností, dokud vše nevyjde.

Pojďme napsat logiku hry. Nebudu to příliš rozebírat, protože je to docela zřejmé a tento příspěvek je o struktuře a vývoji, ne o hře samotné.

"use strict";
class Game {
    constructor() {
        // how many color choices to have
        this._numberOfChoices = 5;
        // the list of user scores
        this._log = [];
    }
    init(doc) {
        this._document = doc;
        this._document.addEventListener('DOMContentLoaded', this.onLoad.bind(this), false);
    }
    onLoad() {
        this._guide = this._document.getElementById('guideColor');
        this._choices = this._document.getElementById('choiceColors');
        // one click event on the parent, but event.target contains the exact element that was clicked
        this._choices.addEventListener('click', this.onChoiceClick.bind(this), false);
        this._score = this._document.getElementById('score');
        this.startRound();
    }
    startRound() {
        // all game logic works with numeric data
        const guideColor = this.randomColor();
        this._roundData = {
            guideColor: guideColor,
            choiceColors: this.generateChoices(guideColor),
            tries: new Set()
        };
        // only this method transforms the data into visuals
        this.refreshUI();
    }
    randomColor() {
        return Math.round(Math.random() * 0xFFFFFF);
    }
    generateChoices(guideColor) {
        const complementaryColor = 0xFFFFFF - guideColor;
        const index = Math.floor(Math.random() * this._numberOfChoices);
        const choices = [];
        for (let i = 0; i < this._numberOfChoices; i++) {
            choices.push(i == index
                ? complementaryColor
                : this.randomColor());
        }
        return choices;
    }
    refreshUI() {
        this._guide.style.backgroundColor = '#' + this._roundData.guideColor.toString(16).padStart(6, '0');
        while (this._choices.firstChild) {
            this._choices.removeChild(this._choices.firstChild);
        }
        for (let i = 0; i < this._roundData.choiceColors.length; i++) {
            const color = this._roundData.choiceColors[i];
            const elem = this._document.createElement('span');
            elem.style.backgroundColor = '#' + color.toString(16).padStart(6, '0');
            elem.setAttribute('data-index', i);
            this._choices.appendChild(elem);
        }
        while (this._score.firstChild) {
            this._score.removeChild(this._score.firstChild);
        }
        const threshold = 50;
        for (let i = this._log.length - 1; i >= 0; i--) {
            const value = this._log[i];
            const elem = this._document.createElement('span');

            elem.className = value >= threshold
                ? 'good'
                : 'bad';
            elem.innerText = value;
            this._score.appendChild(elem);
        }
    }
    onChoiceClick(ev) {
        const elem = ev.target;
        const index = elem.getAttribute('data-index');
        // just a regular expression test that the attribute value is actually a number
        if (!/^\d+$/.test(index)) {
            return;
        }
        const result = this.score(+index);
        elem.setAttribute('data-result', result);
    }
    score(index) {
        const expectedColor = 0xFFFFFF - this._roundData.guideColor;
        const isCorrect = this._roundData.choiceColors[index] == expectedColor;
        if (!isCorrect) {
            this._roundData.tries.add(index);
        }
        if (isCorrect || this._roundData.tries.size >= this._numberOfChoices - 1) {
            const score = 1 / Math.pow(2, this._roundData.tries.size);
            this._log.push(Math.round(100 * score));
            this.startRound();
        }
        return isCorrect;
    }
}

const game = new Game();
game.init(document);

Funguje to, ale má to několik problémů, včetně příliš mnoha odpovědností (zobrazení, logika, zpracování kliknutí, generování barevných řetězců z čísel atd.).

A když už máme logiku a strukturu, displej ponechává mnoho přání. Nejprve to napravíme (jsem hrozný s designem, takže výsledek sem hodím a pro čtenáře bude domácí úkol zlepšit vizuál).

Nejprve přidám nový div, který bude obsahovat tři další. Mohl bych pracovat přímo s tělem, ale bylo by to ošklivé:

<html>

<head>
    <link rel="stylesheet" href="complementary.css" />
    <script src="complementary.js"></script>
</head>

<body>
    <div class="board">
        <div id="guideColor"></div>
        <div id="choiceColors"></div>
        <div id="score"></div>
    </div>
</body>

</html>

Poté vyplňte CSS:

body {
    width: 100vw;
    height: 100vh;
    margin: 0;
}
.board {
    width:100%;
    height:100%;
    display: grid;
    grid-template-columns: 50% 50%;
    grid-template-rows: min-content auto;
}
#score {
    grid-column-start: 1;
    grid-column-end: 3;
    grid-row: 1;
    display: flex;
    flex-direction: row;
    flex-wrap: nowrap;
}
#score span {
    display: inline-block;
    padding: 1rem;
    border-radius: 0.5rem;
    background-color: darkgray;
    margin-left: 2px;
}
#score span.good {
    background-color: darkgreen;
}
#score span.bad {
    background-color: red;
}
#guideColor {
    grid-column: 1;
    grid-row: 2;
}
#choiceColors {
    grid-column: 2;
    grid-row: 2;
    display: flex;
    flex-direction: column;
}
#choiceColors span {
    flex-grow: 1;
    cursor: pointer;
}
#choiceColors span[data-result=false] {
    opacity: 0.3;
}

Použil jsem hodně flexu a mřížky k zobrazení věcí.

Hra by nyní měla provést následující:

  • zobrazí barvu levé strany
  • na pravé straně zobrazí pět řad různých barev
  • kliknutím na kteroukoli z nich se změní skóre (každá špatná volba sníží maximální skóre na polovinu)
  • když už nezbývají žádné tahy nebo se klikne na správnou volbu, skóre se přidá do seznamu v horní části hrací desky
  • Skóre jsou buď zelené (skóre>=50) nebo červené

Jsem však nespokojen s kódem Javascript. Pokud má Hra příliš mnoho povinností, je to znamení, že je třeba vytvořit nové třídy.

Refaktoring kódu

Nejprve zapouzdřím veškerou logiku barev do třídy Color.

class Color {
    constructor(value = 0 /* black */) {
        this._value = value;
    }
    toString() {
        return '#' + this._value.toString(16).padStart(6, '0');
    }
    complement() {
        return new Color(0xFFFFFF - this._value);
    }
    equals(anotherColor) {
        return this._value === anotherColor._value;
    }
    static random() {
        return new Color(Math.round(Math.random() * 0xFFFFFF));
    }
}

To zjednodušuje třídu Game takto:

class Game {
    constructor() {
        // how many color choices to have
        this._numberOfChoices = 5;
        // the list of user scores
        this._log = [];
    }
    init(doc) {
        this._document = doc;
        this._document.addEventListener('DOMContentLoaded', this.onLoad.bind(this), false);
    }
    onLoad() {
        this._guide = this._document.getElementById('guideColor');
        this._choices = this._document.getElementById('choiceColors');
        // one click event on the parent, but event.target contains the exact element that was clicked
        this._choices.addEventListener('click', this.onChoiceClick.bind(this), false);
        this._score = this._document.getElementById('score');
        this.startRound();
    }
    startRound() {
        // all game logic works with numeric data
        const guideColor = Color.random();
        this._roundData = {
            guideColor: guideColor,
            choiceColors: this.generateChoices(guideColor),
            tries: new Set()
        };
        // only this method transforms the data into visuals
        this.refreshUI();
    }
    generateChoices(guideColor) {
        const complementaryColor = guideColor.complement();
        const index = Math.floor(Math.random() * this._numberOfChoices);
        const choices = [];
        for (let i = 0; i < this._numberOfChoices; i++) {
            choices.push(i == index
                ? complementaryColor
                : Color.random());
        }
        return choices;
    }
    refreshUI() {
        this._guide.style.backgroundColor = this._roundData.guideColor.toString();
        while (this._choices.firstChild) {
            this._choices.removeChild(this._choices.firstChild);
        }
        for (let i = 0; i < this._roundData.choiceColors.length; i++) {
            const color = this._roundData.choiceColors[i];
            const elem = this._document.createElement('span');
            elem.style.backgroundColor = color.toString();
            elem.setAttribute('data-index', i);
            this._choices.appendChild(elem);
        }
        while (this._score.firstChild) {
            this._score.removeChild(this._score.firstChild);
        }
        const threshold = 50;
        for (let i = this._log.length - 1; i >= 0; i--) {
            const value = this._log[i];
            const elem = this._document.createElement('span');

            elem.className = value >= threshold
                ? 'good'
                : 'bad';
            elem.innerText = value;
            this._score.appendChild(elem);
        }
    }
    onChoiceClick(ev) {
        const elem = ev.target;
        const index = elem.getAttribute('data-index');
        // just a regular expression test that the attribute value is actually a number
        if (!/^\d+$/.test(index)) {
            return;
        }
        const result = this.score(+index);
        elem.setAttribute('data-result', result);
    }
    score(index) {
        const expectedColor = this._roundData.guideColor.complement();
        const isCorrect = this._roundData.choiceColors[index].equals(expectedColor);
        if (!isCorrect) {
            this._roundData.tries.add(index);
        }
        if (isCorrect || this._roundData.tries.size >= this._numberOfChoices - 1) {
            const score = 1 / Math.pow(2, this._roundData.tries.size);
            this._log.push(Math.round(100 * score));
            this.startRound();
        }
        return isCorrect;
    }
}

Ale pořád to nestačí. Hra stále dělá spoustu věcí v uživatelském rozhraní. Můžeme to opravit? Ano, s vlastními prvky HTML!

Zde je kód. Vypadá to podrobně, ale to, co dělá, je zcela zapouzdřit logiku uživatelského rozhraní do prvků uživatelského rozhraní:

class GuideColor extends HTMLElement {
    set color(value) {
        this.style.backgroundColor = value.toString();
    }
}

class ChoiceColors extends HTMLElement {
    connectedCallback() {
        this._clickHandler = this.onChoiceClick.bind(this);
        this.addEventListener('click', this._clickHandler, false);
    }
    disconnectedCallback() {
        this.removeEventListener('click', this._clickHandler, false);
    }
    onChoiceClick(ev) {
        const elem = ev.target;
        if (!(elem instanceof ChoiceColor)) {
            return;
        }
        const result = this._choiceHandler(elem.choiceIndex);
        elem.choiceResult = result;
    }
    setChoiceHandler(handler) {
        this._choiceHandler = handler;
    }
    set colors(value) {
        while (this.firstChild) {
            this.removeChild(this.firstChild);
        }
        for (let i = 0; i < value.length; i++) {
            const color = value[i];
            const elem = new ChoiceColor(color, i);
            this.appendChild(elem);
        }
    }
}

class ChoiceColor extends HTMLElement {
    constructor(color, index) {
        super();
        this.color = color;
        this.choiceIndex = index;
    }
    get choiceIndex() {
        return +this.getAttribute('data-index');
    }
    set choiceIndex(value) {
        this.setAttribute('data-index', value);
    }
    set choiceResult(value) {
        this.setAttribute('data-result', value);
    }
    set color(value) {
        this.style.backgroundColor = value.toString();
    }
}

class Scores extends HTMLElement {
    set scores(log) {
        while (this.firstChild) {
            this.removeChild(this.firstChild);
        }
        for (let i = log.length - 1; i >= 0; i--) {
            const value = log[i];
            const elem = new Score(value);
            this.appendChild(elem);
        }
    }
}

class Score extends HTMLElement {
    constructor(value) {
        super();
        this.innerText = value;
        this.className = value > 50
            ? 'good'
            : 'bad';
    }
}

class Board extends HTMLElement {
    constructor() {
        super();
        this._guide = new GuideColor();
        this._choices = new ChoiceColors();
        this._score = new Scores();
    }
    connectedCallback() {
        this.appendChild(this._guide);
        this.appendChild(this._choices);
        this.appendChild(this._score);
    }
    setChoiceHandler(handler) {
        this._choices.setChoiceHandler(handler);
    }
    set guideColor(value) {
        this._guide.color = value;
    }
    set choiceColors(value) {
        this._choices.colors = value;
    }
    set scores(value) {
        this._score.scores = value;
    }
}

window.customElements.define('complementary-board', Board);
window.customElements.define('complementary-guide-color', GuideColor);
window.customElements.define('complementary-choice-colors', ChoiceColors);
window.customElements.define('complementary-choice-color', ChoiceColor);
window.customElements.define('complementary-scores', Scores);
window.customElements.define('complementary-score', Score);

Tím se HTML stane:

<html>

<head>
    <link rel="stylesheet" href="complementary.css" />
    <script src="complementary.js"></script>
</head>

<body>
    <complementary-board>
    </complementary-board>
</html>

a CSS:

body {
    width: 100vw;
    height: 100vh;
    margin: 0;
}
complementary-board {
    width:100%;
    height:100%;
    display: grid;
    grid-template-columns: 50% 50%;
    grid-template-rows: min-content auto;
}
complementary-scores {
    grid-column-start: 1;
    grid-column-end: 3;
    grid-row: 1;
    display: flex;
    flex-direction: row;
    flex-wrap: nowrap;
}
complementary-score {
    display: inline-block;
    padding: 1rem;
    border-radius: 0.5rem;
    background-color: darkgray;
    margin-left: 2px;
}
complementary-score.good {
    background-color: darkgreen;
}
complementary-score.bad {
    background-color: red;
}
complementary-guide-color {
    grid-column: 1;
    grid-row: 2;
}
complementary-choice-colors {
    grid-column: 2;
    grid-row: 2;
    display: flex;
    flex-direction: column;
}
complementary-choice-color {
    flex-grow: 1;
    cursor: pointer;
}
complementary-choice-color[data-result=false] {
    opacity: 0.3;
}

Další

V dalších příspěvcích na blogu uvidíme, jak můžeme otestovat náš kód (nejprve jej musíme lépe testovat!) a jak můžeme použít Git jako ovládací prvek zdroje. Konečně bychom měli mít funkční hru, kterou lze snadno nezávisle upravovat:vizuální design, pracovní kód, strukturální prvky.

  • Přidání projektu do ovládání zdroje (GitHub a VS Code)
  • Jednotkové testování doplňkové hry