Fallstudie: Wordico von Flash in HTML5 konvertieren

Adam Mark
Adam Mark
Adrian Gould
Adrian Gould

Einführung

Als wir unser Wordico-Kreuzworträtsel von Flash zu HTML5 umgestellt haben, mussten wir zuerst alles vergessen, was wir über die Erstellung einer ansprechenden Benutzeroberfläche im Browser wussten. Während Flash eine einzige umfassende API für alle Aspekte der Anwendungsentwicklung bot – von der Vektorzeichnung über die Polygon-Treffererkennung bis hin zum XML-Parsing –, bot HTML5 eine Vielzahl von Spezifikationen mit unterschiedlicher Browserunterstützung. Außerdem haben wir uns gefragt, ob HTML, eine dokumentspezifische Sprache, und CSS, eine boxorientierte Sprache, für die Entwicklung eines Spiels geeignet sind. Würde das Spiel wie in Flash in allen Browsern einheitlich angezeigt werden und würde es genauso gut aussehen und funktionieren? Für Wordico lautete die Antwort ja.

Was ist Ihr Vektor, Viktor?

Wir haben die ursprüngliche Version von Wordico ausschließlich mit Vektorgrafiken entwickelt: Linien, Kurven, Füllungen und Farbverläufe. Das Ergebnis war sowohl sehr kompakt als auch unbegrenzt skalierbar:

Wordico-Wireframe
In Flash bestand jedes Displayobjekt aus Vektorformen.

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

Ein dreistelliger Leerraum in Flash.
Ein dreistelliger Leerraum in Flash.

In HTML5 verwenden wir jedoch einen Bitmapped-Sprite:

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

Um das 15 × 15 Felder große Spielbrett aus einzelnen Feldern zu erstellen, iterieren wir über eine 225-stellige Stringnotation, in der jedes Feld durch ein anderes Zeichen dargestellt wird (z. B. „t“ für dreifache Buchstaben und „T“ für dreifache Wörter). In Flash war das ganz einfach: Wir haben einfach Bereiche erstellt 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. Wir verwenden das <canvas>-Element, eine Bitmap-Zeichenfläche, um das Spielbrett nach und nach zu zeichnen. Laden Sie zuerst den Bild-Sprite. Nach dem Laden durchlaufen wir die Layoutnotation und zeichnen bei jeder Iteration einen anderen Teil des Bilds:

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

Das Ergebnis im Webbrowser: Der Canvas selbst hat einen CSS-Schatten:

In HTML5 ist das Spielfeld ein einzelnes Canvas-Element.
In HTML5 ist das Spielfeld ein einzelnes Canvas-Element.

Die Umwandlung des Kachelobjekts war eine ähnliche Übung. In Flash haben wir Textfelder und Vektorformen verwendet:

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

In HTML5 kombinieren wir drei Bild-Sprites zur Laufzeit in einem einzigen <canvas>-Element:

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

Jetzt haben wir 100 Canvasse (jeweils eines für jede Kachel) sowie ein Canvas für das Spielbrett. 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 sie sich im Rack befindet (Reflexion):

Die verschobene Kachel ist etwas größer, etwas transparenter und hat einen Schatten.
Die verschobene Kachel ist etwas größer, etwas transparenter und hat einen Schlagschatten.

Die Verwendung von Rasterbildern hat einige offensichtliche Vorteile. Erstens: Das Ergebnis ist pixelgenau. Zweitens können diese Bilder vom Browser im Cache gespeichert werden. Drittens: Mit ein wenig zusätzlicher Arbeit können wir die Bilder austauschen, um neue Fliesendesigns zu erstellen, z. B. eine Metallfliese. Diese Designarbeit kann in Photoshop statt in Flash erfolgen.

Der Nachteil? Wenn wir Bilder verwenden, verzichten wir auf den programmatischen Zugriff auf die Textfelder. In Flash war es einfach, die Farbe oder andere Eigenschaften des Typs zu ändern. In HTML5 sind diese Eigenschaften in die Bilder selbst eingebettet. Wir haben es mit HTML-Text versucht, aber das erforderte viel zusätzliches Markup und CSS. Wir haben auch Canvas-Text ausprobiert, aber die Ergebnisse waren in verschiedenen Browsern nicht einheitlich.)

Fuzzy-Logik

Wir wollten das Browserfenster in jeder Größe optimal nutzen und Scrollen vermeiden. Das war in Flash relativ einfach, da das gesamte Spiel in Vektoren gezeichnet wurde und ohne Qualitätsverlust vergrößert oder verkleinert werden konnte. In HTML war es jedoch schwieriger. Wir haben versucht, die CSS-Skalierung zu verwenden, aber das Canvas wurde unscharf:

CSS-Skalierung (links) im Vergleich zum Neuzeichnen (rechts)
CSS-Skalierung (links) im Vergleich zum Neuzeichnen (rechts)

Unsere Lösung besteht darin, das Spielfeld, das Rack und die Kacheln 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 bei jeder Bildschirmgröße gestochen scharfe Bilder und ansprechende Layouts:

Das Spielfeld füllt den vertikalen Bereich aus; andere Seitenelemente fließen darum herum.
Das Spielfeld füllt den vertikalen Raum aus; andere Seitenelemente fließen darum herum.

Schnell zum Punkt kommen

Da jede Kachel absolut positioniert ist und genau mit dem Spielbrett und dem Rack ausgerichtet werden muss, benötigen wir ein zuverlässiges Positionierungssystem. Wir verwenden zwei 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, auch als Registrierpunkt bezeichnet.

