Fallstudie: Wordico von Flash in HTML5 konvertieren

Adam Mark
Adam Mark
Adrian Gould
Adrian Gould

Einleitung

Bei der Umwandlung unseres Wordico-Kreuzworträtselspiels von Flash in HTML5 bestand unsere erste Aufgabe darin, alles zu verstehen, was wir über die Erstellung einer ansprechenden Nutzererfahrung im Browser wussten. Während Flash eine einzelne, umfassende API für alle Aspekte der Anwendungsentwicklung bot – von der Vektorzeichnung über die Erkennung von Polygontreffern bis hin zur XML-Analyse, bot HTML5 eine Unmenge an Spezifikationen mit unterschiedlicher Browserunterstützung. Außerdem haben wir uns gefragt, ob HTML, eine dokumentenspezifische Sprache, und CSS, eine kastenzentrierte Sprache, zum Erstellen eines Spiels geeignet sind. Würde das Spiel in allen Browsern einheitlich dargestellt werden wie in Flash und würde es auch genauso gut aussehen und funktionieren? Für Wordico lautete die Antwort ja.

Welchen Vektor hast du, Victor?

Wir entwickelten die ursprüngliche Version von Wordico ausschließlich mithilfe von Vektorgrafiken: Linien, Kurven, Füllungen und Farbverläufe. Das Ergebnis war sowohl hochkompakt als auch unendlich skalierbar:

Wordico-Wireframe
In Flash bestand jedes Anzeigeobjekt aus Vektorformen.

Außerdem haben wir die Flash-Zeitachse genutzt, um Objekte mit mehreren Status zu erstellen. Wir haben beispielsweise neun benannte Keyframes für das Space-Objekt verwendet:

Ein Leerzeichen mit drei Buchstaben in Flash.
Ein Leerzeichen mit drei Buchstaben in Flash

In HTML5 verwenden wir jedoch ein Bitmap-Sprite:

Ein PNG-Sprite mit allen neun Leerzeichen.
Ein PNG-Sprite mit allen neun Leerzeichen.

Um das 15 x 15-Gameboard aus einzelnen Bereichen zu erstellen, iterieren wir über eine 225-stellige Zeichenfolgennotation, in der jedes Leerzeichen durch ein anderes Zeichen dargestellt wird (z. B. "t" für das Dreifachbuchstaben und "T" für ein Dreifachwort). Dies war eine unkomplizierte Operation in Flash. Wir haben einfach Leerzeichen ausgestanzt und in einem Raster angeordnet:

var spaces:Array = new Array();

for (var i:int = 0; i < 225; i++) {
  var space:Space = new Space(i, layout.charAt(i));
  ...
  spaces.push(addChild(space));
}

LayoutUtil.grid(spaces, 15);

In HTML5 ist das etwas komplizierter. Mit dem <canvas>-Element, einer Bitmap-Zeichenfläche, wird das Spielfeld quadratisch bemalt. Der erste Schritt besteht darin, das Bild-Sprite zu laden. Nach dem Laden durchlaufen wir die Layoutnotation und zeichnen mit jeder Iteration einen anderen Teil des Bildes:

var x = 0;  // x coordinate
var y = 0;  // y coordinate
var w = 35; // width and height of a space

for (var i = 0; i < 225; i++) {
  if (i && i % 15 == 0) {
    x = 0;
    y += w;
  }

  var imageX = "_dDFtTqQxm".indexOf(layout.charAt(i)) * 70;

  canvas.drawImage("spaces.png", imageX, 0, 70, 70, x, y, w, w);

  x += w;
}

Hier ist das Ergebnis im Webbrowser. Der Canvas selbst verfügt über einen CSS-Schlagschatten:

In HTML5 besteht das Gameboard aus einem einzelnen Canvas-Element.
In HTML5 besteht das Gameboard aus einem einzelnen Canvas-Element.

Das Konvertieren des Kachelobjekts war eine ähnliche Übung. In Flash wurden Textfelder und Vektorformen verwendet:

