HTML5 Game Tutorial: Wzorzec obiektowy

Endless Runner style game - Kandi Runner

Właśnie skończyłem pisać grę Endless Runner dla Gamedevtuts+ do artykułu Spritesheet Animation. Łaskawie pozwolili mi napisać tutorial o tym jak to zrobiłem. Ten artykł↓ jest pierwszym z trzech które wyjaśnią jak stworzyć naszą grę od zera.


Kod: https://github.com/straker/endless-runner-html5-game
Tłumaczenie: Kamil “Eluzive” Pyszczek

W tym tutorialu nauczę Cię jak używać techniki znanej jako wzorzec obiektowy aby stworzyć obiekty naszej gry i zorganizowaną strukturę naszego kodu. Na początek zobaczmy jak wygląda finalny produkt który stworzysz. Kliknij na okienko gry jeśli sterowanie nie działa.

Sterowanie: Skakanie – spacja

Jak widzisz jest to pełnoprawna gra (menu, sama gra, restart, itd.). Jest też bardziej wymuskana od mojej poprzedniej gry na potrzeby tutorialu. Muzyka nie gra automatycznie co też jest plusem – jest domyślnie wyłączona ale użytkownik w każdej chwili może ją włączyć jeśli tylko będzie miał taką ochotę.

Zaczniemy od stworzenia naszej strony HTML i CSS.

index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Kandi Runner</title>
<link rel="stylesheet" href="kandi.css" type="text/css" media="screen">
</head>
<body>
<div class="wrapper">
<canvas id="canvas" width="800" height="480">
<p>You're browser does not support the required functionality to play this game.</p>
<p>Please update to a modern browser such as <a href="www.google.com/chrome/‎">Google Chrome</a> to play.</p>
</canvas>
</div>
<script type="text/javascript" src="kandi.js"></script>
</body>
</html>

Nic specjalnego się tu nie dzieje. Ładujemy kod JavaScript i CSS, tworzymy naszego canvasa.

kandi.css
body {
font-family: arial, sans-serif;
font-size: 16px;
}

.wrapper {
width: 600px;
height: 360px;
position: absolute;
top: 5px;
left: 5px;
}

canvas {
position: absolute;
top: 0;
left: 0;
border: 1px solid black;
z-index: 1;
width: 600px;
height: 360px;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}

Umieszczamy naszą grę wewnątrz diva wrapper i ustawienie na 5px od góry i lewej. Właściwość box-sizing pozwala nam na dodanie 1px obramowania bez zabierania dodatkowych 2px z wyznaczonego obszaru.

Możesz zauważyć, że podczas tworzenia canvasu w HTML zadeklarowaliśmy 800×480, w CSS ustawiliśmy 600×360. Oryginalnie gra zaprojektowana została na 800×480, ale chciałem zawrzeć ją w artykule który ma jedynie 650px;

Zamiast przeskalowywać wszystkie grafiki i ustawiać limit gry na 600px. Użyłem skalowania w CSS. Dzięki użyciu styli przy zmianie szerokości lub wysokości.
CSS używa proporcjonalnego skalowania nie tracąc przy tym na jakości obrazków. To fajna sztuczka jeśli potrzebujesz mniejszej wersji gry.

Zanim zaczniemy rozmawiać o JavaScriptowej stronie tego wszystkiego, musimy pomówić o kilku wzorcach w JS: natychmiastowe wywołanie, obiektowe podejście, zamknięcia.

Kiedy pisałem mój pierwszy artykuł, użyłem idei która zmyliła kilku czytających tworząc singleton. Początkowo myślałem, że new function() będzie dobre, ale później zrozumiałem, że obrałem złą drogę.

Bardziej zgodną ze standardami opcją do stworzenia singleton w JavaScriptcie jest coś co nazywa się IIFE (immediately-invoked function expression).

Jest wiele nazw: immediately-invoked function expression (IIFE), self-invoking function, i self-executing anonymous function. Nie jestem pewien dlaczego IIFE ma tyle nazw ale w społeczności JavaScript przyjęło się używać immediately-invoked function expression.

