Studium przypadku – Onslaught! Hala widowiskowa

Wstęp

W czerwcu 2010 roku zauważyliśmy, że w lokalnym wydawnictwie „zine” Boing Boing odbywał się konkurs tworzenia gier. Uznaliśmy, że to doskonały wymóg, aby stworzyć szybką i prostą grę w językach JavaScript i <canvas>, więc przystąpiliśmy do pracy. Po zakończeniu konkursu wciąż mieliśmy wiele pomysłów i chcieliśmy zakończyć to, co zaczęli. Oto studium przypadku, które omówiliśmy, w grze Onslaught! Stadion.

Pikselowy styl retro

Ze względu na założenie konkursu, aby opracować grę opartą na chiptunezie, zależało nam na tym, aby nasza gra wyglądała i działała jak retro Nintendo Entertainment System. W większości gier nie ma tego wymogu, ale jest to popularny styl artystyczny (zwłaszcza wśród niezależnych twórców), bo łatwo jest tworzyć zasoby i uatrakcyjniać gry w nostalgiczny sposób.

Cisza! Rozmiary Arena w pikselach
Zwiększenie rozmiaru w pikselach może zmniejszyć ilość pracy podczas projektowania graficznego.

Biorąc pod uwagę, jak małe są te sprite, postanowiliśmy podwoić piksele, co oznacza, że sprite 16 x 16 będzie miał teraz wymiary 32 x 32 piksele itd. Od samego początku skupialiśmy się na tworzeniu zasobów, zamiast stawiać na przeglądarkę. Była ona po prostu łatwiejsza w implementacji, ale miała też wyraźne zalety w zakresie wyglądu.

Wzięliśmy pod uwagę taki scenariusz:

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

Ta metoda składałaby się ze sprite'ów 1 x 1 zamiast podwajać je po stronie tworzenia zasobów. Następnie CSS przejmuje obszar roboczy i zmienia jego rozmiar. Nasze testy porównawcze wykazały, że ta metoda może być 2 razy szybsza niż renderowanie większych (podwójnych obrazów), ale niestety zmiana rozmiaru w CSS obejmuje antyaliasing, którego nie mogliśmy temu zapobiec.

Opcje zmiany rozmiaru elementu Canvas
Po lewej: w Photoshopie zasoby o perfekcyjnej jakości obrazu się powiększyły. Po prawej: zmiana rozmiaru CSS dodała efekt rozmycia.

To była przełomowa sytuacja, ponieważ poszczególne piksele są bardzo ważne. Jeśli jednak chcesz zmienić rozmiar obszaru roboczego i użyć anti-aliasingu, weź pod uwagę to podejście ze względu na wydajność.

Zabawne sztuczki na temat płótna

Wszyscy wiemy, że największą popularnością cieszy się <canvas>, ale czasami programiści nadal zalecają korzystanie z DOM. Jeśli zastanawiasz się, którego z nich użyć, poniżej znajdziesz przykład tego, jak dzięki aplikacji <canvas> zaoszczędziliśmy mnóstwo czasu i energii.

Gdy wróg zostanie zaatakowany w Onslaught! Arena, miga na czerwono i przez chwilę wyświetla animację „bólu”. Aby ograniczyć liczbę grafiki, jaką musieliśmy utworzyć, przedstawiamy przeciwników, którzy są „bólem”, patrząc w dół. Wygląda to na akceptowalne w grze i zaoszczędziło Ci mnóstwo czasu na tworzenie sprite’ów. Duże sprite'y (o wymiarach co najmniej 64 x 64 piksele) były znacznie uciążliwe dla bossów – z widokiem w lewo lub w górę na nagłe skierowanie do dołu w przypadku problematycznego klatki.

Oczywistym rozwiązaniem byłoby narysowanie barier związanych z problemami każdego bossa we wszystkich 8 kierunkach, ale byłoby to bardzo czasochłonne. Dzięki aplikacji <canvas> udało nam się rozwiązać ten problem w kodzie:

Gospodarz, który wyrządza szkody w Onslaught! Hala widowiskowa
Ciekawe efekty można uzyskać za pomocą parametru context.globalCompositeOperation.

Najpierw rysujemy potwora w ukrytym „buforze” <canvas>, nakładamy na niego kolor czerwony, a następnie renderujemy wynik z powrotem na ekran. 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

