Case study - Onslaught! Stadio

Geoff Blair
Geoff Blair
Matt Hackett
Matt Hackett

Introduzione

Nel giugno 2010, ci siamo resi conto che la pubblicazione locale di "zine" Boing Boing stava partecipando a una gara di sviluppo di giochi. Abbiamo visto questa scusa perfetta per creare un gioco semplice e veloce in JavaScript e <canvas>, quindi ci siamo concentrati. Dopo il concorso avevamo ancora tante idee e volevamo portare a termine quello che abbiamo iniziato. Ecco il case study del risultato, un piccolo gioco chiamato Onslaught! arena.

Il look retrò in pixel art

Era importante che il nostro gioco fosse simile a un gioco rétro di Nintendo Entertainment System, data la premessa del concorso per 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) grazie alla sua facilità di creazione degli asset e al naturale richiamo dei giocatori nostalgici.

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

Considerando le dimensioni ridotte di questi sprite, abbiamo deciso di raddoppiare i pixel, il che significa che uno sprite 16 x 16 ora sarebbe di 32 x 32 pixel e così via. Sin dall'inizio, abbiamo puntato molto sul lato della creazione degli asset invece di far fare al browser il lavoro pesante. Questa era più facile da implementare, ma aveva anche alcuni vantaggi in termini di 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 1 x 1 invece di raddoppiarli sul lato della creazione degli asset. Da qui, il CSS prendeva il controllo e ridimensionava il canvas stesso. I nostri benchmark hanno rivelato che questo metodo può essere circa due volte più veloce rispetto al rendering di immagini più grandi (raddoppiate). Tuttavia, sfortunatamente il ridimensionamento CSS include l'anti-alias, cosa che non siamo riusciti a trovare un modo per evitare.

Opzioni di ridimensionamento di Canvas
A sinistra: asset pixel perfetti raddoppiati in Photoshop. A destra: il ridimensionamento CSS ha aggiunto un effetto sfocato.

Questo ha causato un'interruzione delle offerte per il nostro gioco, dato che i singoli pixel sono importanti ma se hai bisogno di ridimensionare il canvas e l'anti-aliasing è appropriato per il tuo progetto, puoi prendere in considerazione questo approccio per motivi di prestazioni.

Trucchi divertenti su tela

Sappiamo tutti che <canvas> è il nuovo aspetto più caldo, ma a volte gli sviluppatori consigliano comunque di utilizzare DOM. Se non sai quale utilizzare, ecco un esempio di come <canvas> ci ha fatto risparmiare molto tempo ed energie.

Quando un nemico viene colpito in Assalto! Arena, lampeggia in rosso e mostra brevemente un'animazione "Pain". Per limitare il numero di grafiche da creare, mostriamo i nemici solo con "dolori" rivolti verso il basso. Questo sembra accettabile in-game e ha permesso di risparmiare un sacco di tempo per la creazione di sprite. Tuttavia, per i mostri capo, era scioccante vedere uno sprite di grandi dimensioni (da 64 x 64 pixel o più) scattare dalla sinistra o dall'alto verso l'alto e improvvisamente rivolto verso il basso per inquadrare il dolore.

La soluzione ovvia sarebbe quella di tracciare una struttura dolente per ciascun capo in ognuna delle otto direzioni, ma la procedura avrebbe richiesto molto tempo. Grazie a <canvas>, siamo riusciti a risolvere questo problema nel codice:

Guardatore che subisce danni in Assalto! Stadio
Si possono creare effetti interessanti usando context.globalCompositeOperation.

Per prima cosa disegniamo il mostro su un "buffer" <canvas> nascosto, lo sovrapponiamo di rosso e poi mostriamo il risultato sullo schermo. Il codice è 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 dallo sviluppo web. Nello stack web, è frequente reagire agli eventi che si sono verificati tramite i listener di eventi. Pertanto, il codice di inizializzazione potrebbe non fare altro che ascoltare gli eventi di input. La logica di un gioco è diversa, poiché è necessario aggiornarsi costantemente. Se, ad esempio, un giocatore non si è mosso, non dovrebbe impedire ai goblin di prenderlo!

Ecco un esempio di ciclo di gioco:

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

setInterval(main, 1);

