Studium przypadku – Onslaught! Hala widowiskowa

Wprowadzenie

W czerwcu 2010 r. dowiedzieliśmy się, że lokalny wydawca Boing Boing organizuje konkurs na stworzenie gry. Uznaliśmy, że to świetna okazja do stworzenia prostej gry w JavaScript i <canvas>, więc zabraliśmy się do pracy. Po zakończeniu konkursu mieliśmy jeszcze wiele pomysłów i chcieliśmy dokończyć to, co zaczęliśmy. Oto przykładowy przypadek zastosowania: mała gra Onslaught! Hala.

Retro, piksele

W związku z założeniami konkursu, w którym trzeba było stworzyć grę na podstawie chiptune, zależało nam na tym, aby nasza gra wyglądała i działała jak retro gra na Nintendo Entertainment System. Większość gier nie wymaga tego, ale jest to nadal popularny styl artystyczny (szczególnie wśród niezależnych deweloperów) ze względu na łatwość tworzenia zasobów i naturalne odwołanie do nostalgicznych graczy.

Atak! Rozmiary pikseli areny
Zwiększenie rozmiaru piksela może ułatwić pracę nad projektem graficznym.

Ze względu na to, że te sprite’y są bardzo małe, postanowiliśmy podwoić liczbę pikseli, co oznacza, że sprite 16 x 16 będzie teraz mieć 32 x 32 piksele itd. Od samego początku kładliśmy większy nacisk na tworzenie komponentów niż na to, aby przeglądarka wykonywała ciężką pracę. Było to po prostu łatwiejsze do wdrożenia, ale miało też pewne zalety wizualne.

Oto rozpatrzony przez nas scenariusz:

<style>
canvas {
  width: 640px;
  height: 320px;
}
</style>
<canvas width="320" height="240">
  Sorry, your browser is not supported.
</canvas>

Ta metoda polega na użyciu sprite’ów 1 x 1 zamiast podwójnych sprite’ów podczas tworzenia zasobu. Następnie CSS przejmie kontrolę i zmieni rozmiar samego obrazu. Nasze testy porównawcze wykazały, że ta metoda może być około 2 razy szybsza niż renderowanie większych (podwójnych) obrazów, ale niestety zmiana rozmiaru za pomocą kodu CSS obejmuje wygładzanie krawędzi, czego nie udało nam się zapobiec.

Opcje zmiany rozmiaru płótna
Po lewej: zasoby z zachowanymi szczegółami w każdym pikselu, które zostały podwojone w Photoshopie. Po prawej: zmiana rozmiaru za pomocą CSS spowodowała rozmycie.

W przypadku naszej gry było to nie do przyjęcia, ponieważ pojedyncze piksele są bardzo ważne. Jeśli jednak musisz zmienić rozmiar obrazu, a wygładzanie krawędzi jest odpowiednie dla Twojego projektu, możesz rozważyć to rozwiązanie ze względu na wydajność.

Zabawne sztuczki z obszarem roboczym

Wszyscy wiemy, że <canvas> to nowy hit, ale czasami deweloperzy nadal zalecają korzystanie z DOM. Jeśli nie wiesz, którego z nich użyć, oto przykład tego, jak <canvas> zaoszczędzył nam dużo czasu i energii.

Gdy wroga trafisz w Onslaught! Arena, mignie na czerwono i na krótko wyświetli animację „bólu”. Aby ograniczyć liczbę grafik, które musieliśmy utworzyć, wrogowie są widoczni tylko w stanie „bólu” skierowanym w dół. Wygląda to całkiem nieźle w grze i zaoszczędziło mi mnóstwo czasu na tworzenie sprite’ów. W przypadku bossów dziwnie wyglądało, gdy duży sprite (64 x 64 piksele lub więcej) nagle zmieniał kierunek z lewej lub z góry na dół, aby pokazać ramkę bólu.

Oczywistym rozwiązaniem byłoby narysowanie ramki dla każdego bossa w ośmiu kierunkach, ale zajęłoby to bardzo dużo czasu. Dzięki temu, że <canvas>, udało nam się rozwiązać ten problem w kodzie:

Beholder zadający obrażenia w Onslaught Hala widowiskowa
Za pomocą context.globalCompositeOperation można uzyskać ciekawe efekty.

Najpierw rysujemy potwora w ukrytym „buforze” <canvas>, nakładamy go na czerwono, a potem renderujemy wynik na ekranie. Kod wygląda mniej więcej tak:

// Get the "buffer" canvas (that isn't visible to the user)
var bufferCanvas = document.getElementById("buffer");
var buffer = bufferCanvas.getContext("2d");

