กรณีศึกษา - การแปลง Wordico จาก Flash เป็น HTML5

บทนำ

เมื่อเราแปลงเกมไขปริศนาคำไขว้ Wordico จาก Flash เป็น HTML5 งานแรกของเราคือการเลิกเรียนรู้ทุกสิ่งที่รู้เกี่ยวกับการสร้างประสบการณ์การใช้งานที่สมบูรณ์ในเบราว์เซอร์ ในขณะที่ Flash มี API เดียวที่ครอบคลุมการพัฒนาแอปพลิเคชันทุกด้าน ตั้งแต่การวาดเวกเตอร์ไปจนถึงการตรวจหาการตีโพลิก้อนไปจนถึงการแยกวิเคราะห์ XML แต่ HTML5 มีข้อกำหนดที่หลากหลายซึ่งรองรับเบราว์เซอร์แตกต่างกันไป นอกจากนี้ เรายังสงสัยว่า HTML ซึ่งเป็นภาษาเฉพาะเอกสารและ CSS ซึ่งเป็นภาษาที่เน้นกล่องเหมาะกับการสร้างเกมหรือไม่ เกมจะแสดงอย่างสม่ำเสมอในเบราว์เซอร์ต่างๆ เช่นเดียวกับใน Flash ไหม และเกมจะดูและทำงานได้ดีเท่าเดิมไหม สำหรับ Wordico คำตอบคือใช่

คุณ Victor โปรดระบุเวกเตอร์

เราพัฒนา Wordico เวอร์ชันแรกโดยใช้เฉพาะกราฟิกเวกเตอร์ เช่น เส้น โค้ง การเติม และไล่ระดับ ผลลัพธ์ที่ได้คือทั้งกะทัดรัดและปรับขนาดได้แบบไม่จำกัด

ไวร์เฟรม Wordico
ใน Flash ออบเจ็กต์การแสดงผลทุกรายการทำจากรูปร่างเวกเตอร์

นอกจากนี้ เรายังใช้ประโยชน์จากไทม์ไลน์ของ Flash เพื่อสร้างออบเจ็กต์ที่มีหลายสถานะ ตัวอย่างเช่น เราใช้คีย์เฟรมที่มีชื่อ 9 รายการสำหรับออบเจ็กต์ Space ดังนี้

เว้นวรรค 3 ตัวใน Flash
เว้นวรรค 3 ตัวใน Flash

แต่จะใช้สไปรท์แบบบิตแมปใน HTML5

สไปรต์ PNG ที่แสดงพื้นที่ทำงานทั้ง 9 รายการ
สไปรท์ PNG ที่แสดงช่องทั้งหมด 9 ช่อง

หากต้องการสร้างกระดานเกมขนาด 15x15 จากช่องแต่ละช่อง เราจะวนซ้ำการเขียนสตริง 225 อักขระ โดยแต่ละช่องจะแสดงด้วยอักขระที่แตกต่างกัน (เช่น "t" สำหรับตัวอักษร 3 ตัว และ "T" สำหรับคำ 3 คำ) ซึ่งการดำเนินการนี้ทำได้ง่ายมากใน 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> ซึ่งเป็นพื้นผิวการวาดภาพบิตแมปเพื่อวาดกระดานเกมทีละช่อง ขั้นตอนแรกคือโหลดสไปรท์รูปภาพ เมื่อโหลดแล้ว เราจะวนดูการเขียนแทนที่เลย์เอาต์ โดยวาดส่วนต่างๆ ของรูปภาพในแต่ละรอบดังนี้

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 กระดานเกมคือองค์ประกอบ Canvas รายการเดียว
ใน HTML5 กระดานเกมคือองค์ประกอบแคนวาสรายการเดียว

การแปลงออบเจ็กต์ไทล์ก็ทําได้แบบเดียวกัน ใน Flash เราใช้ช่องข้อความและรูปร่างเวกเตอร์ ดังนี้

ไทล์ Flash ประกอบด้วยช่องข้อความและรูปร่างเวกเตอร์
การ์ด Flash ประกอบด้วยช่องข้อความและรูปทรงเวกเตอร์