Tworzenie gier zasadniczo różni się od tworzenia stron internetowych. W stosie stron często reagowanie na zdarzenia za pomocą detektorów zdarzeń. Dlatego kod inicjowania może jedynie nasłuchiwać zdarzeń wejściowych. Logika gry jest inna, ponieważ musi się stale aktualizować. Jeśli na przykład gracz się nie ruszył, to nie powinno to przeszkodzić jego w dotarciu do goblinów.

Oto przykład pętli gry:

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

setInterval(main, 1);

Pierwszą ważną różnicą jest to, że funkcja handleInput w rzeczywistości nie wykonuje niczego od razu. Gdy użytkownik naciśnie klawisz w typowej aplikacji internetowej, sensowne jest natychmiastowe wykonanie pożądanego działania. Jednak aby wszystko przebiegło poprawnie, w grze wydarzenia muszą dziać się w kolejności chronologicznej.

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];
  }
};

Znamy już dane wejściowe i możemy je uwzględnić w funkcji update, mając pewność, że będzie ona zgodna z pozostałymi zasadami 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();
  }
};

Na koniec, gdy wszystko jest już obliczone, czas ponownie narysować ekran. W środowisku DOM przeglądarka obsługuje tę dynamikę. Jeśli jednak używasz <canvas>, musisz ręcznie narysować obraz za każdym razem, gdy coś się stanie (zwykle jest to każda pojedyncza klatka).

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 na podstawie czasu

Modelowanie na podstawie czasu polega na przenoszeniu sprite’ów na podstawie czasu, który upłynął od ostatniej aktualizacji klatki. Ta technika sprawia, że gra działa tak szybko, jak to możliwe, jednocześnie dba o to, by sprite’y poruszały się ze stałą prędkością.

Aby korzystać z modelowania na podstawie czasu, musimy zarejestrować czas, który upłynął od wyświetlenia ostatniej klatki. Aby to śledzić, będziemy musieli uzupełnić 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 czas, możemy obliczyć, jak daleko dany sprite powinien przesunąć każdą klatkę. Najpierw musimy śledzić kilka rzeczy dotyczących obiektu sprite: bieżąca pozycja, 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, przeniesiemy wystąpienie powyższej klasy sprite 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.x i direction.y powinny być normalizowane, co oznacza, że zawsze powinny się mieścić w przedziale od -1 do 1.

Opcje

Ustawienia sterujące okazały się największym przeszkodą podczas tworzenia gry Onslaught! Stadion. W pierwszej wersji demonstracyjnej zastosowano tylko klawiaturę. Gracze za pomocą klawiszy strzałek przesuwali główną postać po ekranie i wystrzeliwali strzały w kierunku, w którym patrzył ze spacją. Gra była intuicyjna i łatwa w obsłudze, ale sprawiała, że granie na trudniejszych poziomach było prawie niemożliwe. W dowolnym momencie przed graczem lecą dziesiątki wrogów i pocisków, dlatego niezbędna jest możliwość wymiany ciosów między złoczyńcami jednocześnie strzelającymi w dowolnym kierunku.

Aby porównać wyniki z grami podobnymi pod tym względem, dodaliśmy obsługę myszy w celu sterowania siatką kierowania, której postać będzie używać do prowadzenia ataków. Postać można nadal przesuwać za pomocą klawiatury, ale po wprowadzeniu tej zmiany będzie on mógł jednocześnie wystrzeliwać ogień w dowolnym kierunku obejmującym 360 stopni. Zapaleni gracze docenili tę funkcję, ale przyniosła ona niefortunne skutki uboczne irytujące użytkowników trackpada.

Cisza! Sterowanie stadionem (wycofane)
Stare elementy sterujące lub okno z instrukcjami gry w Onslaught! Stadion

Z myślą o użytkownikach trackpada przywróciliśmy elementy sterujące klawiszami strzałek, ale tym razem umożliwiliśmy uruchamianie w naciśniętych kierunkach. Chociaż czuliśmy, że kierujemy się głównie rodzajami graczy, nieświadomie nieświadomie wprowadzaliśmy zbyt duże złożoność gry. Ku naszym zaskoczeniu doszliśmy później do wniosku, że niektórzy gracze nie są świadomi istnienia opcjonalnych elementów sterujących myszką (lub klawiaturą) do atakowania, pomimo że samouczki w większości przypadków ignorowane.

