Studium przypadku: Konwersja Wordico z Flasha na HTML5

Wstęp

Gdy przekonwertowaliśmy naszą krzyżówkę Wordico z Flasha na HTML5, naszym pierwszym zadaniem było uświadomienie sobie wszystkiego o tym, jak zwiększyć wygodę użytkowników w przeglądarce. Choć Flash oferował pojedynczy, wszechstronny interfejs API do obsługi wszystkich aspektów tworzenia aplikacji – od rysowania wektorowego przez wykrywanie trafień w postaci wielokątów po analizowanie XML – język HTML5 udostępniał wiele specyfikacji z różnymi opcjami obsługi przeglądarek. Zastanawialiśmy się też, czy język HTML (czyli język związany z dokumentami) i CSS (czyli język zorientowany na pudełka) nadają się do tworzenia gier. Czy gra będzie się wyświetlać tak samo w różnych przeglądarkach, jak we Flashu, oraz czy będzie wyglądać i działać tak samo dobrze? W przypadku Wordico odpowiedź brzmi: tak.

Jaki jest Twój wektor, Victor?

Opracowaliśmy oryginalną wersję Wordico tylko przy użyciu grafiki wektorowej: linii, krzywych, wypełnienia i gradientów. Wynik był bardzo kompaktowy i skalowalny w nieskończoność:

Szkielet Wordico
We Flashu każdy wyświetlany obiekt składał się z kształtów wektorowych.

Skorzystaliśmy też z osi czasu Flasha, aby utworzyć obiekty mające wiele stanów. Na przykład użyliśmy 9 nazwanych klatek kluczowych dla obiektu Space:

Trzyliterowa spacja we Flashu.
Trójliterowa spacja we Flashu.

Natomiast w HTML5 używamy sprite’a mapowanego na bitach:

Duszek PNG pokazujący wszystkie 9 spacji.
Sprite PNG przedstawiający wszystkie 9 spacji.

Aby utworzyć planszę 15 x 15 z pojedynczych obszarów, stosujemy iterację obejmującą 225-znakowy zapis tekstowy, w którym każda spacja jest reprezentowana przez inny znak (na przykład „t” oznacza potrójną literę i „T” dla potrójnego wyrazu). To proste zadanie we Flashu – wystarczy zaznaczyć miejsca i ułożyć je w siatkę:

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

W HTML5 jest to nieco bardziej skomplikowane. Używamy elementu <canvas>, czyli powierzchni do rysowania bitmapami, do malowania planszy gier pojedynczo. Pierwszym krokiem jest wczytanie sprite’a obrazu. Po załadowaniu obrazu powtarzamy zapis układu, rysując przy każdej iteracji inną część obrazu:

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

Oto wynik w przeglądarce. Zwróć uwagę, że sam obszar roboczy zawiera cień CSS:

W HTML5 tablica gier jest pojedynczym elementem canvas.
W HTML5 tablica do gier to pojedynczy element canvas.

Konwertowanie obiektu kafelkowego było podobnym ćwiczeniem. We Flashu używaliśmy pól tekstowych i kształtów wektorowych:

Kafelek Flash stanowił kombinację pól tekstowych i kształtów wektorowych
Kafelki Flash składały się z pól tekstowych i kształtów wektorowych.

W HTML5 łączymy 3 sprite'y graficzne w jednym elemencie <canvas> w czasie działania:

Kafelek HTML składa się z 3 obrazów.
Kafelek HTML jest złożony z 3 obrazów.

Mamy teraz 100 odbitek na płótnie (po jednym na każdy kafelek) oraz przestrzeń na planszy. Oto znaczniki dla kafelka „H”:

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

Oto odpowiadająca jej usługa porównywania cen:

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

Efekty CSS3 są stosowane, gdy kafelek jest przeciągany (cień, nieprzezroczystość i skalowanie) oraz gdy znajduje się on na stojaku (odbicie):

Przeciągnięty kafelek jest nieco większy, nieco przezroczysty i ma cień.
Przeciągnięty kafelek jest nieco większy, nieco przezroczysty i ma cień.