Die Flash-Kachel war eine Kombination aus Textfeldern und Vektorformen.
Die Flash-Kachel war eine Kombination aus Textfeldern und Vektorformen.

In HTML5 werden zur Laufzeit drei Bild-Sprites in einem einzelnen <canvas>-Element kombiniert:

Die HTML-Kachel besteht aus drei Bildern.
Die HTML-Kachel besteht aus drei Bildern.

Jetzt haben wir 100 Leinwand (eine für jede Kachel) und einen Leinwanddruck für das Spielfeld. Hier ist das Markup für eine „H“-Kachel:

<canvas width="35" height="35" class="tile tile-racked" title="H-2"/>

Hier ist das entsprechende CSS:

.tile {
  width: 35px;
  height: 35px;
  position: absolute;
  cursor: pointer;
  z-index: 1000;
}

.tile-drag {
  -moz-box-shadow: 1px 1px 7px rgba(0,0,0,0.8);
  -webkit-box-shadow: 1px 1px 7px rgba(0,0,0,0.8);
  -moz-transform: scale(1.10);
  -webkit-transform: scale(1.10);
  -webkit-box-reflect: 0px;
  opacity: 0.85;
}

.tile-locked {
  cursor: default;
}

.tile-racked {
  -webkit-box-reflect: below 0px -webkit-gradient(linear, 0% 0%, 0% 100%,  
    from(transparent), color-stop(0.70, transparent), to(white));
}

Wir wenden CSS3-Effekte an, wenn die Kachel gezogen wird (Schatten, Deckkraft und Skalierung) und wenn die Kachel auf dem Rack liegt (Reflexion):

Die gezogene Kachel ist etwas größer, leicht transparent und weist einen Schlagschatten auf.
Die gezogene Kachel ist etwas größer, leicht transparent und hat einen Schlagschatten.

Die Verwendung von Rasterbildern hat einige offensichtliche Vorteile. Das Ergebnis ist pixelgenau. Zweitens können diese Bilder vom Browser im Cache gespeichert werden. Drittens können wir mit ein wenig Zusatzaufwand die Bilder austauschen, um neue Kacheldesigns zu erstellen – zum Beispiel eine Metallkachel. Diese Designarbeit können Sie in Photoshop statt in Flash erledigen.

Der Nachteil? Durch die Verwendung von Bildern gewähren wir programmatischen Zugriff auf die Textfelder. In Flash war es ein einfacher Vorgang, die Farbe oder andere Eigenschaften des Typs zu ändern. In HTML5 sind diese Eigenschaften in die Bilder selbst integriert. Wir haben HTML-Text ausprobiert, allerdings erforderte er viel Markup und CSS. Wir haben auch Canvas-Text-Elemente ausprobiert, aber die Ergebnisse waren in allen Browsern unterschiedlich.)

Unscharfe Logik

Wir wollten das Browserfenster in jeder Größe optimal nutzen und Scrollen vermeiden. Dies war eine relativ einfache Operation in Flash, da das gesamte Spiel in Vektoren gezeichnet wurde und ohne Einbußen in der Grafik vergrößert oder verkleinert werden konnte. In HTML war es etwas komplizierter. Wir haben versucht, die CSS-Skalierung zu verwenden, doch schließlich war der Canvas verschwommen:

CSS-Skalierung (links) im Vergleich zum erneuten Zeichnen (rechts)
CSS-Skalierung (links) im Vergleich zur Neuzeichnung (rechts)

Unsere Lösung besteht darin, das Spielfeld, das Rack und die Kacheln jedes Mal neu zu zeichnen, wenn der Nutzer die Größe des Browsers ändert:

window.onresize = function (evt) {
...
gameboard.setConstraints(boardWidth, boardWidth);

...
rack.setConstraints(rackWidth, rackHeight);

...
tileManager.resizeTiles(tileSize);
});

