Studium przypadku – tworzenie gier w HTML5 Canvas

Derek Detweiler
Derek Detweiler

Wprowadzenie

Wiosną 2010 r. zainteresowałem się gwałtownie rosnącym wsparciem dla HTML5 i powiązanych technologii. W tym czasie mój przyjaciel i ja rywalizowaliśmy ze sobą w 2-tygodniowych konkursach na najlepszy projekt gry, aby doskonalić nasze umiejętności programowania i tworzenia gier oraz wdrożyć pomysły na gry, które ciągle sobie nawzajem podrzucaliśmy. Dlatego zaczęłam stosować elementy HTML5 w swoich pracach konkursowych, aby lepiej zrozumieć, jak działają, i móc robić rzeczy, które były prawie niemożliwe przy użyciu wcześniejszych specyfikacji HTML.

Spośród wielu nowych funkcji HTML5 coraz większa obsługa tagu canvas dała mi możliwość implementacji interaktywnej grafiki za pomocą JavaScriptu. Dzięki temu udało mi się stworzyć grę logiczną o nazwie Entanglement. Mieliśmy już prototyp, który powstał na podstawie planszy do gry Osadnicy z Catanu. Używając jej jako planu, można stworzyć na płótnie HTML5 heksagonalny kafelek do gry w przeglądarce. Do tego potrzebne są 3 elementy: narysowanie heksagonu, narysowanie ścieżek i obrócenie kafelka. Poniżej znajdziesz szczegółowe omówienie tego, jak udało mi się osiągnąć obecną formę.

Rysowanie sześciokąta

W pierwotnej wersji Entanglement do narysowania heksagonu użyłem kilku metod rysowania na płótnie, ale w obecnej wersji gry do rysowania tekstur wyciętych z arkusza sprite służy funkcja drawImage().

Arkusz sprite z kafelkami
Arkusz sprite elementów do układania

Złączyłem obrazy w jeden plik, więc wysłano tylko jedno żądanie do serwera, a nie 10, jak w tym przypadku. Aby narysować wybrany sześcian na płótnie, musimy najpierw zebrać nasze narzędzia: płótno, kontekst i obraz.

Aby utworzyć kanwę, wystarczy dodać do dokumentu HTML tag canvas w taki sposób:

<canvas id="myCanvas"></canvas>

Przypisuję mu identyfikator, abyśmy mogli go użyć w skrypcie:

var cvs = document.getElementById('myCanvas');

Po drugie, musimy pobrać kontekst 2D dla kanwy, aby móc zacząć rysować:

var ctx = cvs.getContext('2d');

Na koniec potrzebujemy obrazu. Jeśli ma nazwę „tiles.png” i znajduje się w tym samym folderze co nasza strona internetowa, możemy go pobrać w ten sposób:

var img = new Image();
img.src = 'tiles.png';

Teraz, gdy mamy 3 komponenty, możemy użyć metody ctx.drawImage(), aby narysować pojedynczy heksagon z arkusza sprite’ów na płótnie:

ctx.drawImage(img, sourceX, sourceY, sourceWidth, sourceHeight,
            destinationX, destinationY, destinationWidth, destinationHeight);

W tym przypadku użyjemy czwartego heksagonu od lewej w górnym rzędzie. Narysujemy go też na płótnie w lewym górnym rogu, zachowując ten sam rozmiar co oryginał. Zakładając, że heksagony mają 400 pikseli szerokości i 346 pikseli wysokości, całość będzie wyglądać mniej więcej tak:

var cvs = document.getElementById('myCanvas');
var ctx = cvs.getContext('2d');
var img = new Image();
img.src = 'tiles.png';
var sourceX = 1200;
var sourceY = 0;
var sourceWidth = 400;
var sourceHeight = 346;
var destinationX = 0;
var destinationY = 0;
var destinationWidth = 400;
var destinationHeight = 346;
ctx.drawImage(img, sourceX, sourceY, sourceWidth, sourceHeight,
            destinationX, destinationY, destinationWidth, destinationHeight);

Skopiowaliśmy część obrazu na kanwę. Oto wynik:

Sześciokątny
Płytka w kształcie heksagonu

Rysowanie ścieżek

Teraz, gdy na płótnie mamy już sześciokąt, chcemy na nim narysować kilka linii. Najpierw przyjrzymy się geometrii heksagonu. Chcemy mieć 2 krawędzie na każdej stronie, z których każda kończy się 1/4 szerokości na każdym z boków i 1/2 szerokości od siebie, tak jak tutaj:

Punkty końcowe linii na heksagonalnej płycie
Punkty końcowe linii na kafelku sześciokątnym

Chcemy też uzyskać ładną krzywą, więc po kilku próbach odkryłem, że jeśli narysuję linię prostopadłą od krawędzi w każdym punkcie końcowym, to przecięcie każdej pary punktów końcowych w określonym kącie heksagonu tworzy ładny punkt kontrolny Béziera dla tych punktów końcowych:

Punkty kontrolne na heksagonalnej płytce
Punkty kontrolne na sześciokącie

Teraz mapujemy punkty końcowe i punkty kontrolne na płaszczyźnie Kartezjańskiej odpowiadającej obrazowi na płótnie. Możemy wrócić do kodu. Aby nie komplikować, zaczniemy od jednej linii. Zaczniemy od narysowania ścieżki od punktu końcowego w lewym górnym rogu do punktu końcowego w prawym dolnym rogu. Nasz wcześniejszy obraz heksagonu ma wymiary 400 x 346, więc jego górny koniec będzie miał 150 pikseli w szerzy i 0 pikseli w dół (w skrótach 150, 0). Punkt kontrolny będzie miał współrzędne (150, 86). Punkt końcowy dolnej krawędzi to (250, 346) z punktem kontrolnym (250, 260):

współrzędne pierwszej krzywej Beziera,
Współrzędne pierwszej krzywej Béziera

Teraz, gdy mamy już współrzędne, możemy zacząć rysować. Zaczniemy od wywołania ctx.beginPath(), a potem przejdziemy do pierwszego punktu końcowego, używając:

ctx.moveTo(pointX1,pointY1);

Następnie możemy narysować samą linię, używając funkcji ctx.bezierCurveTo() w ten sposób:

ctx.bezierCurveTo(controlX1, controlY1, controlX2, controlY2, pointX2, pointY2);

Ponieważ chcemy, aby linia miała ładną obwódkę, narysujemy ją dwukrotnie, za każdym razem używając innej szerokości i koloru. Kolor zostanie ustawiony za pomocą właściwości ctx.strokeStyle, a szerokość za pomocą ctx.lineWidth. W ogóle pierwszy wiersz będzie wyglądał tak:

var pointX1 = 150;
var pointY1 = 0;
var controlX1 = 150;
var controlY1 = 86;
var controlX2 = 250;
var controlY2 = 260;
var pointX2 = 250;
var pointY2 = 346;
ctx.beginPath();
ctx.moveTo(pointX1, pointY1);
ctx.bezierCurveTo(controlX1, controlY1, controlX2, controlY2, pointX2, pointY2);
ctx.lineWidth = 15;
ctx.strokeStyle = '#ffffff';
ctx.stroke();
ctx.lineWidth = 10;
ctx.strokeStyle = '#786c44';
ctx.stroke();

Mamy teraz sześciokątną płytkę z pierwszą linią wijącą się po niej:

Pojedyncza linia na heksagonalnym kafelku
Pojedyncza linia na heksagonalnym kafelku

Po wpisaniu współrzędnych 10 innych punktów końcowych oraz odpowiednich punktów kontrolnych krzywej Béziera możemy powtórzyć powyższe czynności i utworzyć kafelek w takiej formie:

Gotowy heksagonalny kafelek.
Gotowa sześciokątna płytka

Obracanie obszaru roboczego

Gdy już mamy płytkę, chcemy mieć możliwość jej obrócenia, aby umożliwić różne ścieżki w grze. Aby to osiągnąć, używamy w ramach kanwy ctx.translate()ctx.rotate(). Chcemy, aby kafelek obracał się wokół swojego środka, więc pierwszym krokiem jest przeniesienie punktu odniesienia na kanwie do środka heksagonalnego kafelka. W tym celu używamy:

ctx.translate(originX, originY);

Gdzie originX będzie połową szerokości heksagonalnej płytki, a originY będzie połową wysokości, co daje:

var originX = 200;
var originY = 173;
ctx.translate(originX, originY);

Teraz możemy obrócić kafelek z nowym punktem środkowym. Sześciokąt ma 6 boków, więc chcemy obrócić go o wielokrotność Math.PI podzieloną przez 3. Będziemy trzymać się prostoty i wykorzystywać pojedynczy obrót zgodnie z kierunkiem ruchu wskazówek zegara, używając:

ctx.rotate(Math.PI / 3);

Ponieważ jednak sześcian i linie używają starych współrzędnych (0,0) jako początku układu współrzędnych, po zakończeniu obracania musimy przesunąć rysunek w drugą stronę. Łącznie mamy teraz:

var originX = 200;
var originY = 173;
ctx.translate(originX, originY);
ctx.rotate(Math.PI / 3);
ctx.translate(-originX, -originY);

Po umieszczeniu tego przekształcenia i obrotu przed kodem renderowania, kafelek jest renderowany w nowej pozycji:

Obrócony sześciokątny kafelek
Obrócone sześciokątne pole

Podsumowanie

