מקרה לדוגמה - המרת Wordico מ-Flash ל-HTML5

Adam Mark
Adam Mark
Adrian Gould
Adrian Gould

מבוא

כשהעברנו את משחק הפאזל Wordico מ-Flash ל-HTML5, המשימה הראשונה שלנו הייתה לשכוח את כל מה שידענו על יצירת חוויית משתמש עשירה בדפדפן. ב-Flash יש ממשק API יחיד ומקיף לכל ההיבטים של פיתוח אפליקציות – החל מאיור וקטור ועד לזיהוי פגיעה בפוליגון ולניתוח XML. לעומת זאת, ב-HTML5 יש מקבץ של מפרטים עם תמיכה משתנה בדפדפנים. תהינו גם אם HTML, שפה ספציפית למסמכים, ו-CSS, שפה שמתמקדת בתיבות, מתאימות ליצירת משחק. האם המשחק יוצג בצורה אחידה בכל הדפדפנים, כמו ב-Flash, והאם הוא ייראה ויפעל בצורה נעימה כמו ב-Flash? התשובה לגבי Wordico הייתה כן.

מהו הווקטור שלך, ויקטור?

פיתחנו את הגרסה המקורית של Wordico באמצעות גרפיקה וקטורית בלבד: קווים, עקומות, מילוי וגוונים. התוצאה הייתה קומפקטית מאוד וניתנת להתאמה לעומס (scalable) ללא הגבלה:

Wordico Wireframe
ב-Flash, כל אובייקט תצוגה היה עשוי מצורות וקטורים.

בנוסף, השתמשנו בציר הזמן של Flash כדי ליצור אובייקטים עם כמה מצבים. לדוגמה, השתמשנו בתשעת מפתחות ראשיים עם שם לאובייקט Space:

רווח בן שלוש אותיות ב-Flash.
מרחב של שלוש אותיות ב-Flash.

עם זאת, ב-HTML5 אנחנו משתמשים ב-sprite בפורמט bitmap:

תמונת PNG של תשעת המרחבים המשותפים.
ספרייט בפורמט PNG שבו מוצגים כל תשעת המרחבים המשותפים.

כדי ליצור את לוח המשחק בגודל 15x15 ממרחב משותף בודד, אנחנו מבצעים איטרציה על סימון מחרוזת של 225 תווים, שבו כל מרחב משותף מיוצג על ידי תו שונה (למשל 't' עבור אות משולשת ו-'T' עבור מילה משולשת). זו הייתה פעולה פשוטה ב-Flash. פשוט יצקנו את המרחבים והסדרנו אותם ברשת:

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

ב-HTML5, התהליך קצת יותר מורכב. אנחנו משתמשים באלמנט <canvas>, משטח ציור של קובץ בייטמאפ, כדי לצייר את לוח המשחק, ריבוע אחד בכל פעם. השלב הראשון הוא טעינת ה-sprite של התמונה. אחרי הטעינה, אנחנו עוברים על סימון הפריסה ומציירים חלק אחר של התמונה בכל חזרה:

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

זו התוצאה בדפדפן האינטרנט. שימו לב שלבד הציור יש הטלת צללית ב-CSS:

ב-HTML5, לוח המשחק הוא רכיב בד קנבס יחיד.
ב-HTML5, לוח המשחק הוא רכיב אחד של לוח ציור.

המרת אובייקט המשבצת הייתה תהליך דומה. ב-Flash, השתמשנו בשדות טקסט ובצורות וקטוריות:

המשבצת של Flash הייתה שילוב של שדות טקסט וצורות וקטור
המשבצת של Flash הייתה שילוב של שדות טקסט וצורות וקטורים.

ב-HTML5, אנחנו משלבים שלושה ספרייטים של תמונות ברכיב <canvas> יחיד בזמן הריצה:

המשבצת ב-HTML היא שילוב של שלוש תמונות.
הכרטיסייה ב-HTML מורכבת משלושה תמונות.

עכשיו יש לנו 100 משטחי קנבס (אחד לכל משבצת) ועוד משטח קנבס ללוח המשחק. זהו הרכיב של אריח מסוג 'H':

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

זהו הקוד של 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));
}

אנחנו מחילים אפקטים של CSS3 כשהמשבצת גוררת (צל, אטימות ושינוי קנה מידה) וכשהמשבצת נמצאת במדף (השתקפות):

המשבצת שנגררת גדולה מעט יותר, שקופה מעט יותר ומוצגת עם צללית.
המשבצת שנגררת גדולה מעט יותר, שקופה מעט יותר ומוצגת עם צללית.