ใน HTML5 เราจะรวมสไปรต์รูปภาพ 3 รายการไว้ในองค์ประกอบ <canvas> รายการเดียวที่รันไทม์ ดังนี้

ไทล์ HTML ประกอบด้วยรูปภาพ 3 รูป
การ์ด HTML ประกอบด้วยรูปภาพ 3 รูป

ตอนนี้เรามีภาพพิมพ์แคนวาส 100 ภาพ (1 ภาพสำหรับแต่ละไทล์) บวกภาพพิมพ์แคนวาสสำหรับกระดานเกม มาร์กอัปสำหรับไทล์ "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 เมื่อมีการลากการ์ด (เงา ความทึบแสง และการปรับขนาด) และเมื่อการ์ดอยู่บนชั้นวาง (แสงสะท้อน) ดังนี้

ไทล์ที่ลากจะใหญ่ขึ้นเล็กน้อย มีความโปร่งใสเล็กน้อย และมีเงาตกกระทบ
การ์ดที่ลากจะใหญ่ขึ้นเล็กน้อย โปร่งใสเล็กน้อย และมีเงาตกกระทบ

การใช้รูปภาพแรสเตอร์มีข้อดีบางอย่างที่เห็นได้ชัด ประการแรก ผลลัพธ์จะมีความแม่นยำระดับพิกเซล ประการที่ 2 เบราว์เซอร์อาจแคชรูปภาพเหล่านี้ไว้ ประการที่สาม เราสามารถเปลี่ยนรูปภาพเพื่อสร้างการออกแบบใหม่ได้ เช่น กระเบื้องโลหะ และงานออกแบบนี้ทำได้ใน 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);
});

ผลลัพธ์ที่ได้คือรูปภาพที่คมชัดและเลย์เอาต์ที่สร้างความพึงพอใจในทุกขนาดหน้าจอ

กระดานเกมจะกินพื้นที่แนวตั้งทั้งหมด ส่วนองค์ประกอบอื่นๆ ของหน้าจะวางอยู่รอบๆ
กระดานเกมใช้พื้นที่แนวตั้งทั้งหมด ส่วนองค์ประกอบอื่นๆ ของหน้าจะวางอยู่รอบๆ

เข้าประเด็นทันที

เนื่องจากการ์ดแต่ละใบมีตำแหน่งที่แน่นอนและต้องจัดวางให้สอดคล้องกับกระดานเกมและขาตั้งอย่างแม่นยำ เราจึงต้องใช้ระบบการวางตำแหน่งที่เชื่อถือได้ เราใช้ฟังก์ชัน 2 รายการ ได้แก่ Bounds และ Point เพื่อช่วยจัดการตําแหน่งองค์ประกอบในพื้นที่ส่วนกลาง (หน้า HTML) Bounds อธิบายพื้นที่สี่เหลี่ยมผืนผ้าบนหน้าเว็บ ส่วน Point อธิบายพิกัด x,y ที่สัมพันธ์กับมุมซ้ายบนของหน้า (0,0) หรือที่เรียกว่าจุดลงทะเบียน

เมื่อใช้ Bounds เราจะตรวจหาจุดตัดขององค์ประกอบสี่เหลี่ยมผืนผ้า 2 รายการ (เช่น เมื่อการ์ดวางทับแร็ค) หรือพื้นที่สี่เหลี่ยมผืนผ้า (เช่น พื้นที่ว่างระหว่าง 2 อักขระ) มีจุดที่กำหนดเองหรือไม่ (เช่น จุดศูนย์กลางของการ์ด) การใช้งาน 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 Box Model พิจารณามุมมองตารางกริดต่อไปนี้ที่มีความกว้างและความสูงแบบแปรผัน

เลย์เอาต์นี้ไม่มีขนาดที่แน่นอน แสดงภาพขนาดย่อจากซ้ายไปขวา จากบนลงล่าง
เลย์เอาต์นี้ไม่มีขนาดที่แน่นอน ภาพขนาดย่อจะแสดงจากซ้ายไปขวา จากบนลงล่าง