Powyżej wyróżniliśmy kilka możliwości, jakie oferuje HTML5 za pomocą tagu canvas, w tym renderowanie obrazów, rysowanie krzywych Beziera i obracanie obrazu. Korzystanie z tagu canvas HTML5 i jego narzędzi do rysowania w JavaScriptzie w przypadku gry Entanglement było przyjemnym doświadczeniem. Z niecierpliwością czekam na nowe aplikacje i gry, które inni będą tworzyć z użyciem tej otwartej i wschodniej technologii.

Przewodnik po kodzie

Wszystkie przykłady kodu podane powyżej zostały zebrane poniżej w jeden plik:

var cvs = document.getElementById('myCanvas');
var ctx = cvs.getContext('2d');
var img = new Image();
img.src = 'tiles.png';

var originX = 200;
var originY = 173;
ctx.translate(originX, originY);
ctx.rotate(Math.PI / 3);
ctx.translate(-originX, -originY);

var sourceX = 1200;
var sourceY = 0;
var sourceWidth = 400;
var sourceHeight = 346;
var destinationX = 0;
var destinationY = 0;
var destinationWidth = 400;
var destinationHeight = 346;
ctx.drawImage(img, sourceX, sourceY, sourceWidth, sourceHeight,
            destinationX, destinationY, destinationWidth, destinationHeight);

ctx.beginPath();
var pointX1 = 150;
var pointY1 = 0;
var controlX1 = 150;
var controlY1 = 86;
var controlX2 = 250;
var controlY2 = 260;
var pointX2 = 250;
var pointY2 = 346;
ctx.moveTo(pointX1, pointY1);
ctx.bezierCurveTo(controlX1, controlY1, controlX2, controlY2, pointX2, pointY2);
ctx.lineWidth = 15;
ctx.strokeStyle = '#ffffff';
ctx.stroke();
ctx.lineWidth = 10;
ctx.strokeStyle = '#786c44';
ctx.stroke();

ctx.beginPath();
pointX1 = 250;
pointY1 = 0;
controlX1 = 250;
controlY1 = 86;
controlX2 = 150;
controlY2 = 86;
pointX2 = 75;
pointY2 = 43;
ctx.moveTo(pointX1, pointY1);
ctx.bezierCurveTo(controlX1, controlY1, controlX2, controlY2, pointX2, pointY2);
ctx.lineWidth = 15;
ctx.strokeStyle = '#ffffff';
ctx.stroke();
ctx.lineWidth = 10;
ctx.strokeStyle = '#786c44';
ctx.stroke();

ctx.beginPath();
pointX1 = 150;
pointY1 = 346;
controlX1 = 150;
controlY1 = 260;
controlX2 = 300;
controlY2 = 173;
pointX2 = 375;
pointY2 = 213;
ctx.moveTo(pointX1, pointY1);
ctx.bezierCurveTo(controlX1, controlY1, controlX2, controlY2, pointX2, pointY2);
ctx.lineWidth = 15;
ctx.strokeStyle = '#ffffff';
ctx.stroke();
ctx.lineWidth = 10;
ctx.strokeStyle = '#786c44';
ctx.stroke();

ctx.beginPath();
pointX1 = 325;
pointY1 = 43;
controlX1 = 250;
controlY1 = 86;
controlX2 = 300;
controlY2 = 173;
pointX2 = 375;
pointY2 = 130;
ctx.moveTo(pointX1, pointY1);
ctx.bezierCurveTo(controlX1, controlY1, controlX2, controlY2, pointX2, pointY2);
ctx.lineWidth = 15;
ctx.strokeStyle = '#ffffff';
ctx.stroke();
ctx.lineWidth = 10;
ctx.strokeStyle = '#786c44';
ctx.stroke();

ctx.beginPath();
pointX1 = 25;
pointY1 = 130;
controlX1 = 100;
controlY1 = 173;
controlX2 = 100;
controlY2 = 173;
pointX2 = 25;
pointY2 = 213;
ctx.moveTo(pointX1, pointY1);
ctx.bezierCurveTo(controlX1, controlY1, controlX2, controlY2, pointX2, pointY2);
ctx.lineWidth = 15;
ctx.strokeStyle = '#ffffff';
ctx.stroke();
ctx.lineWidth = 10;
ctx.strokeStyle = '#786c44';
ctx.stroke();

ctx.beginPath();
pointX1 = 325;
pointY1 = 303;
controlX1 = 250;
controlY1 = 260;
controlX2 = 150;
controlY2 = 260;
pointX2 = 75;
pointY2 = 303;
ctx.moveTo(pointX1, pointY1);
ctx.bezierCurveTo(controlX1, controlY1, controlX2, controlY2, pointX2, pointY2);
ctx.lineWidth = 15;
ctx.strokeStyle = '#ffffff';
ctx.stroke();
ctx.lineWidth = 10;
ctx.strokeStyle = '#786c44';
ctx.stroke();