Stosowanie obrazów rastrowych ma pewne oczywiste zalety. Po pierwsze, wynik jest pikselowy. Po drugie, obrazy te mogą być zapisywane w pamięci podręcznej przeglądarki. Po trzecie, przy odrobinie pracy możemy zamienić je na nowe projekty płytek – na przykład metalowej płytki. Takie prace można wykonać w programie Photoshop zamiast we Flashu.

Jakie są wady? Stosując obrazy, rezygnujemy z automatycznego dostępu do pól tekstowych. We Flashu zmiana koloru i innych właściwości typu kreacji była prosta – w HTML5 te właściwości są wbudowane w obrazy. (Próbowaliśmy użyć tekstu HTML, ale wymagało to dużo dodatkowych znaczników i kodu CSS. Próbowaliśmy też użyć tekstu na płótnie, ale wyniki były niespójne w różnych przeglądarkach).

Logika przybliżona

Chcieliśmy w pełni wykorzystać możliwości okna przeglądarki niezależnie od rozmiaru i uniknąć przewijania. Operacja we Flashu była stosunkowo prosta, ponieważ cała gra była narysowana wektorami i można było skalować obraz w górę lub w dół bez utraty jakości. Jednak kod HTML był trudniejszy. Próbowaliśmy zastosować skalowanie CSS, ale w rezultacie otrzymaliśmy rozmytą przestrzeń roboczą:

Skalowanie CSS (w lewo) i przerysowywanie (w prawo)
Skalowanie CSS (w lewo) a przerysowywanie (w prawo)

Nasze rozwiązanie polega na ponownym rysowaniu planszy, stojaka i kafelków za każdym razem, gdy użytkownik zmieni rozmiar przeglądarki:

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

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

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

Dzięki temu otrzymujesz wyraźne obrazy i ładne układy na ekranach dowolnego rozmiaru.

Plansza wypełnia pionową przestrzeń, a wokół niej okrążają się inne elementy strony.
Plansza wypełnia przestrzeń pionową, a inne elementy strony opływają.

Przejdź do rzeczy

Ponieważ każdy kafelek jest absolutnie zlokalizowany i musi ściśle przylegać do planszy oraz stojaka, potrzebujemy niezawodnego systemu pozycjonowania. Używamy 2 funkcji: Bounds i Point, które ułatwiają zarządzanie lokalizacją elementów w przestrzeni globalnej (stronie HTML). Bounds opisuje prostokątny obszar na stronie, a Point – współrzędne x,y względem lewego górnego rogu strony (0,0), nazywane też punktem rejestracyjnym.

Funkcja Bounds pozwala wykryć przecięcie 2 prostokątnych elementów (na przykład gdy płytka przecina szafę) lub ustalić, czy prostokątny obszar (na przykład dwuliterowa przestrzeń) zawiera dowolny punkt (na przykład punkt środkowy kafelka). Oto implementacja granic:

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

Parametr Point określa współrzędną bezwzględną (lewy górny róg) dowolnego elementu na stronie lub zdarzenia myszy. Point zawiera też metody obliczania odległości i kierunku, które są niezbędne do tworzenia efektów animacji. Oto implementacja 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);
}

Te funkcje są podstawą do przeciągania i upuszczania oraz tworzenia animacji. Na przykład używamy Bounds.intersects(), aby określić, czy kafelek nakłada się na przestrzeń na planszy, Point.vector() do określenia kierunku przeciągniętego kafelka. Używamy funkcji Point.interpolate() w połączeniu z licznikiem czasu, aby uzyskać efekt wygładzania lub animacji.

Płynę z prądem

Chociaż układy o stałym rozmiarze łatwiej jest tworzyć we Flashu, układy płynne można znacznie łatwiej wygenerować za pomocą kodu HTML i modelu pola CSS. Na przykład widok siatki o zmiennej szerokości i wysokości:

W tym układzie nie ma stałych wymiarów: miniatury przesuwają się od lewej do prawej, z góry do dołu.
W tym układzie nie ma stałych wymiarów: miniatury przesuwają się od lewej do prawej, z góry na dół.

Możesz też przejść do panelu czatu. Wersja Flash wymagała wielu modułów obsługi zdarzeń reagowania na działania myszy, maski dla obszaru przewijanego, funkcji matematycznych do obliczania pozycji przewijania i dużej ilości innego kodu do łączenia elementów w całość.