Niezależnie od nazwy istotą IIFE jest tworzenie bardziej zamkniętych (o tym więcej potem) struktur gdzie wszystkie zmienne i funkcję znajdują się wewnątrz IIFE i nie są widoczne poza tym zakresem.

Deklaracja IIFE polega na otoczeniu funkcji nawiasami i wykonaniu jej.

(function() {
var hidden = "You can't see me.";
})();

console.log(hidden); // ReferenceError: hidden is not defined

Poprzez umieszczenie funkcji wewnątrz nawiasów JavaScript zmienia deklaracje funkcji w wyrażenie. Kiedy chcesz wywołać wyrażenie robisz to podając dwa nawiasy na końcu. Wyrażenie zostanie wykonane natychmiastowo. To właśnie jest idea IIFE.

Kiedy IIFE zwraca obiekt tworzony jest singleton.

var singleton = (function() {
var visible = "Now you can see me.";

return {
visible : visible
}
})();

console.log(singleton.visible); // "Now you can see me."

Prawdopodobnie znasz już trochę ten wzorzec jeśli używałeś Paul Irish’s request animation polyfill (którego powinieneś używać).

var requestAnimFrame = (function(){
return window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.oRequestAnimationFrame ||
window.msRequestAnimationFrame ||
function(callback, element){
window.setTimeout(callback, 1000 / 60);
};
})();

Technika polegająca na zwracaniu właściwości z IIFE nazywana jest wzorcem obiektowym (ang. module pattern). Wzorzec ten nazywany jest eksportowaniem obiektów (ang. module exports), wskutek czeo dostępne są na zewnątrz IIFE.

Nie musimy tylko eksportować właściwości na zewnątrz IIFE. Możzemy także dosatrczać je do środka, tak jak zwykłe zmienne do funkcji.

(function($) {
// IIFE ma dostep do jQuery poprzez "$"
})(jQuery);

Najlepszą częścią importowania obiektów jest fakt, że stan zmiennej jest zapisywany przy przekazaniu jej do IIFE. Będzie to ważne później lub kiedy będziemy omawwiać loader obiektów.

IIFE jest ściśle powiązany z closures, są to proste funkcje zagnieżdżone w innych Funkcja z zewnątrz nie może dostać się do zmiennych wewnętrznych innej funkcji. Ale funkcją wewnętrzna ma dostęp do wszystkich zmiennych na zewnątrz. Innymi słowy JavaScript wykorzystuje closures do wzorca OOP (programowanie zorientowane obiektowo).

Możesz zobaczyć jak działają colsures jeśli użyjesz konstruktora.

function Greeting() {
var saying = "hello";

this.sayHello = function() { // JavaScript closure
var name = "Bob";

console.log(saying + " " + name);
}

this.sayGoodbye = function() {
// Ta funkcja nie ma dostepu do zmiennej name z poprzedniej funkcji
console.log("goodbye '" + name + "'");
}
}

greet = new Greeting();
greet.sayHello(); // "hello Bob"
greet.sayGoodbye(); // "goodbye ""

Ponieważ zmienna name została zdeklarowana (i zdefiniowana) wewnątrz closure, metoda sayGoodbye nie mogła uzyskać do niej dostępu Może wydawać się to oczywiste i takie jest, ale jest jedna bardzo ważna rezcz do zapamiętania kiedy mamy do czynienia z closure. Zachowuja one this w odniesieniu do zasięgu w jakim zostały zdefiniowane (stworzone).

Najlepiej wytłumaczy to przykład. Przepuśćmy, że masz funckję konstruktora która ma publiczne zmienne. Mam też prywatną funkcję pomocniczą do robienia na nich najróżniejszych rzeczy i ta prywatna funkcja wykorzystywana jest przez funkcję publiczną.

function Example() {
this.base = 10; // public variable

function isEven() { // private helper function
return this.base % 2 === 0;
}

this.calculate = function() { // public function
if (isEven()) {
console.log("even");
}
else {
console.log("odd");
}
}
}

e = new Example();
e.calculate(); // "odd"