לשימוש בתמונות רסטר יש כמה יתרונות ברורים. קודם כול, התוצאה מדויקת עד לפיקסל. שנית, התמונות האלה יכולות להישמר במטמון של הדפדפן. שלישית, עם קצת עבודה נוספת, אנחנו יכולים להחליף את התמונות כדי ליצור עיצובים חדשים של משבצות – כמו משבצת מתכת – ואפשר לבצע את עבודת העיצוב הזו ב-Photoshop במקום ב-Flash.

החיסרון? כשמשתמשים בתמונות, אנחנו מוותרים על גישה פרוגרמטית לשדות הטקסט. ב-Flash, שינוי הצבע או מאפיינים אחרים של הסוג היה פשוט. ב-HTML5, המאפיינים האלה מוטמעים בתמונות עצמן. (ניסינו טקסט HTML, אבל נדרשו הרבה תגי markup ו-CSS נוספים. ניסינו גם טקסט על קנבס, אבל התוצאות לא היו עקביות בין הדפדפנים).

לוגיקה פאזית

רצינו לנצל את חלון הדפדפן במלואו בכל גודל – ולהימנע מגלישה. זו הייתה פעולה פשוטה יחסית ב-Flash, כי כל המשחק צויר בווקטורים וניתן היה לשנות את הגודל שלו למעלה או למטה בלי לאבד את איכות התצוגה. אבל זה היה מורכב יותר ב-HTML. ניסינו להשתמש בשינוי קנה מידה ב-CSS, אבל התוצאה הייתה קנבס מטושטש:

שינוי קנה מידה ב-CSS (שמאל) לעומת ציור מחדש (ימין).
שינוי קנה המידה של CSS (שמאל) לעומת ציור מחדש (ימין).

הפתרון שלנו הוא לצייר מחדש את לוח המשחק, את המארז ואת המשבצות בכל פעם שהמשתמש משנה את גודל הדפדפן:

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

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

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

התוצאה היא תמונות חדות ותצוגות מרהיבות בכל גודל מסך:

לוח המשחק ממלא את המרחב האנכי, ואלמנטים אחרים בדף מוצגים מסביב אליו.
לוח המשחק ממלא את המרחב האנכי, ואלמנטים אחרים בדף מוצגים מסביב לו.

התמקדות בנושא

מכיוון שכל משבצת ממוקמת באופן מוחלט וצריך להתאים אותה במדויק ללוח ולמארז, אנחנו זקוקים למערכת מיקום מהימנה. אנחנו משתמשים בשתי פונקציות, Bounds ו-Point, כדי לנהל את המיקום של רכיבים במרחב הגלובלי (דף ה-HTML). הערך של Bounds מתאר אזור מלבני בדף, והערך של Point מתאר קואורדינטות x,y ביחס לפינה הימנית העליונה של הדף (0,0), שנקראת גם נקודת הרישום.

בעזרת Bounds אנחנו יכולים לזהות את הצטלבות של שני אלמנטים מלבניים (למשל, כאשר משבצת חוצה את המדף) או אם אזור מלבני (למשל, רווח בין שתי אותיות) מכיל נקודה שרירותית (למשל, נקודת המרכז של משבצת). זו ההטמעה של 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(",");
}

אנחנו משתמשים ב-Point כדי לקבוע את הקואורדינטה המוחלטת (הפינה הימנית העליונה) של כל רכיב בדף או של אירוע של העכבר. Point מכיל גם שיטות לחישוב המרחק והכיוון, שנדרשים ליצירת אפקטים של אנימציה. זו ההטמעה של 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);
}

הפונקציות האלה מהוות את הבסיס ליכולות של גרירה ושחרור ואנימציה. לדוגמה, אנחנו משתמשים ב-Bounds.intersects() כדי לקבוע אם משבצת חופפת למרחב על לוח המשחק, אנחנו משתמשים ב-Point.vector() כדי לקבוע את הכיוון של משבצת שנגררה, ואנחנו משתמשים ב-Point.interpolate() בשילוב עם טיימר כדי ליצור תנועה רציפה או אפקט הקטנת מהירות.

נוטה לזרום

קל יותר ליצור פריסות בגודל קבוע ב-Flash, אבל קל הרבה יותר ליצור פריסות גמישות באמצעות HTML ומודל התיבה של CSS. נבחן את תצוגת הרשת הבאה, עם רוחב וגובה משתנים:

לפריסת התמונות הזו אין מידות קבועות: התמונות הממוזערות מוצגות משמאל לימין, מלמעלה למטה.
לפריסת התמונות הזו אין מידות קבועות: התמונות הממוזערות מוצגות משמאל לימין, מלמעלה למטה.