// Draw your image on the buffer
buffer.drawImage(image, 0, 0);

// Draw a rectangle over the image using a nice translucent overlay
buffer.save();
buffer.globalCompositeOperation = "source-in";
buffer.fillStyle = "rgba(186, 51, 35, 0.6)"; // red
buffer.fillRect(0, 0, image.width, image.height);
buffer.restore();

// Copy the buffer onto the visible canvas
document.getElementById("stage").getContext("2d").drawImage(bufferCanvas, x, y);

Pętla gry

Programowanie gier różni się w pewnym stopniu od tworzenia stron internetowych. W przypadku architektury sieciowej często zdarza się, że na zdarzenia odpowiadają detektory zdarzeń. Kod inicjalizacji może więc nie robić nic poza nasłuchiwaniem zdarzeń związanych z wejściami. Logika gry jest inna, ponieważ musi być stale aktualizowana. Jeśli na przykład gracz się nie poruszył, nie powinno to powstrzymywać goblinów przed jego zjedzeniem.

Oto przykład pętli gry:

function main () {
  handleInput();
  update();
  render();
};

setInterval(main, 1);

Pierwsza ważna różnica polega na tym, że funkcja handleInput nie robi niczego od razu. Jeśli użytkownik naciśnie klawisz w typowej aplikacji internetowej, warto, aby natychmiast wykonała się odpowiednia czynność. W grze wszystko musi się jednak dziać w kolejności chronologicznej, aby zachować płynność rozgrywki.

window.addEventListener("mousedown", function(e) {
  // A mouse click means the players wants to attack.
  // We don't actually do that yet, but instead tell the rest
  // of the program about the request.
  buttonStates[e.button] = true;
}, false);

function handleInput() {
  // Here is where we respond to the click
  if (buttonStates[LEFT_BUTTON]) {
    player.attacking = true;
    delete buttonStates[LEFT_BUTTON];
  }
};

Teraz znamy dane wejściowe i możemy je uwzględnić w funkcji update, wiedząc, że będą one zgodne z resztą zasad gry.

function update() {
  // Check for collisions, states, whatever else is needed

  // If after that the player can still attack, do it!
  if (player.attacking && player.canAttack()) {
    player.attack();
  }
};

Gdy wszystko zostanie obliczone, czas na ponowne narysowanie ekranu. W świecie DOM przeglądarka wykonuje całą ciężką pracę. Jednak przy użyciu funkcji <canvas> trzeba ręcznie odświeżać obraz za każdym razem, gdy coś się dzieje (co zwykle oznacza każdy pojedynczy kadr).

function render() {
  // First erase everything, something like:
  context.clearRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);

  // Draw the player (and whatever else you need)
  context.drawImage(
    player.getImage(),
    player.x, player.y
  );
};

Modelowanie według czasu

Modelowanie oparte na czasie to koncepcja przemieszczania sprite’ów na podstawie upływu czasu od ostatniej aktualizacji klatki. Ta technika pozwala na jak najszybsze działanie gry przy jednoczesnym zapewnieniu, że stwory poruszają się z jednakową prędkością.

Aby korzystać z modelowania opartego na czasie, musimy zarejestrować upływ czasu od narysowania ostatniej klatki. Aby to śledzić, musimy rozszerzyć funkcję update() w pętli gry.

function update() {

  // NOTE: You'll need to initially seed this.lastUpdate
  // with the current time when your game loop starts
  // this.lastUpdate = Date.now();

  // Calculate elapsed time since last frame
  var now = Date.now();
  var elapsed = (now - this.lastUpdate);
  this.lastUpdate = now;

  // Do stuff with elapsed

};

Teraz, gdy znamy upływ czasu, możemy obliczyć, jak daleko dany sprite powinien przesunąć się w każdej klatce. Najpierw musimy śledzić kilka rzeczy dotyczących obiektu sprite: bieżącą pozycję, prędkość i kierunek.

var Sprite = function() {

  // The sprite's position relative to the top left of the game world
  this.position = {x: 0, y: 0};

  // The sprite's direction. A positive x value indicates moving to the right
  this.direction = {x: 1, y: 0};

  // How many pixels the sprite moves per second
  this.speed = 50;
};

Mając na uwadze te zmienne, w ten sposób możemy przesunąć instancję powyższej klasy sprite’a za pomocą modelowania czasowego:

// Determine how far this sprite will move this frame
var distance = (sprite.speed / 1000) * elapsed;

// Apply the movement distance to the sprite's current position
// taking into account its direction
sprite.position.x += (distance * sprite.direction.x);
sprite.position.y += (distance * sprite.direction.y);

