Czas na kolejny wpis z serii gra co miesiąc. Tym razem tworzę JavaScriptowy remake starej gry Jetpac. Kiedys trochę grałem w tę grę na wysłużonym komputerze ZX Spectrum i bardzo mi się podobała. Szczerze mówiąc od dawna chciałem ją odtworzyć. W końcu mam dość umiejętności aby tego dokonać 🙂
W grze mam do czynienia z podstawowymi prawami fizyki. Na postać gracza działa grawitacja i jeżeli żadna siła nie będzie unosić go w górę, zacznie opadać. Na szczęście, wyposażony jest w tytułowy jetpack, czyli plecak odrzutowy 🙂 Dzięki temu może się wznieść i wylądować na platformach znajdujących się w powietrzu (nie, na platformy grawitacja nie działa, cicho!).
Aby sprawdzić jak działa aktualna wersja gry, wystarczy kliknąć w obrazek powyżej. Jak zawsze, Przygotowałem też paczkę z kodem oraz grafikami gry.
Poprzednią grę wrzuciłem na parę for o programowaniu. Chciałem uzyskać opinie bardziej doświadczonych developerów na temat mojej pracy. Jedną z rzeczy, na którą zwracano mi uwagę to to, że wartości gry dostępne są w globalnej przestrzeni nazw. Tym razem chciałem tego uniknąć więc zastosowałem bardzo prosty wzorzec modułu.
Cały kod gry znajduje się w samowywołującej się funkcji, która zwraca jedynie metodę uruchamiającą grę. Jest ona przypisywana do globalnej zmiennej game. Taki zabieg pozwala metodzie uruchamiającej na dostęp do danych, które nie są dostępne w przestrzeni globalnej. Jeżeli nie jesteś pewny o czym piszę, poszukaj w internecie informacji na temat wzorca modułu w JS, enakspulacji oraz domknięć. Ten post to nie czas i miejsce na tłumaczenie szczegółów 🙂
Kolejną ciekawą uwagą jaką otrzymałem odnośnie poprzedniej gry, było to, że używam requestAnimationFrame do zadań niezwiązanych z rysowaniem na płótnie. Zamiast tego zasugerowano abym skorzystał z dwóch pętli. setInterval do kontrolowania aktualizacji danych i rAF do rysowania klatek. Spróbowałem wdrożyć to rozwiązanie. Nie wiem czy do końca mi się udało 🙂 rAF wciąż jest główną pętla, jednak za aktualizacje danych w konkretnych stanach odpowiadają funkcje setInterval. Są one inicjalizowane wraz ze stanami gry i zatrzymywane, gdy stan się zmienia.
JSetpac: pierwsza odsłona – kod
Ok, czas na kod. Postaram nie rozwodzić się nad oczywistymi fragmentami. Skupię się nad tymi rozwiązaniami, które są nowe. Na początku znajdują się pola i metody głównego modułu:
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 |
var self = this; this.canvas = canvas; this.ctx = ctx; this.keys = { SPACE: 32, UP: 38, DOWN: 40, LEFT: 37, RIGHT: 39, } this.config = { masterSprite: undefined, assets: [], loadedAssets: 0, fps: 60, currentState: "loadingState", spriteSize: 64, pressedKeys: {}, gravity: 40, }; this.spriteObject = { sourceX: 0, sourceY: 0, sourceWidth: this.config.spriteSize, sourceHeight: this.config.spriteSize, x: 0, y: 0, width: this.config.spriteSize, height: this.config.spriteSize, centerY: function() { return this.y + this.height/2 }, centerX: function() { return this.x + this.width/2 }, }; this.activateKeys = function(){ window.addEventListener("keydown", function keydown(e) { self.config.pressedKeys[e.keyCode] = true; },false) window.addEventListener("keyup", function keydown(e) { delete self.config.pressedKeys[e.keyCode]; },false) }; this.mainLoop = function(){ window.requestAnimationFrame(self.mainLoop,self.canvas); if(self[self.config.currentState].init){ if(!self[self.config.currentState].initialised){ self[self.config.currentState].init(); } } if(self[self.config.currentState].draw){ self[self.config.currentState].draw(); } }; this.start = function(){ this.activateKeys(); this.mainLoop(); } |
Oczywiście najpierw pojawia się zmienna self, dzięki której będę mógł odnosić się do tego obiektu z wnętrza innych obiektów.
Następnie tworzę pole keys. Zawiera ono tekstowe nazwy dla wartości przycisków. Dzięki temu kod będzie bardziej czytelny. Kolejne pole config zawiera wszystkie zmienne związane z ustawieniami gry. W głównym stanie gry znajduje się również obiekt spriteObject, którego używać będę jako wzorca do tworzenia nowych obiektów wewnątrz stanów gry.
Dalsze cześć to trzy metody głównego obiektu. Pierwsza to activateKeys. Działa ona identycznie jak w przypadku poprzedniej gry. Kod wciśnietego klawisza zapisywany jest jako pole o wartości true w odpowiednim obiekcie. Po puszczeniu przycisku, pole kodu usuwane jest z obiektu pressedKeys.
Kolejna metoda, mainLoop, również działa podobnie do jej odpowiednika w poprzedniej grze. Wewnątrz metody, tworzona jest pętla, na bazie requestAnimationFrame. W pętli sprawdzane jest czy obecny stan jest zainicjalizowany i czy posiada metodę draw. W zależności od wyników tego sprawdzania, metoda wykonuje odpowiednie akcje. Tak jak zaznaczyłem wcześniej, nic więcej nie dzieje się wewnątrz pętli rAF, tylko zmiana stanu i rysowanie.
Ostatnia metoda to start. Odpala ona po prostu dwie poprzednie.
Pozostała część kodu to stany gry. Pierwszy stan to loadingState:
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 |
this.loadingState = { initialised: false, counter: 0, gameLoop: undefined, text: "Loading", init: function() { console.log("loading state initialised"); this.initialised = true; this.loadAssets(); this.gameLoop = setInterval(function(){ self[self.config.currentState].update(); },200) }, draw: function() { self.ctx.clearRect(0,0,self.canvas.width,self.canvas.height); self.ctx.font="20px Arial"; self.ctx.fillStyle = '#000'; self.ctx.textAlign = "left"; self.ctx.fillText(this.text,20,self.canvas.height/2-20); }, update: function(){ if(this.counter < 3){ this.text += '.'; this.counter++; } else { this.counter = 0; this.text = "Loading"; } if(self.config.loadedAssets === self.config.assets.length && self.config.assets.length > 0){ clearInterval(self[self.config.currentState].gameLoop); self[self.config.currentState].initialised = false; self.config.currentState = "menuState"; } }, loadAssets : function(){ self.config.masterSprite = new Image(); self.config.masterSprite.src = "GFX/spriteSheet.png"; self.config.masterSprite.addEventListener("load",function(){self.config.loadedAssets++},false) self.config.assets.push(self.config.masterSprite); } } |
Ttutaj kod również bardzo przypomina ten sam stan z poprzedniej gry. Rożnicą jest to, że teraz w metodzie init aktywuję pętle setInterval, która z kolei wywołuje metodę update stanu co sekundę dzieloną na zdefiniowaną liczbę klatek na sekundę (coś podobnego robiłem już w jednym z wcześniejszych projektów). To właśnie ta druga pętla, o której wspominałem wcześniej. Podobna znajduje się wewnątrz każdego stanu. Uaktualniają one aktualny stan i pozwalają funkcji requestAnimationFrame zająć się przede wszystkim rysowaniem na canvas.
Po za tą jedną różnicą ten stan jest bardzo podobny do tego z poprzedniej gry. Funkcja update uaktualnia napis wyświetlany na płótnie, oraz sprawdza czy wszystkie grafiki dodane przez metodę loadAssets są już gotowe. Jeżeli zadeklarowane obrazki zostały wczytane, stan zmienia się na kolejny.
Mechanizm zmiany stanów również zbytnio się nie zmienił. Jeżeli warunek zmiany stanu zostanie spełniony, metoda update zmienia initalised na false, zatrzymuje pętle setInterval i ustawia nowy stan na głównym obiekcie gry.
Kolejne dwa stany to menuState oraz storyState:
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 |
this.menuState = { initialised: false, init: function(){ console.log("menu state initialised"); this.initialised = true; var gameLoop = setInterval(function(){ self[self.config.currentState].update(); },1000/self.config.fps); }, update: function(){ if(self.config.pressedKeys[self.keys.SPACE]){ clearInterval(self[self.config.currentState].gameLoop); self[self.config.currentState].initialised = false; self.config.pressedKeys = {}; self.config.currentState = "storyState"; } }, draw: function(){ self.ctx.clearRect(0,0,self.canvas.width,self.canvas.height); self.ctx.font="20px Arial"; self.ctx.fillStyle = '#000'; self.ctx.textAlign = "left"; self.ctx.fillText("Menu State",20,self.canvas.height/2-20); } } this.storyState = { initialised: false, init: function(){ console.log("story state initialised"); this.initialised = true; var gameLoop = setInterval(function(){ self[self.config.currentState].update(); },1000/self.config.fps); }, update: function(){ if(self.config.pressedKeys[self.keys.SPACE]){ clearInterval(self[self.config.currentState].gameLoop); self[self.config.currentState].initialised = false; self.config.pressedKeys = {}; self.config.currentState = "gameState"; } }, draw: function(){ self.ctx.clearRect(0,0,self.canvas.width,self.canvas.height); self.ctx.font="20px Arial"; self.ctx.fillStyle = '#000'; self.ctx.textAlign = "left"; self.ctx.fillText("Story will appear here",20,self.canvas.height/2-20); } } |
Te dwa stany to, póki co, tylko placeholdery. Są tu po prostu po to aby trzymać miejsce dla stanu z prawdziwego zdarzenia, który pojawi się w przyszłości. Oba są praktycznie takie same. Zmieniają się w kolejny stan po wciśnięciu przez gracza spacji. To czy przycisk został naciśnięty sprawdzane jest w metodzie update. Nie licząc wewnętrznej pętli uruchamianej za pomocą setInterval, takie same stany znajdowały się już w poprzedniej grze. W kolejnych aktualizacjach zostaną trochę bardziej rozbudowane.
W ten sposób przechodzę do serca tematu czyli głównego stanu gry:
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 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 |
this.gameState = { initialised: false, platforms: [], player: undefined, init: function(){ console.log("game state initialised"); this.initialised = true; this.platform = Object.create(self.spriteObject); this.platform.sourceY = 116; this.platform.height = 40; this.platform.draw = function(){ for(var i = 0;i<this.width/64;i++){ self.ctx.drawImage(self.config.masterSprite, this.sourceX,this.sourceY,this.sourceHeight,this.sourceWidth, this.x+this.sourceWidth*i,this.y,this.sourceWidth,this.sourceHeight) } } this.ground = Object.create(this.platform); this.ground.width = self.canvas.width; this.ground.y = self.canvas.height-this.ground.height; this.platform1 = Object.create(this.platform); this.platform1.width = 3*this.platform1.sourceWidth; this.platform1.y = 5*this.platform1.sourceHeight; this.platform1.x = 2*this.platform1.sourceWidth; this.platform2 = Object.create(this.platform); this.platform2.width = 4*this.platform1.sourceWidth; this.platform2.y = 3*this.platform1.sourceHeight; this.platform2.x = 11*this.platform1.sourceWidth; this.platforms.push(this.ground); this.platforms.push(this.platform1); this.platforms.push(this.platform2); this.player = Object.create(self.spriteObject); this.player.sourceWidth = 42; this.player.sourceHeight = 58; this.player.width = 42; this.player.height = 58; this.player.facing = 0; this.player.sideSpeed = 40; this.player.enginePower = 0; this.player.maxEnginePower = 70; this.player.onGround = false; this.player.frames = 3; this.player.currFrame = 0; this.player.dispFor = 10; this.player.dispTime = 0; this.player.x = self.canvas.width/2-this.player.width/2; this.player.y = self.canvas.height/2-this.player.height/2; this.player.draw = function(){ self.ctx.drawImage(self.config.masterSprite, this.sourceX+this.sourceWidth*this.currFrame,this.sourceY+this.height*this.facing,this.sourceWidth,this.sourceHeight, this.x,this.y,this.width,this.height) }; this.player.update = function() { if(self.config.pressedKeys[self.keys.LEFT] && !self.config.pressedKeys[self.keys.RIGHT]) { this.facing = 1; if(!this.onGround){ this.x -= this.sideSpeed*(1/self.config.fps) } } if(self.config.pressedKeys[self.keys.RIGHT] && !self.config.pressedKeys[self.keys.LEFT]) { this.facing = 0; if(!this.onGround){ this.x += this.sideSpeed*(1/self.config.fps) } } if(self.config.pressedKeys[self.keys.UP]) { this.onGround = false; if(this.dispFor > this.dispTime){ this.currFrame++; if(this.currFrame === this.frames){ this.currFrame = 0; } this.dispFor = 0; } else { this.dispFor++; } if(this.enginePower <= this.maxEnginePower){ this.enginePower +=0.5; } } else { this.currFrame = 0; this.enginePower -= 0.5; if(this.enginePower < 0){ this.enginePower = 0; } } this.y += (self.config.gravity*(1/self.config.fps))-(this.enginePower*(1/self.config.fps)); this.x = Math.max(0, Math.min(this.x, self.canvas.width - this.width)); this.y = Math.max(0, Math.min(this.y, self.canvas.height - this.height)); }; var gameLoop = setInterval(function(){ self[self.config.currentState].update(); },1000/self.config.fps); }, update: function(){ if(this.player){ this.player.update(); } for(var i = 0; i<this.platforms.length;i++){ this.blockRect(this.player,this.platforms[i]) }; }, draw: function(){ self.ctx.clearRect(0,0,self.canvas.width,self.canvas.height); self.ctx.fillStyle = "#000"; self.ctx.fillRect(0,0,self.canvas.width,self.canvas.height) for(var i = 0; i<this.platforms.length;i++){ this.platforms[i].draw(); } this.player.draw(); }, blockRect: function(r1,r2){ var vx = r1.centerX() - r2.centerX(); var vy = r1.centerY() - r2.centerY(); var combinedHalfWidths = r1.width/2 + r2.width/2; var combinedHalfHeights = r1.height/2 + r2.height/2; if(Math.abs(vx) < combinedHalfWidths){ if(Math.abs(vy) < combinedHalfHeights){ var overlapX = combinedHalfWidths - Math.abs(vx); var overlapY = combinedHalfHeights - Math.abs(vy); if(overlapX >= overlapY) { if(vy > 0) { r1.y = r1.y + overlapY; } else { if(r1.hasOwnProperty('onGround')) r1.onGround = true; r1.y = r1.y - overlapY; } } else { if(vx > 0) { r1.x = r1.x + overlapX; } else { r1.x = r1.x - overlapX; } } } } }, }; |
Oprócz standardowego initalised, w tym stanie znajdują się też dwa inne pola: player oraz platforms. Pierwsze to obiekt, który reprezentować będzie postać gracza. Drugie to tablica, w której znajdować się będą platformy, na które może wzlecieć gracz.
Pierwsza metoda to standardowo init. Najpierw oczywiście zmieniam wartość pola initialised, następnie zajmuję się obiektami gry. Na podstawie spriteObject tworzę obiekt platform. Będzie on wzorem dla obiektów przedstawiających platformy oraz podłoże. Oprócz ustawienia unikalnych wartości źródła obrazka oraz rozmiarów, definiuję też metodę draw tego obiektu. Każdy obiekt platformy będzie zawierał w sobie tę metodę. Definiuje ona w jaki sposób jest. Ponieważ podłoża nie będą aktualizowane, nie posiadają metody update.
Kolejny krok to zdefiniowanie dwóch instancji platform, oraz instancji ziemi. Są one tworzone na bazie platform. Każda z nich utrzymuje unikatową szerokość oraz położenie.
Następnie na podstawie spriteObject, tworzę obiekt gracza – player. W nim ustawiam wszystkie pola zawierające potrzebne wartości. Rozmiar obrazka oraz obiektu, pole facing, odpowiadająca za kierunek w którym ‚patrzy’ gracz, pola odpowiadające za prędkość ruchu (sideSpeed,enginePower,maxEnginePower), pole onGround, przechowujące wartość odpowiadającą za to czy gracz stoi na ziemi czy się unosi, pola potrzebne do wyświetlania klatek animacji oraz początkowe współrzędne obiektu.
Po za polami, obiekt player posiada dwie metody draw oraz update. draw jest dość proste. Obrazek reprezentujący obiekt jest rysowany na płótnie, biorąc pod uwagę takie informacje jak aktualna klatka animacji obrazka oraz stronę, w którą ‚patrzy’ obiekt.
Metoda update zawiera już trochę więcej logiki. Najpierw sprawdzam czy wciśnięte zostały przyciski, które mogą zmienić stan obiektu gracza. Te przyciski to strzałki w lewo, prawo oraz w górę. Strzałki wskazujące na boki, o ile gracz nie stoi na ziemi, zmieniają wartość jego współrzędnej x o sideSpeed. sideSpeed wyraża prędkość w pixelach na sekundę, dlatego muszę odpowiednio je zmodyfikować, aby przemieszczenie było realnie oddane w każdej klatce (mnożę tę wartość razy jeden dzielone na liczbę klatek na sekundę). W taki sposób traktuje każdą wartość reprezentującą prędkość w grze.
Oczywiście jeśli gracz porusza się w lewo wartość x jest zmniejszana, w przeciwnym wypadku, wzrasta. Nie zapominam również o aktualizowaniu wartości pola facing.
Efekt naciśnięcia strzałki w górę jest trochę bardziej skomplikowany. Postać gracza posiada plecak odrzutowy, po naciśnięciu strzałki w górę silnik uruchamia się i postać unosi się ku górze, najpierw powoli, ale gdy silnik się rozgrzewa, coraz szybciej, aż osiąga pełną prędkość. Gdy silnik zostanie wyłączony (strzałka w górę nie jest już przyciskana), gracz chwilę jeszcze się unosi, po czym zaczyna opadać na dół.
Teraz tę ideę należy przetłumaczyć na kod. Gdy strzałka w górę jest wciśnięta wartość pola enginePower zaczyna rosnąć, aż osiągnie wartość równą wartości pola maxEnginePower. Gdy strzałka w górę zostanie zwolniona, wartość pola enginePower zaczyna stopniowo się zmniejszać, aż osiąga zero.
Przy okazji, trzymanie strzałki w górę powoduje zmiany klatki animacji obiektu gracza oraz ustawia wartość pola onGround na false.
Po wszystkim aktualizowana jest wartość współrzędnej y obiektu gracza (niezależnie od tego czy jakieś klawisze zostały naduszone czy nie). Najpierw dodaje do niej wartość grawitacji, zdefiniowanej w głównym obiekcie a następnie odejmuje wartość pola enginePower. W ten bardzo prosty sposób udało mi się zamodelować zjawisko grawitacji oraz przyśpieszenia. Jeżeli nie działa moc silnika, gracz opada, jeżeli moc silnika działa i jest większa niż przyciąganie podłoża, gracz unosi się w górę :). Proste! 🙂
Następnie w metodzie update obiektu player. Sprawdzam, czy gracz nie wyleciał poza granice płótna. Jeżeli tak, zostaje zatrzymany.
To koniec inicjalizacji obiektu gracza, Na końcu metody init, uruchamiam jeszcze pętle, która wywołuje metodę update stanu, co określoną ilość czasu.
W metodzie update stanu wywołuje metodę update obiektu gracza, oraz sprawdzam czy nie koliduje z którąś z platform. Do wykrycia kolizji używam metody blockRectangle, którą opisywałem już wcześniej na blogu. Dzięki temu, gracz może ‚stawać’ na platformach.
Wprowadziłem jedna małą zmianę w metodzie blockRectangle. Jeżeli kolizja z graczem nastąpi od góry, pole onGround obiektu zostaje ustawione na true.
I to póki co cała gra. Następna aktualizacja powinna pojawić się niedługo. Postaram się aby był to początek przyszłego tygodnia. Jeżeli chcesz być na bieżąco z aktualizacjami tej gry, zachęcam do polubienia mojej strony na facebooku. Regularnie zamieszczam tam informacje o wszystkich nowościach na blogu.