W tym prostym przykładzie spodziewalibyśmy się ‘even’ z this.calculate() ale zamiast tego mamy ‘odd’. Dlaczego? Oczywiście 10 to też liczba parzysta więc czemu myśli, że cos innego?

Sedno w tym, że isEven() jest closure i odkąd była zadeklarowana do czasu wywołania this zmieniło punkt odniesienia i nie wskazuje już na Example. Więc jeśli this nie wskazuje na Example to this.base nie istnieje w closure. Dlatego isEven() zwraca false dla undefined % 2 === false.

Są trzy metody na ominięcie tego problemu. Pierwszą jest stworzenie prywatnego odniesienia dla this i używania go wewnatrz closure nadal jako odniesienia dla tego samego this:

function Example() {
var _this = this;
this.base = 10;

function isEven() {
return _this.base % 2 === 0; // To zwróci true
}

// …
}

Inną metodą jest wywołanie funkcj i call żeby odnieść się do właściwego this.

function Example() {
// …

this.calculate = function() {
if (isEven.call(this)) // To zwróci true
// …
}
}

Ostatnim sposobem jest użycie bind dla uzyskania odpowiedniego zasięgu.

function Example() {
this.base = 10;

function isEven() {
return this.base % 2 === 0;
}

var isEven = isEven.bind(this); // To zwróci true

// …
}

Nie znalazłem nic na temat tego czy któraś z tych metod jest bardziej poprawna od innych, ale prywatnie preferuje tworzenia prywatnego odnośnika dla this.

Kiedy omówilismy już podstawy IIFE i closure jesteśmy w stanie napisać podstawy naszej gry.

kandi.js
(function () {
// Zdefiniowanie zmiennych
var canvas = document.getElementById("canvas");
var ctx = canvas.getContext("2d");
var player = {};
var ground = [];
var platformWidth = 32;
var platformHeight = canvas.height - platformWidth * 4;

/**
* Request Animation Polyfill
*/
var requestAnimFrame = (function(){
return window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.oRequestAnimationFrame ||
window.msRequestAnimationFrame ||
function(callback, element){
window.setTimeout(callback, 1000 / 60);
};
})();

/**
* Pre-loader dla obiektów gry. Ładuje wszystkie obrazki i dźwięki.
*/
var assetLoader = (function() {
// …
})();
})();

Pierwszą rzeczą jaką robimy jest wrzucenie naszego kodu do IIFE. To bardzo popularny zabieg który zapobiega zajmowaniu przez te zmienne i funkcje globalnej przestrzenii – potencjalny problem w przyszłości.

Następnie pobieramy nasz canvas i context do niego żeby móc ustawić jakieś domyslne wartości na przykład gdzie mają być platformy (4 wiersze od dołu). Tworzymy też request animation polyfill dla naszych animacji. Następnie tworzymy funkcje ładującą obiekty do gry.

Podstawowym zadaniem dla naszego modułu ładującego jest stworzenie obiektu który będzie trzymał wszystkie obiekty Image i Audio żebyśmy mogli odwołać się do nich kiedy będziemy chcieli. Po drugie wiemy też kiedy wszystkie elementy gry zostaną załadowane i kiedy możemy rozpocząć grę. Jeśli, dla przykładu, spróbujemy narysować obraz na canvasie zanim zostanie on załadowany, nic nie zostanie narysowane.

Moja pierwsza próba napisania takiego modułu nie była zbyt dobra. Wiele kodu się powtarzała co czyniło pracę z nim niechlujną. Od tego czasu wpadłem na pomysł który znacząco uprościł sprawę.