So erhalten wir gestochen scharfe Bilder und ansprechende Layouts für jede Bildschirmgröße:

Das Spielbrett füllt die vertikale Fläche aus und andere Seitenelemente fließen um ihn herum.
Das Spielbrett füllt die vertikale Fläche aus und ist von anderen Seitenelementen umgeben.

Kommen Sie auf den Punkt

Da jede Kachel absolut positioniert ist und genau auf dem Spielbrett und dem Rack ausgerichtet werden muss, benötigen wir ein zuverlässiges Positionierungssystem. Wir verwenden die beiden Funktionen Bounds und Point, um die Position von Elementen im globalen Bereich (der HTML-Seite) zu verwalten. Bounds beschreibt einen rechteckigen Bereich auf der Seite, während Point eine x,y-Koordinate relativ zur oberen linken Ecke der Seite (0,0) beschreibt, die auch als Registrierungspunkt bezeichnet wird.

Mit Bounds können wir die Schnittmenge von zwei rechteckigen Elementen erkennen (z. B. wenn eine Kachel das Rack kreuzt) oder ob ein rechteckiger Bereich (z. B. ein Raum mit zwei Buchstaben) einen beliebigen Punkt enthält (z. B. den Mittelpunkt einer Kachel). Hier ist die Implementierung von Bounds:

// bounds.js
function Bounds(element) {
var x = element.offsetLeft;
var y = element.offsetTop;
var w = element.offsetWidth;
var h = element.offsetHeight;

this.left = x;
this.right = x + w;
this.top = y;
this.bottom = y + h;
this.width = w;
this.height = h;
this.x = x;
this.y = y;
this.midx = x + (w / 2);
this.midy = y + (h / 2);
this.topleft = new Point(x, y);
this.topright = new Point(x + w, y);
this.bottomleft = new Point(x, y + h);
this.bottomright = new Point(x + w, y + h);
this.middle = new Point(x + (w / 2), y + (h / 2));
}

Bounds.prototype.contains = function (point) {
return point.x > this.left &amp;&amp;
point.x < this.right &amp;&amp;
point.y > this.top &amp;&amp;
point.y < this.bottom;
}

Bounds.prototype.intersects = function (bounds) {
return this.contains(bounds.topleft) ||
this.contains(bounds.topright) ||
this.contains(bounds.bottomleft) ||
this.contains(bounds.bottomright) ||
bounds.contains(this.topleft) ||
bounds.contains(this.topright) ||
bounds.contains(this.bottomleft) ||
bounds.contains(this.bottomright);
}

Bounds.prototype.toString = function () {
return [this.x, this.y, this.width, this.height].join(",");
}

Wir verwenden Point, um die absolute Koordinate (oben links) eines beliebigen Elements auf der Seite oder eines Mausereignisses zu bestimmen. Point enthält außerdem Methoden zum Berechnen von Entfernung und Richtung, die für die Erstellung von Animationseffekten erforderlich sind. Hier ist die Implementierung von Point:

// point.js

function Point(x, y) {
this.x = x;
this.y = y;
}

Point.prototype.distance = function (point) {
var a = point.x - this.x;
var b = point.y - this.y;

return Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2));
}

Point.prototype.distanceX = function (point) {
return Math.abs(this.x - point.x);
}

Point.prototype.distanceY = function (point) {
return Math.abs(this.y - point.y);
}

Point.prototype.interpolate = function (point, pct) {
var x = this.x + ((point.x - this.x) * pct);
var y = this.y + ((point.y - this.y) * pct);

return new Point(x, y);
}

Point.prototype.offset = function (x, y) {
return new Point(this.x + x, this.y + y);
}

Point.prototype.vector = function (point) {
return new Point(point.x - this.x, point.y - this.y);
}

Point.prototype.toString = function () {
return this.x + "," + this.y;
}

// static
Point.fromElement = function (element) {
return new Point(element.offsetLeft, element.offsetTop);
}

