Tower Defense – piszemy grę w TypeScript – część pierwsza
W poprzednim wpisie obiecałem, że następnym razem zabierzemy się za stworzenie gry w TypeScripcie. Oczywiście zamierzam dotrzymać obietnicy. Zatem zróbmy to – wspólnie napiszmy bardzo prostą grę, będącą przedstawicielem gatunku tower defense.
Tower defense to moim zdaniem jeden z najprzyjemniejszych typów gier strategicznych. W tego typu grach podstawowe zasady rozgrywki są bardzo proste: na planszy znajduje się baza którą musimy chronić, a pojawia się coraz to więcej przeciwników chcących tę bazę zniszczyć. My natomiast możemy na planszy stawiać różnorakie wieżyczki z działkami i innymi zabójczymi wynalazkami, które pomogą nam przetrwać kolejne fale przeciwników.
Nasza implementacja tego rodzaju gry będzie bardzo prosta, ale nie powinno to umniejszać jej grywalności!
Wstępne wymagania
Zanim przejdziemy do jakichkolwiek prac, musimy się upewnić że posiadamy w systemie wszystkie niezbędne zależności, które są potrzebne do pracy w TypeScripcie. A są to:
- node rzecz jasna,
- menadżer paczek npm,
- TypeScript
Dwie pierwsze zależności załatwimy odwiedzając tę stronę. Gdy pierwsze dwie mamy już spełnione, to możemy zainstalować język TypeScript i tym samym spełnić zależność numer trzy:
npm install -g typescript
Globalna instalacja TypeScriptu jest potrzebna, aby móc korzystać z całkiem przydatnego polecenia tsc. Po spełnieniu wszystkich powyższych zależności możemy zacząć przygotowywać projekt do działania.
Przygotowanie projektu
Tworzymy sobie gdzieś na dysku nowy katalog, który będzie głównym katalogiem naszego projektu. Następnie – przed rozpoczęciem właściwej pracy – należy wykonać kilka czynności:
Inicjalizacja projektu npm – wpisujemy poniższe polecenie, a następnie odpowiadamy na kilka prostych pytań:
npm init
Inicjalizacja projektu TypeScript:
tsc --init
Instalacja TypeScript w projekcie:
npm install typescript --save-dev
Instalacja bundlera parcel:
npm install parcel-bundler --save-dev
Konfiguracja polecenia uruchomienia: otwieramy wygenerowany przed chwilą plik package.json i wprowadzamy w sekcji scripts komendę start, która wywoła polecenie parcel index.html. Chodzi tutaj mniej więcej o coś takiego:
{ "name": "typeerror-towerdefense", "scripts": { "start": "parcel index.html" }, "devDependencies": { "parcel-bundler": "^1.12.3", "typescript": "^3.5.2" } }
Tworzymy plik index.html z następującą zawartością:
<html> <head> <title>TypeError.pl Tower Defense</title> <style>body { margin: 0; padding: 0; }</style> </head> <body> <script src="./app.ts"></script> </body> </html>
Tworzymy plik app.ts z następującą zawartością:
document.write('Hello world');
A następnie kontrolujemy, czy wszystko działa poprawnie. Uruchamiamy polecenie npm start, a następnie otwieramy wskazany adres w naszej ulubionej przeglądarce (mam nadzieję, że nie jest to Internet Explorer). Wyświetla się Hello world? Jeśli nie – sprawdź dokładnie, może coś Ci umknęło. Jeśli tak – jedziemy dalej.
Rysujemy po ekranie
Do rysowania po ekranie wykorzystamy najprostszą możliwą metodę – element canvas wprowadzony wraz z HTML5. Doprowadźmy do tego, by nasz plik app.ts wyglądał mniej więcej tak:
// przygotowanie płótna do rysowania oraz pobranie jego kontekstu const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); canvas.width = window.innerWidth; canvas.height = window.innerHeight; // rysowanie po kontekście płótna function draw(ctx: CanvasRenderingContext2D): void { ctx.fillStyle = 'red'; ctx.fillRect(100, 100, 100, 100); ctx.fillStyle = 'green'; ctx.fillRect(300, 100, 100, 100); ctx.fillStyle = 'blue'; ctx.fillRect(500, 100, 100, 100); } if (context !== null) { draw(context); } // dodanie płótna do elementu body document.body.appendChild(canvas);
Dzięki temu powinniśmy osiągnąć mniej więcej taki efekt. Działa? Są kolorowe kwadraty? Jedziemy dalej.
Główna pętla gry
Zapewne jesteś świadomy (świadoma?) tego, w jaki sposób działają gry – że istnieje coś takiego jak klatki na sekundę (dla niektórych możliwie najwyższa ich ilość jest dużo ważniejsza od samej gry), w ramach których dokonywane są obliczenia związane z logiką gry oraz jej renderowanie – to tak w uproszczeniu. W rzeczywistości to dużo bardziej skomplikowany temat i w Internecie niejedno już zostało na ten temat powiedziane. Dlatego też my nie będziemy się nad tym szczególnie długo rozwodzić i od razu przejdziemy do jej implementacji.
Jako że naszym docelowym środowiskiem jest przeglądarka internetowa, to możemy (a nawet musimy, bo nie mamy innego wyjścia) wykorzystać udostępniane przez nią API. Mamy szczęście – istnieje coś takiego jak requestAnimationFrame, które zostało specjalnie stworzone na potrzeby animacji. Zachęcam do zerknięcia pod wskazany przeze mnie adres, aby chociaż z grubsza wiedzieć, jak to działa.
Potrzebujemy funkcji aktualizującej stan gry – nazwijmy ją update. Funkcja ta powinna dokonywać potrzebnych przeliczeń logiki gry, następnie odrysowywać cały ekran i wywoływać się po raz kolejny. Jej wywoływania powinny być kontrolowane przez wspomniane już wcześniej API requestAnimationFrame. Jej implementacja mogłaby wyglądać chociażby tak:
const circle = { x: window.innerWidth / 2, y: window.innerHeight / 2, radius: 1, }; // aktualizacja oraz rysowanie stanu gry let lastTime = performance.now(); const makeMainLoop = (ctx: CanvasRenderingContext2D) => { const frame = (currentTime: number) => { // obliczenie delty pomiędzy dwoma klatkami const delta = (currentTime - lastTime) / 1000; lastTime = currentTime; // przeliczenia logiki gry circle.radius += 20 * delta; // rysowanie gry ctx.save(); ctx.fillStyle = 'red'; ctx.beginPath(); ctx.arc(circle.x, circle.y, circle.radius, 0, Math.PI * 2); ctx.fill(); ctx.restore(); // ponowne wywołanie aktualizacji stanu requestAnimationFrame(frame); } return frame; } if (context !== null) { const update = makeMainLoop(context); requestAnimationFrame(update); }
Efekt działania tego kodu możesz sprawdzić w tym miejscu. Zostało tutaj zrobione trochę więcej: zastosowaliśmy domknięcie, aby w sprytny sposób wstrzyknąć kontekst płótna potrzebny do rysowania, bez konieczności tworzenia globalnych zmiennych. Jest to nic innego jak gloryfikowane wszędzie wstrzykiwanie zależności. Mówiąc prościej: stworzyliśmy funkcję, która przyjmuje kontekst renderowania i zwraca kolejną funkcję: właściwą funkcję update.
W tej funkcji również pojawiło się coś ciekawego – obliczenie różnicy czasu pomiędzy dwoma klatkami. Jest to stary trick, umożliwiający uniezależnienie prędkości działania gry od wydajności – dzięki temu nasza gra będzie mniej więcej zachowywać się tak samo – niezależnie od tego czy będzie działać w 30, 45 bądź w 60 (lub więcej) klatkach na sekundę. Wartość delta to tak naprawdę różnica w sekundach pomiędzy jedną klatką a drugą. Mnożąc ją przez jakąś wartość, możemy uzależnić aktualizację tej wartości od upływu czasu.
W naszym przypadku na środku ekranu narysowaliśmy koło, które zwiększa swój promień o 20 pikseli na sekundę. Jest to naprawdę bardzo proste – ot, dodanie do promienia wartości 20 przemnożonej przez deltę i gotowe. Właśnie w taki sposób będziemy dokonywać większość aktualizacji stanu w naszej grze.
Obsługa zdarzeń
Pora wprowadzić możliwość interakcji z naszą grą – czyli czas na implementację obsługi zdarzeń. Różne gry obsługują różne zdarzenia – czy to klawiatury, gamepady, kierownice i inne cuda. Nam natomiast wystarczą jedynie zdarzenia myszki. No bo w końcu gatunek tower defense należy do gier strategicznych, a przecież gry tego typu zawsze obsługujemy myszką, prawda?
Na potrzeby naszej gry będziemy potrzebować jedynie współrzędnych X i Y myszki oraz przechwytywać moment kliknięcia przycisku. Do tego celu w zupełności wystarczą nam zdarzenia mousedown oraz mousemove. Plan działania jest prosty: tworzymy sobie funkcję wyciągającą współrzędne z obiektu zdarzenia dostarczanego przez API przeglądarki. Następnie tworzymy drugą funkcję, odpowiedzialną za akcję przy kliknięciu. Podpinamy pod nie prostą logikę, a następnie rozpoczynamy nasłuchiwanie na odpowiednich zdarzeniach przeglądarki. Mam tutaj na myśli coś takiego:
type Coords = { x: number; y: number; } const points: Coords[] = []; const getMouseCoords = (e: MouseEvent): Coords => ({ x: e.pageX, y: e.pageY }); const onMouseClick = (e: MouseEvent) => { const mouseCoords = getMouseCoords(e); points.push(mouseCoords); }; canvas.addEventListener('mousedown', onMouseClick);
Zdefiniowany został typ Coords, którego zadaniem będzie przechowywanie informacji o współrzędnych. Następnie zostały zdefiniowane dwie funkcje: getMouseCoords oraz onMouseClick. Ta pierwsza tworzy obiekt typu Coords z obiektu MouseEvent, natomiast ta druga – przetwarza kliknięcie. Na sam koniec podpinamy nasłuchiwanie zdarzenia mousedown na naszym płótnie.
Ponadto minimalnie modyfikacji uległo wnętrze głównej pętli gry – a dokładniej to kod odpowiedzialny za rysowanie. Aktualnie ten fragment wygląda następująco:
// rysowanie gry ctx.save(); ctx.fillStyle = 'red'; points.forEach(point => { ctx.beginPath(); ctx.arc(point.x, point.y, circle.radius, 0, Math.PI * 2); ctx.fill(); }); ctx.restore();
Teraz zamiast rysowania pojedynczego punktu – iterujemy po utworzonej wcześniej tablicy współrzędnych points i rysujemy każdy jej element.
Dzięki temu otrzymaliśmy prostą interaktywną aplikację – spróbuj trochę poklikać!
Podsumowanie
Przebrnęliśmy przez początek, a początki – jak to zwykle bywa – są najtrudniejsze najnudniejsze. Ale to mamy już za sobą – wiemy jak przygotować projekt TypeScript, jak wykorzystać element Canvas standardu HTML5, oraz w jaki sposób obsługiwać zdarzenia przeglądarki. Ponadto stworzyliśmy główną pętlę gry. To całkiem solidne podstawy, wystarczające do stworzenia prostej gry tower defense. Nie wierzysz? Przekonasz się już wkrótce!
W następnym wpisie przejdziemy do implementacji konkretnej logiki gry. Pracę rozpoczniemy od definicji potrzebnych nam typów (tutaj TypeScript zabłyśnie), następnie przygotujemy sobie kilka metod i pospinamy wszystko w całość. Tak, aby nasze dzieło powoli zaczęło przypominać prawdziwą grę.
Cały kod źródłowy znajdziesz w repozytorium na GitHubie.
Zapraszam serdecznie do dyskusji w komentarzach!