Case study - Onslaught! Stadio

Geoff Blair
Geoff Blair
Matt Hackett
Matt Hackett

Introduzione

A giugno 2010 è venuta alla nostra attenzione la notizia che la rivista locale Boing Boing stava organizzando una competizione di sviluppo di giochi. Abbiamo visto questa come una scusa perfetta per creare un gioco semplice e veloce in JavaScript e <canvas>, quindi ci siamo messi al lavoro. Dopo il concorso, avevamo ancora molte idee e volevamo finire quello che avevamo iniziato. Ecco il case study del risultato, un piccolo gioco chiamato Onslaught. Arena.

Look retrò e pixelato

Era importante che il nostro gioco avesse l'aspetto e le sensazioni di un gioco retrò per Nintendo Entertainment System, dato che la premessa del concorso era sviluppare un gioco basato su un chiptune. La maggior parte dei giochi non ha questo requisito, ma è comunque uno stile artistico comune (soprattutto tra gli sviluppatori indipendenti) a causa della facilità di creazione degli asset e del suo fascino naturale per i giocatori nostalgici.

Scontro! Dimensioni dei pixel di Arena
L'aumento delle dimensioni dei pixel può ridurre il lavoro di progettazione grafica.

Dato che questi sprite sono molto piccoli, abbiamo deciso di raddoppiare i pixel, il che significa che un sprite 16x16 ora sarà di 32x32 pixel e così via. Fin dall'inizio, abbiamo lavorato su due fronti per la creazione degli asset anziché lasciare al browser il compito più complesso. Era semplicemente più facile da implementare, ma presentava anche alcuni vantaggi evidenti per l'aspetto.

Ecco uno scenario che abbiamo preso in considerazione:

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

Questo metodo consisterebbe in sprite 1x1 anziché raddoppiarli per quanto riguarda la creazione di asset. A questo punto, il CSS assume il controllo e ridimensiona la tela stessa. I nostri benchmark hanno rivelato che questo metodo può essere circa il doppio della velocità di rendering delle immagini più grandi (raddoppiate), ma purtroppo il ridimensionamento CSS include l'anti-aliasing, un problema che non siamo riusciti a risolvere.

Opzioni di ridimensionamento del canvas
A sinistra: risorse con pixel perfetti raddoppiate in Photoshop. A destra: il ridimensionamento CSS ha aggiunto un effetto sfocatura.

Questo è stato un fattore decisivo per il nostro gioco, dato che i singoli pixel sono molto importanti, ma se devi ridimensionare la tela e l'anti-aliasing è appropriato per il tuo progetto, potresti prendere in considerazione questo approccio per motivi di prestazioni.

Trucchi divertenti con la tela

Sappiamo tutti che <canvas> è la nuova frontiera, ma a volte gli sviluppatori consigliano ancora di utilizzare il DOM. Se non sai quale utilizzare, ecco un esempio di come <canvas> ci ha fatto risparmiare molto tempo ed energia.

Quando un nemico viene colpito in Assalto! Arena, lampeggia di rosso e visualizza brevemente un'animazione di "dolore". Per limitare il numero di immagini da creare, mostriamo gli avversari in "dolore" solo in direzione verso il basso. Il risultato sembra accettabile in-game e ci ha fatto risparmiare un sacco di tempo per la creazione degli sprite. Per i boss, però, era spiacevole vedere uno sprite di grandi dimensioni (di 64 x 64 pixel o più) che passava improvvisamente da essere rivolto verso sinistra o verso l'alto a essere rivolto verso il basso per l'inquadratura del dolore.

Una soluzione ovvia sarebbe stata disegnare un frame di dolore per ogni boss in ognuna delle otto direzioni, ma questo avrebbe richiesto molto tempo. Grazie a <canvas>, siamo riusciti a risolvere questo problema nel codice:

Beholder che subisce danni in Onslaught. Stadio
È possibile creare effetti interessanti utilizzando context.globalCompositeOperation.

Per prima cosa disegniamo il mostro in un "buffer" nascosto <canvas>, lo sovrapponiamo in rosso e poi rendo il risultato sullo schermo. Il codice sarà simile al seguente:

// 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);

Il ciclo di gioco

