Jeszcze zostało kilka dni września, może zdążę dostarczyć grę miesiąca na czas 🙂 . Będzie to na pewno wyzwanie, ponieważ postanowiłem tym razem odejść od Phasera, którego znam już prawie na wylot.
Postanowiłem wykorzystać projekt Gra Co miesiąć, aby podciągnąć swoje umiejętności programowania w TypeScript. A właściwie, to żeby w ogóle jakieś umiejętności zdobyć. Póki co moje doświadczenia z TSem były czysto teoretyczne. Czas na trochę praktyki! 🙂
Aktualną wersję gry przetestować można klikając w obrazek powyżej. Na moim githubie znajduje się repozytorium zawierające jak najaktualniejszy stan gry. Wersja przedstawiona w poście nie będzie dostępna w źródle strony. To czego używa przeglądarka to przetranspilowany na JS kod. Wersję TypeScriptową zobaczyć można klikając w ten link.
Najzabawniejsze jest to, że w tej chwili do końca nie wiem jeszcze co to będzie za gra. Narazie przygotowałem tylko główną pętle oraz prosty mechanizm obsługujący stany 🙂 . Sama „gra” to póki co tylko kwadrat, którym można poruszać po małym czarnym polu za pomocą strzałek. Wsparcia dla urządzeń mobilnych tym razem nie ma.
Po naciśnięciu spacji, stan gry zmieni się i gracz będzie kontrolował czerwony kwadrat zamiast białego. Ponownie naciśnięcie spacji obróci sytuację i gra powróci to poprzedniego stanu.
Jeżeli chodzi o system budowania projektu, jest on w tym wypadku zerowy. Korzystam z edytora Atom wyposażonego w plugin TypeScriptowy. Jeżeli chcesz wiedzieć więcej na temat jak to wszystko działa, zachęcam do lektury mojej serii o podstawach TypeScriptu, którą można znaleźć na blogu.
Ok, bez zbędnej gadaniny, czas przejść do kodu. Tym razem całość znajduje się w jednym pliku main.ts. Nie jest to najlepsza praktyka, ale dopóki nie jestem pewny jak obsługiwać moduły w TS, musi tak być. Zresztą brak modułów, powoduje, że później też robię coś nie do końca tak jak powinno się to robić, ale o tym za chwilę. W tym projekcie musi już tak zostać. Następne będą lepsze 🙂 .
Pierwszą rzeczą którą deklaruję w main.ts są interfejsy. Oto one:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
interface vector { x:number, y:number, } interface actor { velocity: vector; x:number; y:number; update(delta:number):void; draw(ctx:CanvasRenderingContext2D):void; } interface istate { created:boolean; readyToChange:boolean; nextState:boolean; actors:actor[]; create():void; update(delta:number):void; draw():void; cleanUp():void; } |
Nie ma tu nic skomplikowanego. Przygotowuje po prostu podwaliny pod główne klasy gry. Jak widać póki co są one dość ogólne. Interfejs vector, definiuję typ, z pomocą którego poruszać będę obiektami. Gra oczywiście w dwóch wymiarach, więc mój wektor posiada tylko pola x oraz y.
Drugi interfejs, określa pola, które wykorzystam do stworzenia klasy obsługującej obiekty występujące w grze. Posiada on trzy pola: pierwsze, velocity, określa aktualną prędkość obiektu. Dwa kolejne, x i y, to położenie obiektu w płaszczyźnie dwuwymiarowej. Po za tym interfejs actor będzie posiadał dwie metody: update, która nic nie zwraca (void) i przyjmuje jedną wartość liczbową, oraz draw, która również nic nie zwraca i przyjmuje referencję do kontekstu rysowania na kanwie (w TS domyślnie zdefiniowany jest już specjalny typ dla tego elementu 🙂 ).
Ostatni interfejs to istate. Definiuje on pola i metody potrzebne w klasie reprezentującej stany gry. Pierwsze trzy metody wykorzystywane są do obsługi zmiany stanu i wszystkie są typu boolowskiego. Dokładnie ich działanie opiszę, gdy przejdę do opisu klasy State oraz głównej pętli gry. Kolejne pole interfejsu istate to tablica actors przechowująca obiekty typu actor. Następnie w stanie pojawiają się cztery metody: created, uruchamiana raz podczas tworzenia stanu, metody update oraz draw uruchamiane cyklicznie podczas przebiegu głównej pętli gry oraz metoda cleanUp uruchamiana na koniec życia stanu.
Kolejny element programu to definicje klas State oraz Rect. Zacznę od opisu pierwszej z klas:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
class State implements istate { constructor(private ctx:CanvasRenderingContext2D,private pressedKeys:Object){}; public spacePressed = false; public created = false; public nextState = false; public readyToChange = false; public actors:actor[] = [] public create() { console.log("state created"); this.created = true; this.nextState = false; } public update(delta:number) { for(var i:number = 0; i < this.actors.length; i++){ this.actors[i].update(delta); } if(this.nextState){ this.cleanUp(); } if(this.pressedKeys[32]){ if(!this.spacePressed){ this.nextState = true; this.spacePressed = true; } } else { this.spacePressed = false; } } public draw() { for(var i:number = 0; i < this.actors.length; i++){ this.actors[i].draw(this.ctx); } } public cleanUp() { console.log("state removed"); this.created = false; for(var i:number = 0; i < this.actors.length; i++){ delete this.actors[i] } this.actors = []; this.readyToChange = true; this.nextState = false; } } |
Klasa implementuje interfejs istate. Dzięki temu wiem, że nie pominę żadnego ważnego elementu w stanie. Na początku deklaruję konstruktor. W argumentach otrzymuje on referencje do kontekstu kanwy oraz do obiektu pressedKeys, w którym zapisane będą informacje o aktualnie wciśniętych przyciskach.
Następny element to pole spacePressed. Jest to element pomocniczy, służący do kontrolowania zmiany stanów. W ostatecznej wersji raczej nie będzie potrzebny stąd jego brak w interfejsie. Kolejne trzy pola są za to ważne. created, readyToChange oraz nextState, są domyślnie ustawione na false. Ostatnie pole actors, to początkowo pusta tablica.
Pozostała część klasy State, to metody. Pierwsza z nich created, najpierw loguje informacje o tym, że nowy stan został stworzony a następnie ustawia wartości pól created na true oraz nextState na false.
Kolejna metoda to update. Najpierw przechodzi ona przez wszystkie elementy tablicy actors i wywołuje metodę update każdego z nich. Jako argument przekazuję wartość zmiennej delta, którą sama otrzymuje podczas wywołania. delta, zawierać będzie informacje o tym ile czasu upłynęło od ostatniego wywołania update. Następnie, jeżeli wartość pola nextState jest true, wywoływana jest metoda CleanUp. Kolejny krok update to sprawdzenie czy pole o kluczu 32 w obiekcie PressedKeys równe jest true. Jeżeli tak, oznacza to, że spacja została wciśnięta a metoda sprawdza, czy wartość pola spacePressed wynosi false. Gdy i to zostanie potwierdzone, zmieniane są wartości pól nextState oraz spacePressed na true. Jeżeli spacja nie jest wciśnięta wartość pola spacedPressed ustawiana jest na false.
Kolejna metoda to draw. Jest ona bardzo prosta: dla każdego elementu tablicy actors wywoływana jest jego metoda draw. Jako argument przekazywana jest referencja do kontekstu 2d kanwy.
Ostatnia metoda klasy State to claeanUp. Na początku loguje ona odpowiednią informację w konsoli a następnie usuwa wszystkie elementy tablicy actors. Do tego zmienia ona wartości pól created oraz nextState na false i pole readyToChange na true.
Kolejna klasa to Rect. Jej instancje reprezentować będą kwadraty pojawiające się w grze:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
class Rect implements actor { constructor(public x:number, public y:number, public keyObj:Object, private color:string = "white"){} public velocity = {x:0,y:0} public update(delta:number):void { if(this.keyObj['39']){ this.velocity.x = 0.08; } else if (this.keyObj['37']) { this.velocity.x = -0.08; } else { this.velocity.x = 0; }; if(this.keyObj['40']){ this.velocity.y = 0.08; } else if (this.keyObj['38']) { this.velocity.y = -0.08; } else { this.velocity.y = 0; }; this.x += this.velocity.x * delta; this.y += this.velocity.y * delta; } public draw(ctx):void{ ctx.save(); ctx.fillStyle = this.color; ctx.fillRect(this.x, this.y, 40, 40); ctx.restore(); } } |
Klasa Rect posiada bardzo podstawowe mechanizmy. Konstruktor przyjmuje w parametrach współrzędne obiektu na płaszczyźnie dwuwymiarowej, referencje do obiektu z informacjami o przyciśniętych przyciskach, oraz łańcuch znaków z informacją o kolorze obiektu (domyślnie biały).
Metoda update póki co pełni bardzo podstawowe funkcje. Sprawdza czy naciśnięte zostały któreś ze strzałek i odpowiednio modyfikuje pola w obiekcie vector. Następnie wartości wektora nanoszone są na współrzędne uwzględniając deltę.
Metoda draw po prostu rysuje kwadrat na kanwie. Tak jak pisałem, nic skomplikowanego.
I teraz przechodzę do głównej części programu, czyli to metody onlad okna przeglądarki. Tutaj zaimplementowałem główną pętle gry, która łączy to wszystko ‚do kupy’. Tutaj też wychodzi mały grzeszek projektu. Sporo danych wycieka do globalnego scope’a. Ponieważ póki co musi tak zostać, w przyszłości wypadałoby zastosować jakiegoś rodzaju moduły.
Oto pozostała część kodu znajdującego się w pliku main.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
window.onload = () => { var canvas:HTMLCanvasElement = <HTMLCanvasElement>document.getElementById("canvas"); var ctx:CanvasRenderingContext2D = canvas.getContext("2d"); var lastFrameTimeMs:number = 0; var delta:number = 0; var timestep:number = 1000/60; var currState:State; var stateMap:Object = { "mainState": mainState, "redState": redState } var currStateKey:string = "mainState"; var pressedKeys:Object = {}; function mainLoop(timestamp) { if(!currState.created && !currState.readyToChange){ currState.create(); requestAnimationFrame(mainLoop); } else if(currState.readyToChange){ currState.readyToChange = false; if(currStateKey === "mainState"){ currStateKey = "redState"; currState.actors.push(new Rect(10,10,pressedKeys,"red")); } else { currStateKey = "mainState"; currState.actors.push(new Rect(10,10,pressedKeys,"white")); } requestAnimationFrame(mainLoop); } else { var numUpdateSteps:number = 0; ctx.clearRect(0, 0, 300, 300); delta += timestamp - lastFrameTimeMs; lastFrameTimeMs = timestamp; while (delta >= timestep) { currState.update(timestep); delta -= timestep; if (++numUpdateSteps >= 240) { delta = 0; break; } } currState.draw(); requestAnimationFrame(mainLoop); } } var keyboardDown = (event: KeyboardEvent) => { pressedKeys[event.keyCode] = true; } var keyboardUp = (event: KeyboardEvent) => { if(pressedKeys[event.keyCode]){ delete pressedKeys[event.keyCode] }; } var mainState = new State(ctx,pressedKeys); var redState = new State(ctx,pressedKeys); currState = stateMap[currStateKey]; mainState.actors.push(new Rect(10,10,pressedKeys)); document.addEventListener('keydown', keyboardDown); document.addEventListener('keyup', keyboardUp); requestAnimationFrame(mainLoop); } |
Całość kodu zawinięta jest wewnątrz funkcji onload, czyli wykona się, gdy tylko dokument html będzie gotowy. Trochę mi ta funkcja spuchła, w przyszłości trzeba będzie ją uszczuplić, ale póki co niech będzie tak jak jest.
Najpierw deklaruję wszystkie potrzebne mi zmienne. Pierwsze dwie to referencja do elementu canvas, oraz jego kontekstu. Następne trzy: lastFrametimeMs, delta oraz timeStep służą do obsługi pętli gry. Kolejne trzy: currState, stateMap i currStateKey potrzebne są do zarządzania stanami. Ostania zmienna to obiekt pressedKeys, to tu zapisywać będę informacje o przyciśniętych klawiszach.
Kolejny element to definicja funkcji mainLoop czyli mojej pętli gry. Nie będę dokładnie opisywał wszystkie co tu się dzieje. Zastosowałem bardziej zaawansowane techniki niż te w moich pierwszych grach. Wbrew pozorom nie są one mocno skomplikowane, jednak chciałbym poświęcić temu zagadnieniu osobny post. To co jest ważne dla opisywanej tu gry, to to, że stan jest aktualizowany równomiernie i niezależnie od rysowania.
Po za aktualizowaniem i rysowaniem, główna pętla sprawdza też czy nie jest czas na zmianę stanu. Zajmuje się tym pierwsza jej połowa (do else). Jeżeli stan jest gotowy do zmiany, odpalany jest kolejny. Póki co wstawiłem tu prowizoryczny mechanizm który na zmianę przełącza dwa stany. Będzie się to działo po wciśnięciu spacji, taki jak zaprogramowałem to w klasie State.
Po mainLoop znajdują się metody keyboardDown i keyboardUp. Jedna dodaje do pressedKeys pole o kluczu równym kodowi przyciśniętego klawisza a druga usuwa to pole. Jak łatwo się domyślić, metody te wywoływane będą w wyniku eventów keyUp oraz keyDown (zresztą robię to przypisanie kilka linijek dalej). Na co warto zwrócić uwagę to to, że event ma różne typy w TSie, ja korzystam z typu KeyboardEvent oraz na to jak zapisałem deklarację funkcji. Użycie „grubej strzałki” sprawia, że nie muszę martwić się o zmianę scope’a w evencie. TS zajmuje się tym za mnie 🙂
Na koniec zostaje już tylko stworzenie instancji stanów, dodanie do nich aktorów i uruchomienie głównej pętli poprzez requestAnimationFrame. I gotowe 🙂 .
Mam nadzieję, że udało mi się w miarę jasno przekazać jak działa „gra” w aktualnym stanie. Jak zawsze, w razie jakichkolwiek pytań, nie wahaj się zostawić komentarz po niżej. Chętnie rozwieje wszelkie wątpliwości.
Jeżeli chcesz być na bieżąco z postami na blogu zachęcam do polubienia mojej strony na facebooku. Zawsze zamieszczam tam informacje o wszystkich nowościach. Jest to też dobre miejsce na kontakt ze mną. Na wszystkie pytania zawsze odpowiem :). Do przeczytania.