Pong je jednou z nejstarších her pro počítače vůbec. V článku si rozebereme, jak hru na podobných principech přepsat do webové verze za pomoci HTML5 a JavaScriptu. Konkrétně půjde o verzi PsychoPong, kde na rozdíl od klasické hry celá plocha rotuje.
Běžící hru si můžete u nás vyzkoušet.
Kompletní zdrojové kódy jsou dostupné na GitHub.
Když jsem tenkrát šel ze školy na autobus, hlava se mi motala, chodník se vlnil a všechno mi připadalo rozmazané. A to jsem prosím nepil. Seděli jsme celý den se spolužákem v šatnách a na naší hlídce jsme si krátili dlouhou chvíli hraním starých dosovských her. Od Maria přes Prince a Vláček jsme se dostali až k PsychoPongu.
Tento článek by vám měl přiblížit, jak takovou hru přepsat do webové verze. Hned na začátek bych ale rád zdůraznil, nebudeme rozebírat hru řádek po řádku. To by dle mého názoru nemělo ani smysl. Zaměřím se nejdříve pouze na celkovou strukturu a fungování hry a poté rozeberu jednotlivě zajímavé problémy.
Co potřebujeme znát?
Podobná jednoduchá hra se dá i bez podrobnějších znalostí napsat za jedno dvě odpoledne. Jak by řekl klasik, stačí se umět ptát. S dobrými dotazy vám Google poradí, protože je téměř jistota, že něco podobného už někdo potřeboval.
Na jakých technologiích tedy postavíme naši hru?
- JavaScript výborně poslouží na samotné programové jádro aplikace. Přestože není úplně klasicky objektově orientovaný, my si jakousi základní MVC strukturu napíšeme. Dostanu se k tomu ještě později, už teď ale poznamenám, že podobné jednoduché hry jsou na ukazování výhod tohoto principu skvělé.
- HTML5. Díky novému elementu canvas budeme mít samotné vykreslování velmi usnadněné. A protože potřebujeme vykreslovat jen obdélníčky a kolečka, bude to záležitost opravdu pár řádků.
- CSS3. Stejně tak díky CSS3 dokážeme snadno rotovat celou plochou, konkrétně díky vlastnosti transform.
Článek předpokládá alespoň základní znalost JavaScriptu a nenulovou zkušenost s programováním samotným.
MVC a objekty v JavaScriptu
Pokud si dobře rozmyslíme, jak má aplikace vypadat, ušetříme si tím do budoucna spoustu problémů – jak při možných opravách, tak i při přidávání nových vlastností.
Jak už jsem naznačil výše, je vhodné použít architekturu MVC. K čemu je to tak dobré? Oddělíme tím jednotlivé části aplikace, které se nám přestanou motat dohromady.
U podobných aplikací je navíc velmi jednoduché říct, co do které části patří:
- Model obstarává veškerou logiku hry. V tomto konkrétním případě to znamená, že obsahuje jakési instance objektů hry a přepočítává jejich pohyb a případné srážky. Pokud jsou jejich pozice uloženy v nějaké soustavě souřadné, jedná se pouze o pár jednoduchých funkcí.
- View je část aplikace pro vykreslování. V našem případě to znamená jednotku, která vždy vymaže celou plochu a znovu vše vykreslí. Ano, mohli byste namítnout, že je neefektivní vykreslovat vše znovu, i když se nic nemuselo změnit. A je to také pravda, ale pro jednoduchost se spokojíme s celým překreslováním.
- Controller sbírá události od uživatele. To jsou hlavně stisky kláves. Zde musíme například zajistit, aby aplikace zvládala zpracovat více stisknutých kláves najednou. Tento problém ještě rozebereme dále v článku.
Protože JavaScript není typickým zástupcem OOP jazyků, nemáme ani žádný ideální vzor, jak napsat jádro aplikace. Existuje několik způsobů, jak v JS vytvořit objekt – jedním z nich je vytvořit jej přímo:
var MyObject = {
// vlastnost objektu
foo : 584,
// metoda objektu
bar : function () {
...
}
}
Tento přístup má jednu zásadní výhodu i nevýhodu – objekt je naprosto statický a existuje pouze jednou. To nám ale pro samotný objekt, který bude uchovávat celou aplikaci, vyhovuje. Má to navíc výhodu i v tom, že k objektu můžeme odkudkoli přistupovat voláním jeho názvu, a to i k jeho metodám a vlastnostem.
Pokud bych tedy měl napsat zjednodušeně, jak bude samotná aplikace v JS vypadat, napsal bych něco takového:
var PsychoPong = {
// zakladni metody aplikace
init : function () { ... },
start : function () { ... },
end : function () { ... },
// MVC model
Model : { ... },
View : { ... },
Controller : { ... },
}
A k samotnému fungování aplikace snad více řekne obrázek:
Význam metody start a end je patrně jasný – první hru spouští, druhá ji zastavuje. Ani metoda init nebude zas až takovou záhadou – zde si připravuji vše, co potřebuju už pro samotný běh. Když se podíváte už na hotovou hru, zjistíte, že metoda init nedělá v podstatě nic jiného, než že volá další inity:
init : function (params) {
PsychoPong.Model.init();
PsychoPong.Controller.init();
PsychoPong.View.init(params['canvas']);
},
Trochu nuda. Až na View není co řešit. Tomu jedinému předáváme ještě parametr ze vstupu – ID elementu, do kterého se má vykreslovat. Vzorní programátoři by měli vyřešit i případ, kdy nám někdo zákeřně podstrčí neplatné ID.
if (typeof(params['canvas']) != 'string'||document.getElementById(params['canvas'])==null) {
throw "Init error: Canvas is not an object";
}
Co se děje v jednotlivých initech?
V modelu si vytvořím instance obou hráčů, které obsahují jejich pozici, rychlost, rozměry a další věci, které budu později potřebovat.
// PsychoPong.Model.init
init : function () {
this.playerA = new this.Player();
this.playerB = new this.Player();
// umisteni hrace B na druhou stranu plochy
this.playerB.x = this.canvasSize[0] - this.playerB.width;
this.ball.init();
}
Jak konkrétně vypadá objekt Player zatím nechme stranou, podrobněji ho rozebereme až v sekci s problémy, konkrétně u problému číslo pět.
Init viewu je trochu zajímavější a dostaneme se zde už k práci se samotným canvasem. Jak tedy vypadá?
// PsychoPong.View.init
init : function (canvas) {
// ziskani velikosti plochy
this.size = PsychoPong.Model.canvasSize;
// nacteni canvasu
this.canvas = document.getElementById(canvas);
// ziskani contextu do ktereho budeme kreslit
this.context = this.canvas.getContext('2d');
// barva
this.context.fillStyle = '#00f';
// rozmery
this.setCanvasSize();
}
Abych ujasnil význam contextu, přeskočím rovnou k funkci, která vykresluje destičku hráče:
// PsychoPong.View.renderPlayer
renderPlayer : function (player) {
this.context.fillRect(player.x, player.y, player.width, player.height);
}
Nebo k vykreslení míčku, tzn. kruhu
// PsychoPong.View.renderBall
renderBall : function (ball) {
this.context.beginPath();
this.context.arc(ball.x, ball.y, ball.size, 0, 2 * Math.PI, false);
this.context.fill();
}
U vykreslení obdélníku jsou všechny parametry zřejmé, u kruhu už tolik ne:
context.arc(x, y, polomer, uhel_zacatek, uhel_konec, smer);
Úhly jsou udávány v radiánech a směr neznamená nic jiného, než zdali se má vykreslovat ve směru hodinových ručiček (pak true), nebo proti směru (false).
Init Controlleru přiřazuje příslušné akce událostem na klávesnici. Zde nastává problém – hráči budou současně držet více kláves najednou a my bychom rádi, aby se navzájem neblokovali. Tento problém si zaslouží rozebrat samostatně, vrátím se k němu…
Tím bych povídání o aplikaci jako celku zakončil. Pokud máte zájem dozvědět se, jak fungují jednotlivé dílčí části hry, můžete prozkoumat zdrojové kódy.
Problémy
1. Obnovování plochy
Základem hry je opakovaně vykreslovat hrací plochu. Není to nic složitého a celá věda je skryta ve funkci start():
// PsychoPong.start
start : function () {
PsychoPong.game = true;
PsychoPong.Model.ball.speed = [1, 0];
setTimeout(function frame() {
if ((PsychoPong.game)&&(PsychoPong.View.status)) {
PsychoPong.Model.incSpeed();
PsychoPong.View.status = false;
PsychoPong.Model.moveAll();
PsychoPong.View.render();
setTimeout(frame, 1000 / PsychoPong.fps);
}
}, 1000 / PsychoPong.fps);
}
Když ukázku rozebereme po řádcích, zjistíme, že si nejprve poznamenáme, že hru spouštíme. Snadno lze domyslet, co bude obsahovat metoda PsychoPong.end(). Poté uvedeme míček do pohybu. Konečně se dostáváme k tomu důležitému, protože funkce setTimeout nám zajistí spuštění kódu až po určitém čase.
Pomocí setTimeoutu je volána funkce frame, která je zde schovaná. Ta nejdříve zjistí, zda ještě hra běží. Druhá část podmínky je také velmi důležitá – vykreslení nějakou dobu trvá. My si pomocí jednoduchého semaforu PsychoPong.View.status zaručíme, že pokud vykreslování ještě nebylo dokončeno, nebude se volat znovu.
2. Vykreslování plochy
Pokud zatím vynecháme problém otáčení, ke kterému se ještě vrátíme, je vykreslení plochy záležitostí pár řádků:
// PsychoPong.View.render
render : function () {
// vymazani cele plochy
this.context.clearRect(0, 0, this.size[0], this.size[1]);
// vykreseni hranic
this.context.fillRect(x, y-1, this.size[0], 2); // up
this.context.fillRect(x, y+this.size[1]-1, this.size[0], 2); // bottom
this.context.fillRect(x-1, y, 2, this.size[1]); // left
this.context.fillRect(x+this.size[0]-1, y, 2, this.size[1]); // right
// vykresleni objektu
this._renderPlayer(x, y, PsychoPong.Model.playerA);
this._renderPlayer(x, y, PsychoPong.Model.playerB);
this._renderBall(x, y, PsychoPong.Model.ball);
}
3. Obsloužení více kláves najednou
V této hře je situace zjednodušena tím, že potřebujeme zareagovat pouze na stisk klávesy (zvýšíme rychlost jedním směrem) a konec stisku (rychlost se změní na nulu). Díky tomu nemusíme vůbec řešit stav mezi.
Co kdybychom ale chtěli při stisknuté klávese rychlost pomalu zvyšovat? Existuje poměrně elegantní řešení. Vytvoříme si pole, ve kterém si budeme pamatovat právě stisknuté klávesy. Při stisku klávesu do pole přidáme, při ukončení stisknutí odstraníme.
Při každém jednom průběhu vždy projdeme celé pole a vykonáme příslušné události. V ukázce využiji knihovny jQuery, ve standardním JavaScriptu by ale kód vypadal velmi podobně.
var keysPressed = [];
$(document).keydown(function (e) {
keysPressed[e.which] = true;
});
$(document).keyup(function (e) {
delete keysPressed[e.which];
});
function serveKeys() {
for (var i in keysPressed) {
...
}
}
4. Velikost canvasu
Nastavování velikosti canvasu je trochu zrádné. Pokud nastavujete rozměry pomocí CSS vlastností width a height, element se vám sice roztáhne, ale počet zobrazovaných pixelů se nijak nezmění. Pokud chcete změnit skutečné rozměry plátna, musíte sáhnout přímo do elementu v HTML:
<canvas height="300" id="game" width="300">
You have no support of canvas.
</canvas>
5. Vytváření více objektů
Hned na začátku článku jsem se zmínil o vícero možnostech, jak vytvářet v JavaScriptu objekty. Způsob, který jsme zvolili na vytvoření objektu celé aplikace, je vhodný, pokud nám vyhovuje mít právě jednu instanci.
Co když ale chceme z jednoho objektu vytvořit více různých instancí? Jako příklad nechť slouží vytváření instancí hráčů v modelu. V initu vytváříme oba hráče voláním this.Player:
this.playerA = new this.Player();
Jak takový Player vypadá? V tomto případě použijeme pro vytvoření objektu funkci jako konstruktor.
// PsychoPong.Model.Player
Player : function () {
this.x = 0;
this.y = 0;
// dalsi vlastnosti
...
this.move = function () {
...
};
},
Velmi důležité je při vytváření takovéhoto objektu nezapomenout na slovíčko new. Bez něj by se pouze objekt zavolal jako funkce.
6. Otáčení
Nejedná se o nic jiného, než hraní s CSS vlastnostmi objektu. Máme-li rychlost speedRotation, kterou chceme, aby se celý element otáčel, stačí nám opakovaně volat funkci, která vypadá následovně:
// PsychoPong.View.rotation
rotation : function () {
if (this.speedRotation > 0) {
this.canvas.css({
'transform': 'rotate('+this.iRotation+'deg)',
'-moz-transform': 'rotate('+this.iRotation+'deg)',
'-o-transform': 'rotate('+this.iRotation+'deg)',
'-webkit-transform': 'rotate('+this.iRotation+'deg)'
});
this.iRotation += this.speedRotation;
}
}
Zde jsem pro jednoduchost sáhl po javascriptovém frameworku jQuery, který například práci se styly zjednodušuje. Pokud nechcete do aplikace tahat další kilobajty kódu, můžete se obejít i bez něj.
Na závěr bych přidal ještě jeden tip. Používejte Firebug (nebo jeho obdoby v jiných prohlížečích) i s konzolí. Předtím, než mi byla tahle možnost prozrazena, cokoli jsem potřeboval otestovat, jsem jednoduše alertoval. Z vyskakujících okének by se ale člověk po čase zbláznil. Mnohem hezčí je napsat:
console.log(...);