אפשר גם להשתמש בחלונית הצ'אט. בגרסה ל-Flash נדרשו כמה פונקציות טיפול באירועים כדי להגיב לפעולות של העכבר, מסכה לאזור הגלילה, חישובים מתמטיים לחישוב מיקום הגלילה והרבה קוד אחר כדי לחבר את הכול.

חלונית הצ&#39;אט ב-Flash הייתה יפה אבל מורכבת.
חלונית הצ'אט ב-Flash הייתה יפה אבל מורכבת.

לעומת זאת, גרסה HTML היא רק <div> עם גובה קבוע ועם ערך hidden למאפיין overflow. גלילה לא עולה לנו כלום.

מודל התיבה של CSS בפעולה.
מודל התיבה של CSS בפעולה.

במקרים כאלה – משימות רגילות של פריסה – HTML ו-CSS עדיפים על Flash.

עכשיו שומעים אותי?

התקשנו להשתמש בתג <audio> – פשוט לא ניתן היה להשמיע אפקטים קצרים של צלילים שוב ושוב בדפדפנים מסוימים. ניסינו שני פתרונות זמניים. קודם כל, הוספנו לקבצי האודיו קטעי זמן של דממה כדי להאריך אותם. לאחר מכן ניסינו להחליף בין ערוצי אודיו שונים. אף אחת מהשיטות לא הייתה יעילה או אלגנטית לחלוטין.

בסופו של דבר, החלטנו לפתח נגן אודיו משלה ב-Flash ולהשתמש באודיו ב-HTML5 כחלופה. זהו הקוד הבסיסי ב-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);

ב-JavaScript, אנחנו מנסים לזהות את נגן ה-Flash המוטמע. אם הפעולה הזו נכשלת, יוצרים צומת <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();
}
}

חשוב לדעת: האפשרות הזו פועלת רק בקובצי MP3 – אף פעם לא הטרחתנו לתמוך ב-OGG. אנחנו מקווים שהתעשייה תגיע בעתיד הקרוב להסכמה על פורמט אחד.

מיקום הסקר

אנחנו משתמשים באותה טכניקה ב-HTML5 כמו שעשינו ב-Flash כדי לרענן את מצב המשחק: כל 10 שניות, הלקוח מבקש מהשרת עדכונים. אם מצב המשחק השתנה מאז הבדיקה האחרונה, הלקוח מקבל את השינויים ומטפל בהם. אחרת, לא קורה כלום. שיטת הסקר המסורתית הזו מקובלת, אם כי לא ממש אלגנטית. עם זאת, אנחנו רוצים לעבור ל-Long Polling או ל-WebSockets ככל שהמשחק יתפתח והמשתמשים יתחילו לצפות לאינטראקציה בזמן אמת ברשת. במיוחד, WebSockets מציעים הזדמנויות רבות לשיפור חוויית המשחק.

איזה כלי!

השתמשנו ב-Google Web Toolkit‏ (GWT) כדי לפתח גם את ממשק המשתמש של הקצה הקדמי וגם את לוגיק הבקרה של הקצה העורפי (אימות, תקינות, עקביות וכו'). קוד ה-JavaScript עצמו עובר הידור מקוד מקור של Java. לדוגמה, הפונקציה Point מותאמת מ-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));
}
...
}

לכיתות מסוימות של ממשק משתמש יש קובצי תבנית תואמים שבהם רכיבי הדף 'מקושר' לחברי הכיתה. לדוגמה, ChatPanel.ui.xml תואם ל-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>

הפרטים המלאים לא מופיעים במאמר הזה, אבל מומלץ לבדוק את GWT בפרויקט ה-HTML5 הבא שלכם.

למה כדאי להשתמש ב-Java? קודם כול, לטיפוּס קפדני. אמנם סיווג דינמי שימושי ב-JavaScript – לדוגמה, היכולת של מערך להכיל ערכים מסוגים שונים – אבל הוא יכול להיות כאב ראש בפרויקטים גדולים ומורכבים. השנייה היא לצורך יכולות של רפקטורינג. נסו לדמיין איך משנים חתימה של שיטת JavaScript באלפי שורות קוד – לא קל! אבל עם סביבת פיתוח משולבת (IDE) טובה ל-Java, זה קל מאוד. לבסוף, למטרות בדיקה. כתיבת בדיקות יחידה לכיתות Java היא דרך טובה יותר מאשר השיטה הוותיקה של 'שמירה ורענון'.

סיכום

מלבד הבעיות באודיו, HTML5 עלה על כל הציפיות שלנו. Wordico נראה מצוין כמו ב-Flash, והוא גם זורם ורספונסיבי באותה מידה. לא היינו יכולים לעשות את זה בלי Canvas ו-CSS3. האתגר הבא שלנו: התאמת Wordico לשימוש בנייד.