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

Adam Mark
Adam Mark
Adrian Gould
Adrian Gould

מבוא

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

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

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

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

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

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

ב-HTML5, לעומת זאת, אנחנו משתמשים ב-Sprite עם מיפוי סיביות (bitmap):

תמונת Sprite של PNG שמציגה את כל תשעת הרווחים.
תמונת Sprite של PNG שמציגה את כל תשעת הרווחים.

כדי ליצור משחק 15x15 מרווחים נפרדים, אנחנו מבצעים איטרציה על סימון מחרוזות של 225 תווים שבו כל מרחב מיוצג על ידי תו שונה (למשל t (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>, משטח שרטוט של מפת סיביות (bitmap), כדי לצבוע את לוח המשחק ריבוע אחד בכל פעם. השלב הראשון הוא לטעון את ה-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, ה-gameboard הוא רכיב אחד של בד קנבס.

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

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

ב-HTML5, אנחנו משלבים שלוש תמונות Sprite אל רכיב <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, אבל נדרש הרבה סימון ו-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> עם גובה קבוע ומאפיין האפשרויות הנוספות מוגדר כמוסתר. גלילה לא כרוכה בתשלום.

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

איזה כלי!

השתמשנו בערכת הכלים של Google Web (GWT) כדי לפתח את ממשק המשתמש בממשק הקצה ואת לוגיקת הבקרה בקצה העורפי (אימות, אימות, עקביות וכו'). קוד ה-JavaScript עצמו מורכב מקוד המקור של Java. לדוגמה, פונקציית הנקודה מותאמת מ-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 - לדוגמה, היכולת של מערך להכיל ערכים מסוגים שונים - אבל היא עלולה לגרום כאב ראש בפרויקטים גדולים ומורכבים. שנית, בקשר ליכולות של ארגון מחדש (Refactoring). חשוב כיצד לשנות את החתימה של שיטת JavaScript באלפי שורות קוד - לא קל! אבל עם סביבת פיתוח משולבת (IDE) טובה, קל ופשוט. לבסוף, למטרות בדיקה. בחינות של יחידות כתיבה לשיעורי Java גוברים על השיטה הוותיקה של "שמירה ורענון".

סיכום

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