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

เกริ่นนำ

ในตอนที่เราแปลงเกมครอสเวิร์ด Wordico จาก Flash ไปเป็น HTML5 งานแรกที่เราพบคือการเรียนรู้ทุกเรื่องเกี่ยวกับการสร้างประสบการณ์ของผู้ใช้ที่สมบูรณ์แบบในเบราว์เซอร์ แม้ว่า Flash จะให้บริการ API เดียวที่ครบครันสำหรับการพัฒนาแอปพลิเคชันในด้านต่างๆ ตั้งแต่การวาดเวกเตอร์ การตรวจหา Hit รูปหลายเหลี่ยม ไปจนถึงการแยกวิเคราะห์ 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" แทนคำสามคำ) ซึ่งเป็นการทำงานที่ตรงไปตรงมาใน 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;
}

นี่คือผลลัพธ์ในเว็บเบราว์เซอร์ โปรดทราบว่า Canvas จะมีเงาตกกระทบของ CSS ดังนี้

ใน HTML5 เกมบอร์ดเป็นองค์ประกอบ Canvas เดียว
ใน HTML5 เกมบอร์ดจะเป็นองค์ประกอบผ้าใบเดี่ยว

การแปลงวัตถุที่เป็นไทล์ก็เหมือนกับแบบฝึกหัดนี้ ใน Flash เราใช้ช่องข้อความและรูปร่างเวกเตอร์

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

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

ชิ้นส่วน HTML ประกอบด้วยรูปภาพ 3 รูป
ชิ้นส่วน HTML ประกอบด้วยรูปภาพ 3 รูป

ตอนนี้เรามีแคนวาส 100 ผืน (1 ใบต่อ 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 เบราว์เซอร์สามารถแคชรูปภาพเหล่านี้ได้ อย่างที่ 3 เมื่อมีงานเพิ่มเติมเล็กๆ น้อยๆ เราสามารถสลับภาพมาสร้างลายกระเบื้องใหม่ๆ เช่น กระเบื้องโลหะ และงานออกแบบนี้สามารถทำได้ใน Photoshop แทนที่จะเป็น Flash

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

ตรรกะ Fuzzy

เราต้องการให้คุณใช้ประโยชน์จากหน้าต่างเบราว์เซอร์ได้อย่างเต็มที่ในทุกขนาด และหลีกเลี่ยงการเลื่อน นี่เป็นการดำเนินการที่ค่อนข้างง่ายใน 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.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 ต้องใช้เครื่องจัดการเหตุการณ์หลายตัวเพื่อตอบสนองต่อการทำงานของเมาส์ มาสก์สำหรับพื้นที่ที่เลื่อนได้ การคำนวณตำแหน่งการเลื่อนได้ และโค้ดอื่นๆ อีกมากมายเพื่อติดเข้าด้วยกัน

แผงแชทใน 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 Player ที่ฝังไว้ หากไม่สำเร็จ เราจะสร้างโหนด <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 Toolkit (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));
}
...
}

คลาส 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 ในโค้ดหลายพันบรรทัด ไม่ใช่เรื่องง่ายเลย แต่เมื่อใช้ Java IDE ที่ดี ทุกอย่างก็ทำงานได้อย่างรวดเร็ว สุดท้าย สำหรับการทดสอบ การเขียนหน่วยทดสอบสำหรับคลาส Java จะดีกว่าเทคนิค "บันทึกและรีเฟรช" ตามหลักเวลา

สรุป

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