Pamiętaj, że wartości direction.xdirection.y powinny być znormalizowane, co oznacza, że zawsze powinny mieścić się w przedziale od -1 do 1.

Elementy sterujące

Podczas tworzenia Onslaught! Hala. Pierwsza wersja demonstracyjna obsługiwała tylko klawiaturę. Gracze poruszali główną postacią po ekranie za pomocą klawiszy strzałek, a strzelali w kierunku, w którym patrzył bohater, za pomocą klawisza spacji. Chociaż jest to dość intuicyjne i łatwe do zrozumienia, czyni to grę prawie niemożliwą do grania na trudniejszych poziomach. W każdej chwili na gracza spływa dziesiątki wrogów i pocisków, dlatego musi on umieć przeciskać się między nimi, podczas gdy strzela w dowolnym kierunku.

Aby gra była porównywalna z podobnymi grami z tego gatunku, dodaliśmy obsługę myszy, która umożliwia sterowanie celownikiem, którego używa postać do celowania atakami. Postać nadal można było przesuwać za pomocą klawiatury, ale po tej zmianie mogła jednocześnie strzelać we wszystkich kierunkach w zakresie 360°. Gracze ceniący sobie wyzwania docenili tę funkcję, ale miała ona niefortunny efekt uboczny w postaci frustracji użytkowników sterowania dotykowego.

Atak! Okno kontrolek areny (wycofane)
Stare elementy sterujące lub okno „Jak grać” w Onslaught Arena.

Aby dostosować się do potrzeb użytkowników trackpadów, przywróciliśmy klawisze strzałek, tym razem z możliwością strzelania w kierunkach naciśniętych klawiszy. Chociaż uważaliśmy, że nasza gra jest przeznaczona dla wszystkich graczy, nieświadomie wprowadziliśmy do niej zbyt wiele złożoności. Ku naszemu zaskoczeniu okazało się, że niektórzy gracze nie wiedzieli o dodatkowych opcjach sterowania atakiem za pomocą myszy (lub klawiatury!), mimo że w treściach samouczka, które zostały w dużej mierze zignorowane,

Atak! Samouczek dotyczący sterowania w grze Arena
Gracze zazwyczaj ignorują samouczek, wolą grać i bawić się.

Mamy też szczęście, że mamy wielu fanów w Europie, ale słyszymy od nich, że nie mają typowych klawiatur QWERTY i nie mogą używać klawiszy WASD do poruszania się w kierunkach. Gracze leworęczni również zgłaszali podobne problemy.

Z powodu złożonego schematu sterowania, który wdrożyliśmy, istnieje też problem z grą na urządzeniach mobilnych. W związku z tym jednym z najczęstszych próśb jest dodanie Onslaught! Arena dostępna na urządzeniach z Androidem, iPadach i innych urządzeniach dotykowych (na których nie ma klawiatury). Jedną z głównych zalet HTML5 jest przenośność, więc przeniesienie gry na te urządzenia jest jak najbardziej możliwe. Musimy tylko rozwiązać wiele problemów (zwłaszcza związanych z sterowaniem i wydajnością).

Aby rozwiązać te problemy, zaczęliśmy testować rozgrywkę z jednym sposobem sterowania, który wymaga tylko myszy (lub interakcji dotykowej). Gracze klikają lub dotykają ekranu, a główna postać podąża w stronę wskazanego miejsca, automatycznie atakując najbliższego przeciwnika. Kod wygląda mniej więcej tak:

// Find the nearest hostile target (if any) to the player
var player = this.getPlayerObject();
var hostile = this.getNearestHostile(player);
if (hostile !== null) {
  // Found one! Shoot in its direction
  var shoot = hostile.boundingBox().center().subtract(
    player.boundingBox().center()
  ).normalize();
}

// Move towards where the player clicked/touched
var move = this.targetReticle.position.clone().subtract(
  player.boundingBox().center()
).normalize();
var distance = this.targetReticle.position.clone().subtract(
  player.boundingBox().center()
).magnitude();

// Prevent jittering if the character is close enough
if (distance < 3) {
  move.zero();
}

// Move the player
if ((move.x !== 0) || (move.y !== 0)) {
  player.setDirection(move);
}

Usunięcie konieczności celowania w przeciwników może ułatwić rozgrywkę w niektórych sytuacjach, ale uważamy, że uproszczenie gry przyniesie wiele korzyści. Pojawiają się inne strategie, takie jak konieczność ustawienia postaci w pobliżu niebezpiecznych wrogów, aby na nich celować. Niezwykle przydatna jest też możliwość obsługi urządzeń dotykowych.

Audio