Lo sviluppo di giochi presenta alcune differenze significative rispetto allo sviluppo web. Nell' stack web, è comune reagire agli eventi che si sono verificati tramite gli ascoltatori di eventi. Pertanto, il codice di inizializzazione potrebbe non fare altro che ascoltare gli eventi di input. La logica di un gioco è diversa, in quanto deve essere costantemente aggiornata. Se, ad esempio, un giocatore non si è mosso, i goblin non devono fermarsi.

Ecco un esempio di loop di gioco:

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

setInterval(main, 1);

La prima differenza importante è che la funzione handleInput non fa nulla immediatamente. Se un utente preme un tasto in una app web tipica, ha senso eseguire immediatamente l'azione desiderata. Ma in un gioco, le cose devono accadere in ordine cronologico per funzionare correttamente.

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

Ora conosciamo l'input e possiamo considerarlo nella funzione update, sapendo che rispetterà il resto delle regole del gioco.

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();
  }
};

Infine, una volta calcolato tutto, è il momento di ridisegnare lo schermo. Nel DOM, il browser gestisce questo sollevamento. Tuttavia, quando si utilizza <canvas> è necessario ridisegnare manualmente ogni volta che accade qualcosa (in genere ogni singolo frame).

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

Modellazione basata sul tempo

La definizione di modelli basati sul tempo si basa sul concetto di spostamento degli sprite in base al tempo trascorso dall'ultimo aggiornamento del frame. Questa tecnica consente al gioco di funzionare il più velocemente possibile, garantendo al contempo che gli sprite si muovano a velocità costanti.

Per utilizzare la definizione del modello in base al tempo, dobbiamo acquisire il tempo trascorso dall'ultimo frame disegnato. Per monitorare questo aspetto, dobbiamo aumentare la funzione update() del nostro loop di gioco.

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

};

Ora che abbiamo il tempo trascorso, possiamo calcolare la distanza che un determinato sprite deve percorrere in ogni frame. Innanzitutto, dobbiamo tenere traccia di alcune cose in un oggetto sprite: posizione attuale, velocità e direzione.

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

Tenendo presenti queste variabili, ecco come sposteremo un'istanza della classe di sprite sopra indicata utilizzando la modellazione basata sul tempo:

// 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);

Tieni presente che i valori direction.x e direction.y devono essere normalizzati, il che significa che devono sempre rientrare tra -1 e 1.

Controlli

I controlli sono stati probabilmente il principale ostacolo durante lo sviluppo di Onslaught. Arena. La prima demo supportava solo la tastiera: i giocatori muovevano il personaggio principale sullo schermo con i tasti freccia e sparavano nella direzione in cui era rivolto con la barra spaziatrice. Sebbene sia intuitivo e facile da comprendere, questo ha reso il gioco quasi impraticabile nei livelli più difficili. Con dozzine di nemici e proiettili che volano verso il giocatore in qualsiasi momento, è fondamentale essere in grado di muoversi tra i cattivi mentre si spara in qualsiasi direzione.

Per fare un confronto con giochi simili nel suo genere, abbiamo aggiunto il supporto del mouse per controllare un mirino di targeting, che il personaggio userà per mirare i suoi attacchi. Il personaggio poteva comunque essere spostato con la tastiera, ma dopo questa modifica poteva essere sparato contemporaneamente in qualsiasi direzione a 360 gradi. I giocatori più accaniti hanno apprezzato questa funzionalità, ma ha avuto lo sfortunato effetto collaterale di frustrare gli utenti del trackpad.

Scontro! Modale dei controlli dell&#39;arena (deprecato)
Un vecchio popup di controllo o "come giocare" in Onslaught. Arena.

Per venire incontro agli utenti che utilizzano il trackpad, abbiamo ripristinato i controlli dei tasti freccia, questa volta per consentire l'attivazione nelle direzioni premute. Anche se pensavamo di soddisfare tutti i tipi di giocatori, introducevamo inconsapevolmente troppa complessità nel nostro gioco. Con nostra sorpresa, in seguito abbiamo appreso che alcuni giocatori non erano a conoscenza dei controlli facoltativi del mouse (o della tastiera) per gli attacchi, nonostante i tutorial modali, che sono stati in gran parte ignorati.

Scontro! Tutorial sui controlli di Arena
I giocatori ignorano per lo più il riquadro del tutorial; preferiscono giocare e divertirsi.