Mit Bounds können wir die Überschneidung zweier rechteckiger Elemente erkennen (z. B. wenn eine Kachel das Rack kreuzt) oder ob ein rechteckiger Bereich (z. B. ein Bereich mit zwei Buchstaben) einen beliebigen Punkt (z. B. den Mittelpunkt einer Kachel) enthält. 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(",");
}

Mit Point wird die absolute Koordinate (links oben) eines Elements auf der Seite oder eines Mausereignisses ermittelt. Point enthält auch 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 für Drag-and-drop- und Animationsfunktionen. Mit Bounds.intersects() können wir beispielsweise feststellen, ob eine Kachel einen Bereich auf dem Spielbrett überlappt. Mit Point.vector() können wir die Richtung einer gezogenen Kachel bestimmen. Und mit Point.interpolate() in Kombination mit einem Timer können wir einen Motion-Tween oder einen Ease-Effekt erstellen.

Mit dem Strom schwimmend

Layouts mit fester Größe lassen sich zwar einfacher in Flash erstellen, aber mit HTML und dem CSS-Box-Modell lassen sich flexible Layouts viel einfacher generieren. Sehen Sie sich die folgende Ansicht mit variabler Breite und Höhe an:

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

Oder das Chatfeld. Die Flash-Version erforderte mehrere Ereignishandler, um auf Mausaktionen zu reagieren, eine Maske für den scrollbaren Bereich, Mathematik zum Berechnen der Scrollposition und viel anderen Code, um alles zusammenzufügen.

Das Chatfeld in Flash war schön, aber komplex.
Das Chatfeld in Flash war schön, aber komplex.

Die HTML-Version besteht dagegen nur aus einem <div> mit einer festen Höhe und der Eigenschaft „overflow“ (Überlauf) auf „hidden“ (ausgeblendet). Das Scrollen kostet uns nichts.

Das CSS-Box-Modell in Aktion
Das CSS-Box-Modell in Aktion.

In solchen Fällen – bei einfachen Layoutaufgaben – sind HTML und CSS die bessere Wahl.

Können Sie mich jetzt hören?

Wir hatten Probleme mit dem <audio>-Tag. In bestimmten Browsern konnten kurze Audioeffekte nicht wiederholt abgespielt werden. Wir haben zwei Lösungen ausprobiert. Zuerst haben wir die Audiodateien mit Stille gefüllt, um sie zu verlängern. Dann haben wir versucht, die Wiedergabe auf mehrere Audiokanäle aufzuteilen. Keine der beiden Techniken war vollständig effektiv oder elegant.

Letztendlich haben wir uns entschieden, unseren eigenen Flash-Audioplayer zu entwickeln 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 versuchen wir, den eingebetteten Flash-Player zu erkennen. Andernfalls erstellen wir für jede Audiodatei einen <audio>-Knoten:

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: Das 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 einziges Format einigen wird.

Umfrageposition

Wir verwenden in HTML5 dieselbe Technik wie in Flash, um den Spielstatus zu aktualisieren: Alle 10 Sekunden fragt der Client den Server nach Updates. Wenn sich der Spielstatus seit der letzten Umfrage geändert hat, empfängt und verarbeitet der Client die Änderungen. Andernfalls passiert nichts. Diese traditionelle Umfragetechnik ist akzeptabel, wenn auch nicht ganz elegant. Wir möchten jedoch zu Long Polling oder WebSockets wechseln, sobald das Spiel ausgereift ist und Nutzer Echtzeitinteraktionen über das Netzwerk erwarten. Insbesondere WebSockets bieten viele Möglichkeiten, das Gameplay zu verbessern.

Was für ein Tool!

Wir haben das Google Web Toolkit (GWT) verwendet, um sowohl die Front-End-Benutzeroberfläche als auch die Back-End-Steuerungslogik (Authentifizierung, Validierung, Persistenz usw.) zu entwickeln. Das JavaScript selbst wird aus Java-Quellcode kompiliert. Die Funktion „Punkt“ wurde beispielsweise von Point.java übernommen:

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 Klassenmitglieder 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>

Die vollständigen Details gehen über den Rahmen dieses Artikels hinaus. Wir empfehlen Ihnen jedoch, GWT für Ihr nächstes HTML5-Projekt zu verwenden.

Vorteile von Java Erstens: die strenge Typisierung. Die dynamische Typisierung ist in JavaScript zwar nützlich, z. B. weil ein Array Werte verschiedener Typen enthalten kann, bei großen, komplexen Projekten kann sie jedoch zu Problemen führen. Zweitens: Refactoring-Funktionen. Stellen Sie sich vor, wie Sie eine JavaScript-Methodensignatur in Tausenden von Codezeilen ändern würden – das ist nicht einfach! Mit einer guten Java-IDE ist das jedoch ein Kinderspiel. Schließlich zu Testzwecken. Das Schreiben von Unit-Tests für Java-Klassen ist besser als die bewährte Methode „Speichern und aktualisieren“.

Zusammenfassung

Abgesehen von den Audioproblemen hat HTML5 unsere Erwartungen bei weitem übertroffen. Wordico sieht nicht nur genauso gut aus wie in Flash, sondern ist auch genauso flüssig und reaktionsschnell. Ohne Canvas und CSS3 wäre das nicht möglich gewesen. Unsere nächste Herausforderung: Wordico für die mobile Nutzung anpassen.