Studium przypadku – tworzenie gier w HTML5 Canvas

Derek Detweiler
Derek Detweiler

Wiosną 2010 r. zainteresowałem się gwałtownie rosnącym wsparciem dla HTML5 i powiązanych technologii. W tym czasie z przyjacicielem rywalizowaliśmy w ramach dwutygodniowych konkursów na najlepsze gry, aby doskonalić nasze umiejętności programowania i tworzenia gier oraz wdrożyć pomysły, które ciągle sobie nawzajem rzucaliś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, rosnąca 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 Catan. Używając go jako planu, możemy wyróżnić 3 elementy niezbędne do stworzenia heksagonu na kanwie HTML5, aby można było grać w internetowej wersji gry: rysowanie heksagonu, rysowanie ścieżek i obracanie heksagonu. 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 sześciokąta użyłam 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ę, potrzebujemy tylko tagu kanwy w dokumencie HTML, np.:

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

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

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

Po drugie, musimy uzyskać 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 heksagonie

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 zarówno punkty końcowe, jak 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);

Chcemy, aby linia miała ładną obwódkę, więc 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:

Ukończony 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 canvas 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ścian ma 6 boków, więc chcemy go obrócić 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 punktu wyjścia, po zakończeniu obracania musimy przesunąć rysunek w drugą stronę. W związku z tym 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ócona sześciokątna płytka

Podsumowanie

Powyżej wyróżniliśmy kilka możliwości, jakie oferuje HTML5 przy użyciu tagu canvas, m.in. renderowanie obrazów, rysowanie krzywych Béziera i obracanie płótna. 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();