kandi.js
/**
* Pre-loader. Ładowanie wszystkich obrazków
*/
var assetLoader = (function() {
// Słownik obrazków
this.imgs = {
"bg" : "imgs/bg.png",
"sky" : "imgs/sky.png",
"backdrop" : "imgs/backdrop.png",
"backdrop2" : "imgs/backdrop_ground.png",
"grass" : "imgs/grass.png",
"avatar_normal" : "imgs/normal_walk.png"
};

var assetsLoaded = 0; // Jak wiele obiektów ma być załadowanych
var numImgs = Object.keys(this.imgs).length; // Maksymalna liczba zdjec
this.totalAssest = numImgs; // Maksymalna liczba obiektów

/**
* Upewnienie się, że wszystkie obiekty zostały załadowane przed użyciem.
* @param {number} dic - Słownik nazw ('imgs')
* @param {number} name - Nazwa obiektu w słowniku
*/
function assetLoaded(dic, name) {
// Nie liczy załadowanych obiektów
if (this[dic][name].status !== "loading" ) {
return;
}

this[dic][name].status = "loaded";
assetsLoaded++;

// finished callback
if (assetsLoaded === this.totalAssest && typeof this.finished === "function") {
this.finished();
}
}

/**
* Tworzenie obiektów, ustawianie callbacku dla ładowanych, ustawienie src
*/
this.downloadAll = function() {
var _this = this;
var src;

// Ładowanie obrazków
for (var img in this.imgs) {
if (this.imgs.hasOwnProperty(img)) {
src = this.imgs[img];

// Tworzenie closure dla powiązania zdarzeń
(function(_this, img) {
_this.imgs[img] = new Image();
_this.imgs[img].status = "loading";
_this.imgs[img].name = img;
_this.imgs[img].onload = function() { assetLoaded.call(_this, "imgs", img) };
_this.imgs[img].src = src;
})(_this, img);
}
}
}

return {
imgs: this.imgs,
totalAssest: this.totalAssest,
downloadAll: this.downloadAll
};
})();

assetLoader.finished = function() {
startGame();
}

Dużo się tutaj dzieje, więc omówmy wszystko po kolei.

Na początku tworzymy obiekt będący listą wszystkich obiektów do załadowania razem z ścieżkami do nich. Następnie liczymy ile mamy tam obrazków za pomocą Object.keys(). Dzięki temu wiemy ile obiektów mamy załadować przed rozpoczęciem gry.

Następnie tworzymy funkcję assetLoaded() which takes the name of a dictionary (since we’re only loading images there is only one dictionary)będącą callbackiem która ma nazwy ze słownika (od teraz wszystkie ładowane zdjęcia, mają jeden słownik), nazwy te należą do obiektów do załadowania. Funkcja oznacza który obiekt jest załadowany i inkrementuje zmienną assetsLoaded. Na koniec kiedy już wszystkie obiekty zostaną załadowane, wywołana zostaje funkcja this.finished() , jeśli jest zdefiniowana. Poprzez zdefiniowanie tej funkcji wiemy, że wszystkie obiekty zostały załadowane.

Następnie tworzymy funckję która rozpoczyna proces ściagania. Pętla tej funkcji bierze każdy obrazek do załadowania i tworzy z niego obiekt Image, oznacza jako ładujący się i nadaje mu callback w przypadku kiedy całkowicie się już załaduje a także nadaje im src.

Zauważ, że proces zachodzi wewnątrz IIFE. Jest to ważne, gdyż przypisujemy callback którego będzie używała zmienna img. Jeśli nie włożymy tego do IIFE,onload() zawsze będzie zwracał ostatni z obrazków w imgs.

for (var i = 0; i < 10; i++) {
setTimeout(function() { console.log(i); }, 1000); // Wypisanie "10" dziesięć razy
}

Dzieje się tak gdyż zmienna img przeszła już przez całą pętle zanim onload() zostanie wykonane. Dlatego, gdy mamy do czynienia z takim asynchronicznym wywołaniem ważne jest abyśmy zachowywali każdy stan zmiennej kórej chcemy użyć. Tutaj z pomocą przychodzi nam IIFE.

for (var i = 0; i < 10; i++) {
(function(i) {
setTimeout(function() { console.log(i); }, 1000); // wypisuje 0-9
})(i);
}

Ostatnią rzeczą jaką robi nasz pre-loader jest zwrócenie obiektu zawierającego dane do których chcemy mieć dostęp.