หรือลองใช้แผงแชท เวอร์ชัน Flash ต้องใช้ตัวแฮนเดิลเหตุการณ์หลายรายการเพื่อตอบสนองต่อการทำงานของเมาส์ มาสก์สําหรับพื้นที่ที่เลื่อนได้ คณิตศาสตร์สําหรับคํานวณตําแหน่งการเลื่อน และโค้ดอื่นๆ อีกมากมายเพื่อเชื่อมโยงเข้าด้วยกัน

แผงแชทใน Flash นั้นดูดีแต่ซับซ้อน
แผงแชทใน Flash นั้นดูดีแต่ซับซ้อน

ในทางกลับกัน เวอร์ชัน HTML เป็นเพียง <div> ที่มีความสูงคงที่และตั้งค่าพร็อพเพอร์ตี้การรองรับค่าที่เกินไว้เป็น "ซ่อน" การเลื่อนไม่มีค่าใช้จ่าย

โมเดลกล่อง CSS ทำงาน
โมเดลกล่อง CSS ทำงานอย่างไร

ในกรณีเช่นนี้ ซึ่งเป็นงานการจัดวางธรรมดา HTML และ CSS จะมีประสิทธิภาพมากกว่า Flash

ตอนนี้ได้ยินฉันไหม

เราพบปัญหาเกี่ยวกับแท็ก <audio> เนื่องจากเล่นเสียงเอฟเฟกต์สั้นๆ ซ้ำๆ ในบางเบราว์เซอร์ไม่ได้ เราได้ลองใช้วิธีแก้ปัญหา 2 วิธีแล้ว ก่อนอื่น เราเพิ่มช่วงพักระหว่างเสียงลงในไฟล์เสียงเพื่อให้ยาวขึ้น จากนั้นเราลองสลับการเล่นในช่องเสียงหลายช่อง เทคนิคทั้งสองไม่ได้มีประสิทธิภาพหรือมีประสิทธิภาพอย่างสมบูรณ์

สุดท้ายเราตัดสินใจที่จะเปิดตัวโปรแกรมเล่นเสียง 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 เมื่อเกมพัฒนาขึ้นและผู้ใช้คาดหวังการโต้ตอบแบบเรียลไทม์ผ่านเครือข่าย โดยเฉพาะอย่างยิ่ง WebSocket จะเปิดโอกาสมากมายในการปรับปรุงการเล่นเกม

เครื่องมือที่ยอดเยี่ยม

เราใช้ 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));
}
...
}

คลาส UI บางคลาสมีไฟล์เทมเพลตที่สอดคล้องกันซึ่งองค์ประกอบหน้าเว็บ "เชื่อมโยง" กับสมาชิกของคลาส ตัวอย่างเช่น 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 เช่น ความสามารถของอาร์เรย์ในการเก็บค่าประเภทต่างๆ แต่อาจทำให้เกิดปัญหาในโปรเจ็กต์ขนาดใหญ่ที่ซับซ้อน ข้อที่ 2 สำหรับความสามารถในการเปลี่ยนรูปแบบ ลองนึกถึงวิธีที่คุณจะเปลี่ยนลายเซ็นเมธอด JavaScript ในโค้ดหลายพันบรรทัด ซึ่งไม่ใช่เรื่องง่าย แต่ IDE ของ Java ที่ดีจะช่วยให้คุณทำสิ่งเหล่านี้ได้อย่างรวดเร็ว สุดท้ายนี้ เพื่อการทดสอบ การเขียนการทดสอบ 1 หน่วยสําหรับคลาส Java ดีกว่าเทคนิค "บันทึกและรีเฟรช" ที่รู้จักกันมาอย่างยาวนาน

สรุป

ยกเว้นปัญหาเกี่ยวกับเสียง HTML5 ทำงานได้เกินความคาดหมายของเราอย่างมาก Wordico ไม่เพียงแต่จะดูดีเหมือนใน Flash เท่านั้น แต่ยังลื่นไหลและตอบสนองได้ดีไม่แพ้กัน เราคงทำไม่ได้หากไม่มี Canvas และ CSS3 ปัญหาถัดไปของเราคือการปรับ Wordico ให้เหมาะกับการใช้งานบนอุปกรณ์เคลื่อนที่