// static
Point.fromEvent = function (evt) {
return new Point(evt.x || evt.clientX, evt.y || evt.clientY);
}

Diese Funktionen bilden die Grundlage der Drag-and-drop- und Animationsfunktionen. Zum Beispiel verwenden wir Bounds.intersects(), um zu bestimmen, ob eine Kachel einen Bereich auf dem Spielfeld überschneidet. Wir verwenden Point.vector(), um die Richtung einer gezogenen Kachel zu bestimmen, und wir verwenden Point.interpolate() in Kombination mit einem Timer, um einen Motion Tween, einen Easing-Effekt, zu erzeugen.

Mit dem Strom schwimmend

Während Layouts mit fester Größe in Flash einfacher zu erstellen sind, lassen sich fließende Layouts mit HTML und dem CSS-Boxmodell viel einfacher generieren. Betrachten Sie die folgende Rasteransicht mit variabler Breite und Höhe:

Dieses Layout hat keine festen Abmessungen: Die Miniaturansichten verlaufen von links nach rechts und von oben nach unten.
Dieses Layout hat keine festen Abmessungen: Thumbnails verlaufen von links nach rechts und von oben nach unten.

Oder ziehen Sie das Chatfeld in Betracht. Bei der Flash-Version waren mehrere Event-Handler erforderlich, um auf Mausaktionen zu reagieren, eine Maske für den scrollbaren Bereich, Berechnungen für die Berechnung der Scrollposition und eine Menge anderer Code, um sie zusammenzukleben.

Das Chat-Steuerfeld in Flash war schön, aber komplex.
Das Chatfenster in Flash war schön, aber komplex.

Die HTML-Version ist im Vergleich dazu nur ein <div> mit fester Höhe und der Überlauf-Eigenschaft, die auf „Ausgeblendet“ gesetzt ist. Scrollen kostet uns nichts.

Das CSS-Boxmodell in der Praxis
Das CSS-Box-Modell in der Praxis

In solchen Fällen, also bei gewöhnlichen Layoutaufgaben, liegt HTML und CSS vor dem Flash.

Können Sie mich jetzt hören?

Wir hatten Schwierigkeiten mit dem <audio>-Tag. Es war in bestimmten Browsern einfach nicht in der Lage, kurze Soundeffekte wiederholt abzuspielen. Wir haben zwei Behelfslösungen ausprobiert. Zuerst haben wir die Sounddateien mit toten Luft aufgefüllt, um sie länger zu machen. Dann haben wir versucht, die abwechselnde Wiedergabe über mehrere Audiokanäle hinweg durchzuführen. Keine der beiden Methoden war absolut effektiv oder elegant.

Schließlich beschlossen wir, unseren eigenen Flash-Audio-Player einzuführen und HTML5-Audio als Fallback zu verwenden. Hier ist der grundlegende Code in Flash:

var sounds = new Array();

function playSound(path:String):void {
var sound:Sound = sounds[path];

if (sound == null) {
sound = new Sound();
sound.addEventListener(Event.COMPLETE, function (evt:Event) {
    sound.play();
});
sound.load(new URLRequest(path));
sounds[path] = sound;
}
else {
sound.play();
}
}

ExternalInterface.addCallback("playSound", playSound);

In JavaScript wird versucht, den eingebetteten Flash-Player zu finden. Wenn das nicht funktioniert, erstellen wir einen <audio>-Knoten für jede Audiodatei:

function play(String soundId) {
var src = "/audio/" + soundId + ".mp3";

// Flash
try {
var swf = window["swfplayer"] || document["swfplayer"];
swf.playSound(src);
}
// or HTML5 audio
catch (e) {
var sound = document.getElementById(soundId);
if (sound == null || sound == undefined) {
    var sound = document.createElement("audio");
    sound.id = soundId;
    sound.src = src;
    document.body.appendChild(sound);
}
sound.play();
}
}