Oprócz interfejsu i wydajności jednym z głównych problemów podczas tworzenia Onslaught! Arena to tag <audio> w HTML5. Najgorszym aspektem jest opóźnienie: w prawie wszystkich przeglądarkach występuje opóźnienie między wywołaniem funkcji .play() a faktycznym odtworzeniem dźwięku. Może to zepsuć wrażenia gracza, zwłaszcza w przypadku gier o szybkim tempie, takich jak nasza.

Inne problemy to niewywoływanie zdarzenia „progress”, które może spowodować zawieszenie się procesu wczytywania gry na stałe. Z tego powodu wprowadziliśmy tak zwaną metodę „fall-forward”, w której w przypadku nieudanego załadowania Flasha przełączamy się na audio w formacie HTML5. Kod wygląda mniej więcej tak:

/*
This example uses the SoundManager 2 library by Scott Schiller:
http://www.schillmania.com/projects/soundmanager2/
*/

// Default to sm2 (Flash)
var api = "sm2";

function initAudio (callback) {
  switch (api) {
    case "sm2":
      soundManager.onerror = (function (init) {
        return function () {
          api = "html5";
          init(callback);
        };
      }(arguments.callee));
      break;
    case "html5":
      var audio = document.createElement("audio");

      if (
        audio
        && audio.canPlayType
        && audio.canPlayType("audio/mpeg;")
      ) {
        callback();
      } else {
        // No audio support :(
      }
      break;
  }
};

Ważne może być też, aby gra obsługiwała przeglądarki, które nie odtwarzają plików MP3 (np. Mozilla Firefox). W takim przypadku może zostać wykryta obsługa i przełączona na Ogg Vorbis, z kodem takim jak ten:

/*
Note: you could instead use "new Audio()" here,
but the client will throw an error if it doesn't support Audio,
which makes using "document.createElement" a safer approach.
*/

var audio = document.createElement("audio");

if (audio && audio.canPlayType) {
  if (!audio.canPlayType("audio/mpeg;")) {
    // Here you know you CANNOT use .mp3 files
    if (audio.canPlayType("audio/ogg; codecs=vorbis")) {
      // Here you know you CAN use .ogg files
    }
  }
}

Zapisywanie danych

Nie ma gier z automatów bez wysokich wyników. Wiedzieliśmy, że niektóre dane gry muszą być trwałe. Mogliśmy użyć czegoś starego, jak pliki cookie, ale chcieliśmy poznać nowe technologie HTML5. Dostępnych jest wiele opcji, w tym pamięć lokalna, pamięć sesji i bazy danych Web SQL.

ALT_TEXT_HERE
Najlepsze wyniki są zapisywane, podobnie jak Twoja pozycja w grze po pokonaniu każdego bossa.

Zdecydowaliśmy się użyć localStorage, ponieważ jest to nowe, świetne i łatwe w użyciu. Obsługuje zapisywanie podstawowych par klucz-wartość, które są potrzebne do prostej gry. Oto prosty przykład użycia tej funkcji:

if (typeof localStorage == "object") {
  localStorage.setItem("foo", "bar");
  localStorage.getItem("foo"); // Value is "bar"
  localStorage.removeItem("foo");
  localStorage.getItem("foo"); // Value is now null
}

Należy jednak pamiętać o kilku „pułapkach”. Niezależnie od tego, co przekazujesz, wartości są przechowywane jako ciągi znaków, co może prowadzić do nieoczekiwanych wyników:

localStorage.setItem("foo", false);
typeof localStorage.getItem("foo"); // Value is "false" (a string literal)
if (localStorage.getItem("foo")) {
  // It's true!
}

// Don't pass objects into setItem
localStorage.setItem("bar", {"key": "value"});
localStorage.getItem("bar"); // Value is "[object Object]" (a string literal)

// JSON stringify and parse when dealing with localStorage
localStorage.setItem("json", JSON.stringify({"key": "value"}));
typeof localStorage.getItem("json"); // string
JSON.parse(localStorage.getItem("json")); // {"key": "value"}

Podsumowanie

Praca z HTML5 jest niesamowita. Większość implementacji obsługuje wszystko, czego potrzebuje deweloper gier, od grafiki po zapisywanie stanu gry. Chociaż pojawiają się pewne problemy (np. z tagiem <audio>), deweloperzy przeglądarek szybko się rozwijają, a obecne wyniki są już bardzo dobre, więc przyszłość gier opartych na HTML5 rysuje się w jasnych barwach.

Atak! Arena z ukrytym logo HTML5
Podczas gry Onslaught możesz zdobyć tarczę HTML5, wpisując „html5”. Arena.