Kiedy zostaną już załadowane wszystkie potrzebne obrazki, do każdego obrazka możemy odnieść się poprzez assetLoader.imgs[imageName].

Z pre-loaderm jesteśmy gotowi do pracy, możemy teraz stworzyć naszą animacje z wykorzystaniem spritesheet (jeden obrazek na którym mamy kilka mniejszych, ułożonych w odpowiedniej kolejności, składających się na poklatkową animacje). dla główneo bohatera.
Więcej o tym możesz przeczytać z artykułu który napisałem.

kandi.js
/**
* Tworzenie Spritesheet
* @param {string} - Ściezka do obrazka.
* @param {number} - Szerokosc (w px) każdej klatki.
* @param {number} - Wysokosc (w px) kazdej klatki.
*/
function SpriteSheet(path, frameWidth, frameHeight) {
this.image = new Image();
this.frameWidth = frameWidth;
this.frameHeight = frameHeight;

// Obliczanie numeru klatki w wierszu i ładowanie obrazka
var self = this;
this.image.onload = function() {
self.framesPerRow = Math.floor(self.image.width / self.frameWidth);
};

this.image.src = path;
}

/**
* Tworzenie animacji z spritesheet.
* @param {SpriteSheet} - Spritesheet używane do animacji.
* @param {number} - Liczba klatek do odczekania przed przejściem animacji.
* @param {array} - Zakres lub sekwencja liczby klatek dla animacji.
* @param {boolean} - Powtórzenie animacji.
*/
function Animation(spritesheet, frameSpeed, startFrame, endFrame) {

var animationSequence = []; // tablica przechowujaca kolejnosc animacji
var currentFrame = 0; // klatka do narysowania
var counter = 0; // ilosc na sekunde

// poczatek i koniec zakresu klatek
for (var frameNumber = startFrame; frameNumber <= endFrame; frameNumber++)
animationSequence.push(frameNumber);

/**
* Aktualizacja animacji
*/
this.update = function() {

// Nastepna klatka jesli jest juz odpowiedni czas
if (counter == (frameSpeed - 1))
currentFrame = (currentFrame + 1) % animationSequence.length;

// aktualizacja licznika
counter = (counter + 1) % frameSpeed;
};

/**
* Rysowanie obecnej klatki
* @param {integer} x - X pozycja do narysowania
* @param {integer} y - Y pozycja do narysowania
*/
this.draw = function(x, y) {
// Pobranie wiersza i kolumny klatki
var row = Math.floor(animationSequence[currentFrame] / spritesheet.framesPerRow);
var col = Math.floor(animationSequence[currentFrame] % spritesheet.framesPerRow);

ctx.drawImage(
spritesheet.image,
col * spritesheet.frameWidth, row * spritesheet.frameHeight,
spritesheet.frameWidth, spritesheet.frameHeight,
x, y,
spritesheet.frameWidth, spritesheet.frameHeight);
};
}

Aby uczynić naszą grę bardziej interesującą, stworzymy paralaksowe tło. Ta technika używa tych samych metod co “uciekjące” tło ale dodamy więcej obrazków poruszjących się z różną prędkością.

kandi.js
/**
* Tworzenie paralaksowego tła
*/
var background = (function() {
var sky = {};
var backdrop = {};
var backdrop2 = {};

/**
* Rysowanie obrazkow tla z rozna predkoscia
*/
this.draw = function() {
ctx.drawImage(assetLoader.imgs.bg, 0, 0);

// Uciekanie tla
sky.x -= sky.speed;
backdrop.x -= backdrop.speed;
backdrop2.x -= backdrop2.speed;

// rysowanie obrazkow obok siebie dla petli
ctx.drawImage(assetLoader.imgs.sky, sky.x, sky.y);
ctx.drawImage(assetLoader.imgs.sky, sky.x + canvas.width, sky.y);

ctx.drawImage(assetLoader.imgs.backdrop, backdrop.x, backdrop.y);
ctx.drawImage(assetLoader.imgs.backdrop, backdrop.x + canvas.width, backdrop.y);

ctx.drawImage(assetLoader.imgs.backdrop2, backdrop2.x, backdrop2.y);
ctx.drawImage(assetLoader.imgs.backdrop2, backdrop2.x + canvas.width, backdrop2.y);

// Jeśli tło wyjedzie poza ekran, resetuj
if (sky.x + assetLoader.imgs.sky.width <= 0)
sky.x = 0;
if (backdrop.x + assetLoader.imgs.backdrop.width <= 0)
backdrop.x = 0;
if (backdrop2.x + assetLoader.imgs.backdrop2.width <= 0)
backdrop2.x = 0;
};

/**
* Reset tła na zero
*/
this.reset = function() {
sky.x = 0;
sky.y = 0;
sky.speed = 0.2;

backdrop.x = 0;
backdrop.y = 0;
backdrop.speed = 0.4;

backdrop2.x = 0;
backdrop2.y = 0;
backdrop2.speed = 0.6;
}

return {
draw: this.draw,
reset: this.reset
};
})();