Cisza! Samouczek dotyczący sterowania areną
Gracze najczęściej ignorują nakładkę z samouczkiem – wolą się bawić i bawić.

Na szczęście mamy kibiców z Europy, ale słyszeliśmy frustrację, że nie mają oni typowych klawiatur QWERTY i nie mogą używać klawiszy WASD do przesuwania kierunkowego ruchu. Leworęczni gracze zgłaszają podobne skargi.

Dzięki temu złożonemu schematowi sterowania pojawia się też problem z grami na urządzeniach mobilnych. Jedną z najczęstszych próśb jest o przesłanie filmu Onslaught! Arena na Androida, iPada i inne urządzenia dotykowe (które nie mają klawiatury). Jedną z największych zalet języka HTML5 jest jego przenośność, więc wprowadzenie gry na te urządzenia jest bez wątpienia możliwe. Musimy tylko rozwiązać wiele problemów (przede wszystkim z elementami sterującymi i wydajnością).

Aby rozwiązać te problemy, zaczęliśmy stosować metodę rozgrywki z jednym wprowadzaniem danych, która wymaga jedynie interakcji myszy (lub dotyku). Gracze klikają ekran lub dotykają ekranu, a główna postać przesuwa się w stronę klikniętego miejsca i automatycznie atakuje złoczyńca. 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 dodatkowego aspektu konieczności celowania wrogów może w niektórych sytuacjach ułatwić grę, ale naszym zdaniem ułatwienie graczom ma wiele zalet. Pojawiają się też inne strategie, jak np. konieczność ustawienia postaci blisko niebezpiecznych wrogów, aby ją zaatakować, czy obsługi urządzeń dotykowych.

Dźwięk

Jednym z największych problemów podczas tworzenia Onslaught! Arena była tagiem <audio> w HTML5. Prawdopodobnie najgorszy aspekt to opóźnienie: w prawie wszystkich przeglądarkach między wywołaniem .play() a rzeczywistym dźwiękiem występuje opóźnienie. Może to negatywnie wpłynąć na wrażenia graczy, zwłaszcza gdy grają w dynamiczną grę taką jak nasza.

Inne problemy to nieuruchomienie zdarzenia „progress”, które może powodować bezterminowe zawieszenie się wczytywania gry. Z tego powodu wprowadziliśmy metodę wycofywaną, w której jeśli Flash się nie wczyta, przejdzie na audio 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 też, aby gra obsługiwała przeglądarki nie odtwarzające plików MP3 (np. Mozilla Firefox). W takim przypadku pomoc można wykryć i przełączyć się na Ogg Vorbis, używając kodu podobnego do tego:

/*
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
    }
  }
}

Zapisuję dane

Zręcznościowa strzelanka nie ma sobie równych bez rekordów. Wiedzieliśmy, że niektóre dane gier muszą zostać zachowane, a chociaż możemy używać starych plików, na przykład plików cookie, chcemy lepiej poznać nowe, ciekawe technologie HTML5. Dostępnych jest z pewnością wiele opcji, takich jak pamięć lokalna, miejsce na dane sesji i bazy danych Web SQL.

ALT_TEXT_HERE
Zapisujemy najlepsze wyniki, a także miejsce w grze po pokonaniu każdego z bossów.

Zdecydowaliśmy się na localStorage, ponieważ jest nowy, świetny i łatwy w użyciu. Obsługuje zapisywanie podstawowych par klucz-wartość, co jest niezbędne w prostej grze. Oto prosty przykład, jak można go wykorzystać:

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 pamiętać o pewnych „gotowych” założeniach. Niezależnie od informacji wartości są zapisywane 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 gry – od grafiki po zapisywanie stanu gry. Pojawiają się pewne problemy (np. problemy z tagiem <audio>), jednak deweloperzy przeglądarek szybko działają, a przystępność wygląda dobrze w przypadku gier opartych na HTML5.

Cisza! Arena z ukrytym logo HTML5
Tarczy HTML5 możesz otrzymać, wpisując „html5” podczas gry Onslaught. Stadion