Hinweis: Dies funktioniert nur für MP3-Dateien. Wir haben uns nie die Mühe gemacht, OGG zu unterstützen. Wir hoffen, dass sich die Branche in naher Zukunft auf ein einheitliches Format einigen wird.

Abstimmungsposition

In HTML5 nutzen wir dieselbe Technik wie in Flash, um den Spielstatus zu aktualisieren: Alle zehn Sekunden fordert der Client den Server nach Aktualisierungen an. Wenn sich der Spielstatus seit der letzten Umfrage geändert hat, empfängt und verarbeitet der Client die Änderungen. Andernfalls passiert nichts. Diese traditionelle Abfragetechnik ist akzeptabel, wenn auch nicht ganz elegant. Wir würden jedoch gerne auf Long Polling oder WebSockets umstellen, wenn das Spiel weiterentwickelt wird und die Nutzer Echtzeitinteraktionen über das Netzwerk erwarten. Insbesondere WebSockets bietet viele Möglichkeiten, das Spiel zu verbessern.

Was für ein Tool!

Wir haben mit dem Google Web Toolkit (GWT) sowohl die Frontend-Benutzeroberfläche als auch die Backend-Steuerlogik (Authentifizierung, Validierung, Persistenz usw.) entwickelt. Das JavaScript selbst wird aus dem Java-Quellcode kompiliert. Die Punktfunktion wird beispielsweise von Point.java angepasst:

package com.wordico.client.view.layout;

import com.google.gwt.dom.client.Element;
import com.google.gwt.dom.client.NativeEvent;
import com.google.gwt.event.dom.client.DomEvent;

public class Point {
public double x;
public double y;

public Point(double x, double y) {
this.x = x;
this.y = y;
}

public double distance(Point point) {
double a = point.x - this.x;
double b = point.y - this.y;

return Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2));
}
...
}

Einige UI-Klassen haben entsprechende Vorlagendateien, in denen Seitenelemente an Kursmitglieder gebunden sind. ChatPanel.ui.xml entspricht beispielsweise ChatPanel.java:

<!DOCTYPE ui:UiBinder SYSTEM "http://dl.google.com/gwt/DTD/xhtml.ent">

<ui:UiBinder
xmlns:ui="urn:ui:com.google.gwt.uibinder"
xmlns:g="urn:import:com.google.gwt.user.client.ui"
xmlns:w="urn:import:com.wordico.client.view.widget">

<g:HTMLPanel>
<div class="palette">
<g:ScrollPanel ui:field="messagesScroll">
    <g:FlowPanel ui:field="messagesFlow"></g:FlowPanel>
</g:ScrollPanel>
<g:TextBox ui:field="chatInput"></g:TextBox>
</div>
</g:HTMLPanel>

</ui:UiBinder>

Sämtliche Details werden in diesem Artikel nicht behandelt, aber wir empfehlen Ihnen, sich für Ihr nächstes HTML5-Projekt mit GWT vertraut zu machen.

Warum Java? Zunächst zur strikten Eingabe. Während die dynamische Eingabe in JavaScript nützlich ist, z. B. weil ein Array Werte verschiedener Typen enthalten kann, kann dies bei großen, komplexen Projekten zu einem Problem werden. Zweitens zu den Refaktorierungsfunktionen. Überlegen Sie, wie Sie die Signatur einer JavaScript-Methode über Tausende Codezeilen hinweg ändern würden – und zwar nicht so einfach! Mit einer guten Java-IDE ist das ein Kinderspiel. Zu Testzwecken. Das Schreiben von Einheitentests für Java-Klassen schlägt die bewährte Technik „Speichern und aktualisieren“.

Zusammenfassung

Mit Ausnahme unserer Audioprobleme hat HTML5 unsere Erwartungen deutlich übertroffen. Wordico sieht nicht nur gut aus wie in Flash, es ist auch genauso flüssig und responsiv. Ohne Canvas und CSS3 wäre das nicht möglich gewesen. Unsere nächste Herausforderung besteht darin, Wordico für die mobile Nutzung anzupassen.