La prima differenza importante è che la funzione handleInput in realtà non fa nulla immediatamente. Se un utente preme un tasto in un'app web tipica, ha senso eseguire immediatamente l'azione desiderata. In un gioco, però, le cose devono avvenire in ordine cronologico per poter essere presentate 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 completato il calcolo, è il momento di ridisegnare lo schermo. Nel DOM-land, il browser gestisce questo sollevamento Quando, invece, utilizzi <canvas>, devi ridisegnare manualmente ogni volta che si verifica un evento (di solito 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 modellazione basata sul tempo è il 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 poter usare la modellazione basata sul tempo, dobbiamo acquisire il tempo trascorso da quando è stato disegnato l'ultimo frame. Dovremo potenziare la funzione update() del nostro ciclo di gioco per monitorare questa situazione.

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 di quanto un determinato sprite dovrebbe spostare ogni frame. Per prima cosa, dobbiamo tenere traccia di alcuni elementi su 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 a mente queste variabili, ecco come sposteremmo un'istanza della classe sprite precedente utilizzando la definizione del modello 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 essere sempre compresi tra -1 e 1.

Controlli

I controlli sono stati forse il principale ostacolo durante lo sviluppo dell'infrastruttura! arena. La prima demo supportava solo la tastiera. I giocatori spostavano il personaggio principale sullo schermo con i tasti freccia e sparava nella direzione in cui si trovava con la barra spaziatrice. Per quanto intuitivo e facile da capire, questo gioco rendeva il gioco quasi irraggiungibile ai livelli più difficili. Con decine di nemici e proiettili che volano contro il giocatore in un determinato momento, è imperativo essere in grado di intrecciare tra i criminali mentre sparano in qualsiasi direzione.

Per fare un confronto con giochi simili del genere, abbiamo aggiunto il supporto per il mouse per controllare un reticolo mirato, che il personaggio avrebbe utilizzato per puntare i suoi attacchi. Il personaggio poteva comunque essere spostato con la tastiera, ma dopo questa modifica poteva azionare contemporaneamente in qualsiasi direzione a 360 gradi. I giocatori esperti hanno apprezzato questa funzione, ma ha avuto lo sfortunato effetto collaterale di frustrare gli utenti del trackpad.

Complimenti! Modalità modale dei controlli Arena (deprecata)
Una vecchia finestra modale di controlli o "come giocare" in Onslaught. arena.

Per soddisfare le esigenze degli utenti del trackpad, abbiamo ripristinato i controlli dei tasti freccia, questa volta per consentire l'attivazione nelle direzioni di pressione. Se, da un lato, sentivamo di soddisfare tutti i tipi di giocatori, dall'altro stavamo inconsapevolmente introducendo troppa complessità nel gioco. Con nostra sorpresa in seguito abbiamo saputo che alcuni giocatori non erano a conoscenza dei controlli facoltativi del mouse (o della tastiera) per l'attacco, nonostante le modalità dei tutorial, che venivano ampiamente ignorate.

Complimenti! Tutorial sui controlli dell&#39;arena
I giocatori ignorano per lo più l'overlay del tutorial; preferiscono giocare e divertirsi.

Abbiamo anche la fortuna di avere alcuni fan europei, ma ne abbiamo accolti della frustrazione perché non hanno le tipiche tastiere QWERTY e non sono in grado di utilizzare i tasti WASD per gli spostamenti direzionali. Anche i giocatori mancini hanno presentato reclami simili.

Con questo complesso schema di controllo che abbiamo implementato, si presenta anche il problema di giocare sui dispositivi mobili. Una delle nostre richieste più comuni è infatti di fare 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 è decisamente fattibile, dobbiamo solo risolvere i molti problemi (in particolare controlli e prestazioni).

Per risolvere questi molti problemi, abbiamo iniziato a utilizzare un metodo di gameplay con un singolo input che prevede solo l'interazione con il mouse (o il tocco). I giocatori fanno clic sullo schermo o lo toccano e il personaggio principale cammina verso il punto premuto, attaccando automaticamente il malintenzionato 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 l'ulteriore fattore di dover mirare ai nemici può rendere il gioco più semplice in alcune situazioni, ma riteniamo che questo aspetto per il giocatore abbia molti vantaggi. Emergono altre strategie, come la necessità di posizionare il personaggio vicino a nemici pericolosi per prenderli di mira e la capacità di supportare i dispositivi touch è inestimabile.

Audio

Tra i controlli e le prestazioni, uno dei nostri maggiori problemi durante lo sviluppo 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 il suono effettivamente in riproduzione. Ciò può rovinare l'esperienza di un giocatore, soprattutto quando si gioca con un gioco frenetico come il nostro.

Altri problemi includono l'impossibilità di attivare l'evento "Avanzamento", che potrebbe causare il blocco del flusso di caricamento del gioco a tempo indeterminato. Per questi motivi, abbiamo adottato quello che chiamiamo un metodo "fall-forward", in cui se Flash non viene caricato, 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;
  }
};

Potrebbe inoltre essere importante che un gioco supporti i browser che non riproducono file MP3 (come Mozilla Firefox). In questo caso, il supporto può essere rilevato e passato a 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 uno sparatutto in stile arcade senza i migliori punteggi! Sapevamo che avremmo bisogno di conservare alcuni dei nostri dati di gioco e, anche se avremmo potuto usare qualcosa di vecchio come i cookie, abbiamo voluto approfondire le nuove e divertenti tecnologie HTML5. Non mancano di certo le opzioni, tra cui archiviazione locale, archiviazione delle sessioni e database SQL web.

ALT_TEXT_HERE
I migliori punteggi vengono salvati, così come il tuo posto nel gioco dopo aver sconfitto ogni boss.

Abbiamo deciso di usare localStorage perché è nuovo, fantastico e facile da usare. Supporta il salvataggio di coppie chiave/valore di base, che è tutto ciò di cui abbiamo bisogno per il gioco. Ecco un esempio pratico di come utilizzarlo:

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 "consigli" da tenere presenti. Indipendentemente dai dati inseriti, 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

L'utilizzo di HTML5 è fantastico. La maggior parte delle implementazioni gestisce tutto ciò che serve a uno sviluppatore di giochi, dalla grafica al salvataggio dello stato. Anche se alcuni problemi sono in aumento (come i problemi con i tag <audio>), gli sviluppatori di browser si muovono rapidamente e, con qualcosa di già fantastico come sono, il futuro si preannuncia roseo per i giochi basati su HTML5.

Complimenti! Arena con logo HTML5 nascosto
Puoi ottenere uno scudo HTML5 digitando "html5" mentre giochi a Onslaught. arena.