Tower Defense – piszemy grę w TypeScript – część druga
W poprzednim wpisie przygotowaliśmy sobie środowisko do pracy. Następnie stworzyliśmy główną pętlę gry oraz szczątkową obsługę zdarzeń. Tym razem pójdziemy o krok dalej i zbudujemy podwaliny do pełnoprawnej gry tower defense.
Zaczniemy od zdefiniowania potrzebnych nam typów, ale zanim to zrobimy – zastanówmy się, co chcemy osiągnąć.
Nasz cel
Będziemy dążyć do możliwie najprostszej implementacji gry typu tower defense. Moim zdaniem do osiągnięcia tego celu będziemy musieli spełnić następujące założenia:
- przeciwnicy poruszają się z lewej do prawej krawędzi ekranu
- naszą bazą będzie prawa krawędź ekranu i przeciwnicy nie mogą jej przekroczyć
- gracz może postawić wieżyczkę w niemal dowolnym miejscu na planszy
- jeden rodzaj przeciwnika oraz jedna wieżyczka
- wszystko w grze jest kołem – dla uproszczenia sprawy
Ponadto chcemy aby gracz mógł postawić wieżyczkę tylko wtedy gdy ma odpowiednią ilość pieniędzy, a pieniądze będzie zdobywał za każdego pokonanego wroga. No i oczywiście musi być jakiś warunek przegranej – gracz dostanie kilka żyć, a gra się skończy gdy te spadną do zera.
Wiemy już, co chcemy osiągnąć. To najważniejsze! Teraz możemy już spokojnie przejść do implementacji. Najpierw zastanówmy się, na jakich danych będzie operować nasza gra.
Dane w grze
Nasza implementacja będzie bardzo prosta – zatem naszych typów danych także będzie niewiele. Po chwili zastanowienia możemy wydzielić następujące typy danych:
- współrzędne (tutaj możemy śmiało użyć zdefiniowanego już wcześniej typu Coords)
- promień koła (bo wszystkie obiekty w naszej grze będą reprezentowane za pomocą kolorowego kółka)
- kierunek ruchu (potrzebny przy implementacji przeciwników oraz pocisków)
- właściwości poziomu (wymiary planszy, na jakiej będzie toczyła się gra)
- parametry wieżyczki (zasięg, cena, szybkostrzelność)
- parametry przeciwnika (punkty trafień, nagroda za pokonanie, prędkość)
- właściwości pocisku (jaka wieżyczka go wystrzeliła oraz do jakiego celu)
- globalny stan (czyli miejsce, w którym będzie przechowywany cały stan gry)
Teraz przenieśmy to na kod w TypeScripcie.
Tworzymy typy
TypeScript posiada bardzo bogaty mechanizm typów. Poza typami prostymi zawiera w sobie także typy złożone – między innymi intersection types oraz union types. Są to zdecydowanie jedne z mocniejszych stron języka TypeScript.
Na nasze potrzeby wystarczą jedynie intersection types. Z ich użyciem możemy osiągnąć coś takiego:
type Coords = { x: number; y: number; } type Radius = { radius: number; } type Entity = Coords & Radius type Movable = { vx: number; vy: number; } type Level = { width: number; height: number; } type Tower = { cost: number; range: number; fireRatio: number; lastShootAt: number; } & Entity type Enemy = { hitPoints: number; reward: number; speed: number; } & Entity & Movable type Bullet = { firedFrom: Tower; target: Enemy; } & Entity & Movable type GameState = { hitPoints: number; cash: number; towers: Tower[]; enemies: Enemy[]; bullets: Bullet[]; }
Zastosowanie intersection types powoduje, że dany typ musi być zgodny z wszystkimi typami, na których dokonano przecięcia. Innymi słowy: posiadać właściwości wszystkich tych typów.
Jest to bardzo wygodne rozwiązanie, za pomocą którego można znacznie zredukować powtarzalność kodu. W naszym przypadku pozwoliło wydzielić to typy Entity oraz Movable, a następnie wykorzystać je w pozostałych definicjach.
Mając zdefiniowane typy możemy przejść do następnego kroku: do napisania funkcji.
Piszemy funkcje
Mimo że TypeScript, podobnie jak JavaScript, jest językiem wieloparadygmatowym i pozwala na pisanie kodu w tradycyjny, obiektowy sposób (klasy, interfejsy), to ja jestem fanem funkcyjnego podejścia i pisania pojedynczych funkcji (metod) zamiast klas. Jest to dużo bardziej elastyczne rozwiązanie, mocno promujące ponowne używanie istniejącego kodu. Ponadto pojedyncze funkcje są dużo prostsze do implementacji i testowania, a mechanizm typów w TypeScript świetnie współgra z takim podejściem. Szkoda byłoby to zmarnować…
Zaczniemy od prostszych funkcji, a później weźmiemy się za te nieco bardziej skomplikowane. Na pierwszy ogień – odległość pomiędzy dwoma punktami:
const distanceBetween = (p1: Coords, p2: Coords): number => { const delta: Coords = { x: p1.x - p2.x, y: p1.y - p2.y }; return Math.sqrt(delta.x * delta.x + delta.y * delta.y); }
Nic skomplikowanego, zwykła matematyka. Teraz napiszmy funkcję, która sprawdza czy można postawić wieżyczkę w danym miejscu:
const canPlaceTower = (gameState: GameState, newTower: Tower): boolean => { if (gameState.cash < newTower.cost) { return false; } const isPlaceOccupied = gameState.towers.some(otherTower => { const distance = distanceBetween(newTower, otherTower); return distance < (newTower.radius + otherTower.radius); }); return !isPlaceOccupied; }
Tutaj także nie ma nic skomplikowanego. Najpierw sprawdzamy czy gracz posiada odpowiednią ilość pieniędzy. Później sprawdzamy, czy w jakimkolwiek przypadku dystans pomiędzy nową wieżyczką a dowolną już istniejącą jest odpowiedni (innymi słowy, czy wieżyczki będą na siebie zachodzić). Jeśli nie – to w tym miejscu wieżyczki postawić nie możemy.
Skoro już jesteśmy sprawdzić czy gracz może postawić w danym miejscu wieżyczkę… To możemy zaimplementować samo stawianie wieżyczek:
const placeTower = (gameState: GameState, newTower: Tower): void => { if (!canPlaceTower(gameState, newTower)) { return; } gameState.cash -= newTower.cost; gameState.towers.push(newTower); }
Jeszcze przydałaby nam się prosta funkcja, która zwracałaby gotowy obiekt typu Tower z domyślnymi wartościami. Mam tutaj na myśli coś takiego:
const provideBasicTower = (): Tower => { const basicTower: Tower = { cost: 50, range: 300, fireRatio: 0.4, radius: 16, lastShootAt: 0, x: 0, y: 0, }; return basicTower; };
Już mamy kawałek logiki – pora zająć się stanem gry.
Stan gry
Najwyższy czas na przygotowanie stanu gry. Stwórzmy sobie instancję obiektu typu GameState, która będzie zawierała pewne startowe wartości:
const gameState: GameState = { cash: 1000, hitPoints: 10, towers: [], enemies: [], bullets: [], };
Następnie stwórzmy sobie instancję typu Level, aby mieć w jednym miejscu wielkość planszy:
const level: Level = { width: window.innerWidth, height: window.innerHeight, };
Pamiętasz z poprzedniego wpisu sekcję Rysujemy po ekranie? Ustawialiśmy tam wymiary płótna – teraz jest dobry moment, by wszystko ujednolicić i zredukować powtarzający się kod:
canvas.width = level.width; canvas.height = level.height;
Teraz stwórzmy sobie obiekt typu Coords przechowujący współrzędne myszy:
const mouse: Coords = { x: 0, y: 0 };
Na ten moment kwestię stanu gry mamy załatwioną. Przejdźmy do obsługi zdarzeń.
Obsługa zdarzeń
W poprzednim wpisie na samym końcu poruszyłem kwestię obsługi zdarzeń. Tym razem do tego wrócimy, podpinając się pod kolejne zdarzenie i dodając trochę więcej logiki:
const onMouseMove = (e: MouseEvent) => { const mouseCoords = getMouseCoords(e); mouse.x = mouseCoords.x; mouse.y = mouseCoords.y; }; const onMouseClick = (e: MouseEvent) => { const mouseCoords = getMouseCoords(e); const newTower = provideBasicTower(); newTower.x = mouseCoords.x; newTower.y = mouseCoords.y; placeTower(gameState, newTower); }; canvas.addEventListener('mousedown', onMouseClick); canvas.addEventListener('mousemove', onMouseMove);
Dzięki temu będziemy w jednym miejscu trzymać aktualną pozycję myszy. Ponadto pod zdarzenie kliknięcia podpięliśmy stawianie nowej wieżyczki – sprytnie wykorzystaliśmy sobie tutaj funkcję provideBasicTower(), aby uprościć sprawę i zredukować w tym miejscu ilość kodu do potrzebnego minimum.
Świetnie – mamy obsługę zdarzeń. Już wszystko działa sobie w tle, ale na ekranie jeszcze nic nie widać. Pora to zmienić.
Przygotowania do rysowania
Będziemy musieli nieco zmodyfikować ciało głównej pętli gry z poprzedniego wpisu. Zanim to jednak zrobimy, warto byłoby sobie przygotować co nieco. Co nieco do rysowania.
Po każdej klatce animacji (czyli po każdym obiegu głównej pętli gry), będzie musiał być wyczyszczony ekran – aby można było od nowa narysować aktualny stan, bez bazgrania po poprzednim. Do tego celu przygotujemy sobie prostą funkcję:
const clearScreen = (ctx: CanvasRenderingContext2D, level: Level): void => { ctx.clearRect(0, 0, level.width, level.height); }
Pamiętasz jak na początku wpisu założyliśmy, że wszystko jest kołem? To założenie upraszcza nam nie tylko logikę, ale też i rysowanie. Stwórzmy sobie zatem funkcję rysującą ładne koła:
const drawCircle = (ctx: CanvasRenderingContext2D, circle: Entity, color: string): void => { ctx.save(); ctx.fillStyle = color; ctx.beginPath(); ctx.arc(circle.x, circle.y, circle.radius, 0, Math.PI * 2); ctx.fill(); ctx.restore(); };
Parametry tego koła wyciągamy z obiektu typu Entity – którym jak już dobrze wiesz, może być zarówno wieża, przeciwnik jak i pocisk. Dzięki temu za jednym zamachem mamy rysowanie różnego typu obiektów. Wystarczy im zmienić kolor! 🙂
Od razu możemy sobie przygotować funkcję wyświetlającą interfejs wewnątrz gry. Do tego niech posłużą te dwie funkcje:
const provideGameStateText = (gameState: GameState): string => `HitPoints: ${gameState.hitPoints}, Cash: ${gameState.cash}`; const drawUIOverlay = (ctx: CanvasRenderingContext2D, gameState: GameState): void => { ctx.save(); ctx.font = '24px Helvetica'; ctx.textBaseline = 'top'; ctx.textAlign = 'left'; ctx.fillText(provideGameStateText(gameState), 0, 0); ctx.restore(); };
Pierwsza na podstawie stanu gry zwróci nam tekst do wyświetlenia, a druga go wyświetli na ekranie.
Warto jeszcze stworzyć funkcję rysującą nakładkę, która będzie informowała gracza o tym, czy w danym miejscu może postawić wieżyczkę, czy też nie. Możemy ją zaprogramować w następujący sposób:
const drawTowerOverlay = (ctx: CanvasRenderingContext2D, gameState: GameState, coords: Coords): void => { const newTower = provideBasicTower(); newTower.x = coords.x; newTower.y = coords.y; const canPlace = canPlaceTower(gameState, newTower); const color = canPlace ? 'lightgreen' : '#ff7675'; drawCircle(ctx, newTower, color); };
W zależności od tego czy na danej pozycji można postawić wieżyczkę, rysujemy tam kółko w odpowiednim kolorze.
Pora spiąć całe te rysowanie w całość i wrzucić to wszystko w jedną, wygodną funkcję:
const redrawGameState = (gameState: GameState, level: Level, ctx: CanvasRenderingContext2D): void => { // wyczyszczenie ekranu clearScreen(ctx, level); // wieżyczki gameState.towers.forEach(tower => drawCircle(ctx, tower, 'lightblue')); // czy można zbudować wieżyczkę drawTowerOverlay(ctx, gameState, mouse); // informacje o stanie drawUIOverlay(ctx, gameState); };
Pora przejść do modyfikacji głównej pętli gry.
Główna pętla gry
Rysowanie mamy już z głowy – teraz sprawmy, by faktycznie to co trzeba było widoczne na ekranie.
W poprzedniej sekcji na samym końcu stworzyliśmy funkcję redrawGameState(), która łączy w sobie całość rysowania – od czyszczenia, przez obiekty, po interfejs. Teraz powinniśmy użyć jej w głównej pętli gry.
const makeMainLoop = (ctx: CanvasRenderingContext2D, gameState: GameState, level: Level) => { const frame = (currentTime: number) => { // obliczenie delty pomiędzy dwoma klatkami const delta = (currentTime - lastTime) / 1000; lastTime = currentTime; // odrysowanie stanu gry redrawGameState(gameState, level, ctx); // ponowne wywołanie aktualizacji stanu requestAnimationFrame(frame); }; return frame; }; if (context !== null) { const update = makeMainLoop(context, gameState, level); requestAnimationFrame(update); }
Trochę się pozmieniało – teraz do głównej pętli gry wstrzykujemy nie tylko kontekst do rysowania, ale także cały stan gry i informacje o poziomie. W środku natomiast wywołujemy dopiero co przygotowaną funkcję.
Nie uwierzysz… to działa!
Podsumowanie
To był całkiem długi wpis, ale nic dziwnego – w końcu udało nam się wspólnie zaimplementować solidny kawał logiki gry.
W następnym wpisie dodamy do gry przeciwników, wprowadzimy ich w ruch, a także sprawimy by wieżyczki się z nimi rozprawiały. Ponadto wprowadzimy warunki porażki… no i ogólnie dokończymy to, co zaczęliśmy 🙂
Ostatecznie wynikiem naszej pracy będzie bardzo prosta (wręcz prymitywna) gra tower defense. Plus jest taki, że będzie można ją łatwo rozbudowywać – liczę, że pokażesz mi swoją dopakowaną wersję!
Oczywiście cały projekt w stanie z końcówki tego wpisu znajdziesz na repozytorium. Gorąco zachęcam do pobrania i poeksperymentowania. Oraz do zamieszczania komentarzy pod tym wpisem!
Następny wpis już niedługo!