Znowu używamy IIFE żeby stworzyć singleton który udostępnia nam jedynie metody draw() i reset().

Ostatnią rzeczą jaką musimy zrobić jest uwtorzenie pętli animacji i rozpoczęcie gry.

kandi.js
/**
* Rozpoczecie gry - zresetownaie wszystkich zmiennych, narysowanie platform i wody
*/
function startGame() {
// Ustawienie gracza
player.width = 60;
player.height = 96;
player.speed = 6;
player.sheet = new SpriteSheet("imgs/normal_walk.png", player.width, player.height);
player.anim = new Animation(player.sheet, 4, 0, 15);

// Stworzenie platform
for (i = 0, length = Math.floor(canvas.width / platformWidth) + 1; i < length; i++) {
ground[i] = {"x": i * platformWidth, "y": platformHeight};
}

background.reset();

animate();
}

/**
* Petla gry
*/
function animate() {
requestAnimFrame( animate );

background.draw();

for (i = 0; i < ground.length; i++) {
ground[i].x -= player.speed;
ctx.drawImage(assetLoader.imgs.grass, ground[i].x, ground[i].y);
}

if (ground[0].x <= -platformWidth) {
ground.shift();
ground.push({"x": ground[ground.length-1].x + platformWidth, "y": platformHeight});
}

player.anim.update();
player.anim.draw(64, 260);
}

assetLoader.downloadAll();

Kiedy gra się rozpocznie ustawione zostaną domyślne wartości dla gracza. Speed używana jest do poruszania wszystkich obiektów na ekranie ze stałą prędkością. Gracz ma wrażenie jakby się poruszał podczas gry tak na prawdę cały czas stoi w miejscu.

Gra tworzy także wszystkie platformy na których gracz może stawać. Następnie resetowane jest tło i rozpoczyna się pętla animacji.

Na początku rysuje paralaksowe tło na canvasie. Następnie tworzy i przemieszcza wszystkie platformy. Jeśli któraś platforma wyjedzie poza ekran zostaje usuwana z tablicy i na jej miejsce tworzonaj jest nowa. Daje nam to wrażenie jednej nie kończącej się podstawy.

Pętla animacji aktualizuje też spritesheet gracza i rysuje go na ekranie nadając efekt poruszania się.

Ostatnią rzeczą jaką robimy jest wywołanie downloadAll() żeby rozpocząć proces pobierania i uruchomić grę kiedy się skończy.

To wszystko, powinieneś już mieć prostą gierkę endless runner.

W tym tutorialu nauczyciliśmy się jak korzystać z IIFE aby zachowyać stan zmiennych dla asynchronicznych wywołań. Nauczyliśmy się też jak wykorzystać wzorzec obiektowy do tworzenia singletonu i o tym jak działają fukcję zamknięte (ang. closures). Na koniec zebraliśmy całą tą wiedzę i stworzyliśmy podstawową strukturę dla naszej gry.

W następnym tutorialu dodamy do gry więcej różnorodności, dzięki przerwom, daniu graczowi możliwości skakania , zróżnicowaniu wysokości platform i wrogom.