Panel czatu we Flashu był dość, ale skomplikowany.
Panel czatu we Flashu był dość skomplikowany.

Dla porównania wersja HTML to po prostu element <div> o stałej wysokości i ukrytą właściwość overflow. Przewijanie nic nie kosztuje.

Działanie modelu pola CSS.
Działanie modelu pola CSS.

W takich przypadkach są to zwykłe zadania związane z układem – HTML i CSS wyróżniają się Flash.

Słyszysz mnie teraz?

Mieliśmy problemy z tagiem <audio> – w niektórych przeglądarkach nie mógł on po prostu odtwarzać krótkich efektów dźwiękowych w niektórych przeglądarkach. Próbowaliśmy obejść ten problem na dwa sposoby. Najpierw uzupełniliśmy pliki dźwiękowe powietrzem ciszy, aby były dłuższe. Następnie próbowaliśmy włączyć odtwarzanie naprzemienne w kilku kanałach audio. Żadna z metod nie okazała się skuteczna ani elegancka.

Ostatecznie zdecydowaliśmy się wdrożyć własny odtwarzacz audio Flash i w zastępstwie użyć dźwięku w formacie HTML5. Oto podstawowy kod we Flashu:

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

W kodzie JavaScript próbujemy wykryć umieszczony odtwarzacz Flash. Jeśli to się nie uda, dla każdego pliku dźwiękowego tworzymy węzeł <audio>:

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

Pamiętaj, że ta funkcja działa tylko w przypadku plików MP3. Nigdy nie przejmowaliśmy się z obsługą formatu OGG. Mamy nadzieję, że w najbliższej przyszłości branża zdecyduje się na jednolity format.

Pozycja ankiety

W HTML5 używamy tej samej metody co we Flashu, by odświeżać stan gry: co 10 sekund klient prosi serwer o aktualizacje. Jeśli stan gry zmienił się od czasu ostatniej ankiety, to klient otrzymuje zmiany i je obsługuje. W przeciwnym razie nic się nie dzieje. Ta tradycyjna technika ankiet jest akceptowalna, choć niezbyt elegancka. Chcielibyśmy jednak przejść na długie ankiety lub WebSockets, ponieważ rozgrywka dobiega końca i użytkownicy oczekują interakcji w sieci w czasie rzeczywistym. Zwłaszcza WebSockets daje wiele możliwości poprawy rozgrywki.

Co za narzędzie!

Skorzystaliśmy z Google Web Toolkit (GWT) do opracowania zarówno interfejsu frontendu, jak i logiki kontroli backendu (uwierzytelniania, weryfikacji, trwałości itp.). Kod JavaScript jest kompilowany z kodu źródłowego Java. Na przykład funkcja punktu została adaptowana z zastosowanej funkcji Point.java:

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

Niektóre klasy interfejsu mają odpowiednie pliki szablonów, w których elementy strony są „powiązane” z członkami klasy. Na przykład ChatPanel.ui.xml odpowiada wartości 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>

Szczegółowe informacje wykraczają poza zakres tego artykułu, ale zachęcamy do zapoznania się z GWT przy kolejnych projektach HTML5.

Dlaczego warto używać Javy? Po pierwsze, do ścisłego pisania. Pisanie dynamiczne jest przydatne w języku JavaScript – na przykład możliwość przechowywania wartości różnych typów przez tablicę – jednak w przypadku dużych, złożonych projektów może być kłopotliwe. Po drugie, jeśli chodzi o możliwości refaktoryzacji. Zastanów się, jak zmienić podpis metody JavaScript w tysiącach wierszy kodu – nie jest to łatwe. Jednak w przypadku dobrego środowiska IDE Java to żmudna migracja. I wreszcie, do celów testowych. Pisanie testów jednostkowych klas Java przekracza uznaną technikę „zapisywania i odświeżania”.

Podsumowanie

Oprócz problemów z dźwiękiem HTML5 znacznie przekroczył nasze oczekiwania. Wordico nie tylko wygląda tak samo dobrze jak we Flashu, ale jest też tak samo elastyczni i elastyczny. Nie udało się nam to bez Canvas i CSS3. Przed nami następne wyzwanie: dostosuj Wordico do użytku na urządzeniach mobilnych.