Abbiamo anche la fortuna di avere alcuni fan europei, ma abbiamo sentito la loro frustrazione perché potrebbero non avere tastiere QWERTY standard e non essere in grado di utilizzare i tasti WASD per i movimenti direzionali. Giocatori mancini hanno espresso lamentele simili.

Con questo complesso schema di controllo che abbiamo implementato, si pone anche il problema di giocare su dispositivi mobili. Infatti, una delle richieste più comuni è di realizzare Onslaught! Arena disponibile su Android, iPad e altri dispositivi touch (dove non è presente una tastiera). Uno dei punti di forza principali di HTML5 è la sua portabilità, quindi portare il gioco su questi dispositivi è sicuramente fattibile, dobbiamo solo risolvere i molti problemi (in particolare, controlli e prestazioni).

Per risolvere questi problemi, abbiamo iniziato a giocare con un metodo di input singolo per il gameplay che prevede solo l'interazione con il mouse (o il tocco). I giocatori fanno clic o toccano lo schermo e il personaggio principale si dirige verso la posizione premuto, attaccando automaticamente il cattivo più vicino. Il codice sarà simile al seguente:

// 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);
}

Rimuovere il fattore aggiuntivo di dover prendere la mira contro i nemici può semplificare il gioco in alcune situazioni, ma riteniamo che semplificare le cose per il giocatore abbia molti vantaggi. Emergono altre strategie, come dover posizionare il personaggio vicino a nemici pericolosi per prenderli di mira, e la possibilità di supportare i dispositivi touch è inestimabile.

Audio

Tra i controlli e le prestazioni, uno dei nostri problemi più grandi durante lo sviluppo di Onslaught! Arena era il tag <audio> di HTML5. Probabilmente l'aspetto peggiore è la latenza: in quasi tutti i browser c'è un ritardo tra la chiamata a .play() e la riproduzione effettiva dell'audio. Questo può rovinare l'esperienza di un giocatore, soprattutto quando gioca con un gioco veloce come il nostro.

Altri problemi includono il mancato aggiornamento dell'evento "progress", che potrebbe causare l'interruzione del flusso di caricamento del gioco a tempo indeterminato. Per questi motivi, abbiamo adottato un metodo di "riassegnazione", in cui, se il caricamento di Flash non riesce, passiamo all'audio HTML5. Il codice sarà simile al seguente:

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

Può essere importante anche che un gioco supporti i browser che non riproducono i file MP3 (come Mozilla Firefox). In questo caso, il supporto può essere rilevato e passare a qualcosa come Ogg Vorbis, con un codice simile al seguente:

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

Salvataggio dei dati

Non puoi avere un gioco sparatutto arcade senza punteggi elevati. Sapevamo che avremmo avuto bisogno di alcuni dati di gioco permanenti e, anche se avremmo potuto utilizzare qualcosa di vecchio come i cookie, volevamo approfondire le nuove e divertenti tecnologie HTML5. Non c'è certo carenza di opzioni, tra cui archiviazione locale, archiviazione di sessione e database SQL web.

ALT_TEXT_HERE
I record vengono salvati, così come la tua posizione nel gioco dopo aver sconfitto ogni boss.

Abbiamo deciso di utilizzare localStorage perché è nuovo, fantastico e facile da usare. Supporta il salvataggio di coppie chiave/valore di base, che è tutto ciò di cui il nostro semplice gioco necessita. Ecco un semplice esempio di utilizzo:

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

Ci sono alcuni "problemi" da tenere presenti. Indipendentemente da ciò che passi, i valori vengono memorizzati come stringhe, il che può portare a risultati imprevisti:

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"}

Riepilogo

HTML5 è fantastico da usare. La maggior parte delle implementazioni gestisce tutto ciò di cui un sviluppatore di giochi ha bisogno, dalla grafica al salvataggio dello stato del gioco. Anche se ci sono alcuni problemi di crescita (ad esempio i problemi con i tag <audio>), gli sviluppatori di browser si stanno muovendo rapidamente e, con le cose già così ottime, il futuro sembra roseo per i giochi basati su HTML5.

Scontro! Stadio con un logo HTML5 nascosto
Puoi ottenere uno scudo HTML5 digitando "html5" quando giochi a Onslaught. Arena.