บทนำ
ในเดือนมิถุนายน 2010 เราพบว่าBoing Boing ซึ่งเป็น "นิตยสาร" ท้องถิ่นกำลังจัดการแข่งขันการพัฒนาเกม
เราจึงถือโอกาสนี้สร้างเกมง่ายๆ อย่างรวดเร็วใน JavaScript และ <canvas>
หลังจากการแข่งขัน เรายังมีไอเดียมากมายและอยากทำสิ่งที่เริ่มไว้ให้เสร็จ ต่อไปนี้คือกรณีศึกษาของผลลัพธ์ที่ได้ ซึ่งเป็นเกมเล็กๆ ชื่อ Onslaught Arena
รูปลักษณ์ย้อนยุคแบบพิกเซล
เกมของเราต้องดูและรู้สึกเหมือนเกม Nintendo Entertainment System แบบย้อนยุค เนื่องจากโจทย์การแข่งขันคือการพัฒนาเกมตามชิปทิวน์ เกมส่วนใหญ่ไม่มีข้อกำหนดนี้ แต่ยังคงเป็นสไตล์ศิลปะที่พบได้ทั่วไป (โดยเฉพาะในหมู่นักพัฒนาเกมอินดี้) เนื่องจากการสร้างชิ้นงานได้ง่ายและดึงดูดใจเกมเมอร์ที่โหยหาอดีต
เนื่องจากสไปรต์เหล่านี้มีขนาดเล็ก เราจึงตัดสินใจเพิ่มขนาดพิกเซลเป็น 2 เท่า ซึ่งหมายความว่าสไปรต์ขนาด 16x16 จะกลายเป็น 32x32 พิกเซล และอื่นๆ ตั้งแต่แรกเริ่ม เราทํางานด้านการสร้างชิ้นงานเพิ่มขึ้นแทนที่จะให้เบราว์เซอร์ทํางานหนัก วิธีนี้ติดตั้งใช้งานได้ง่ายกว่า แต่ก็มีข้อดีด้านรูปลักษณ์ที่ชัดเจน
สถานการณ์ที่เราพิจารณามีดังนี้
<style>
canvas {
width: 640px;
height: 320px;
}
</style>
<canvas width="320" height="240">
Sorry, your browser is not supported.
</canvas>
วิธีนี้จะมีสไปรท์ขนาด 1x1 แทนที่จะเพิ่มสไปรท์เป็น 2 เท่าในส่วนการสร้างชิ้นงาน จากนั้น CSS จะเข้ามาควบคุมและปรับขนาดภาพพิมพ์แคนวาสเอง การทดสอบประสิทธิภาพของเราแสดงให้เห็นว่าวิธีการนี้เร็วกว่าการแสดงผลรูปภาพขนาดใหญ่ (2 เท่า) ประมาณ 2 เท่า แต่น่าเสียดายที่การปรับขนาด CSS มีการลดรอยหยัก ซึ่งเราหาวิธีป้องกันไม่ได้
ปัญหานี้ทำให้เกมของเราไม่ผ่านเกณฑ์ เนื่องจากพิกเซลแต่ละพิกเซลมีความสำคัญมาก แต่หากจำเป็นต้องปรับขนาดแคนวาสและการปรับเกลี่ยภาพเหมาะสมกับโปรเจ็กต์ของคุณ คุณก็ลองใช้แนวทางนี้เพื่อเหตุผลด้านประสิทธิภาพได้
เคล็ดลับสนุกๆ สำหรับภาพพิมพ์แคนวาส
เราทุกคนทราบดีว่า <canvas>
เป็นเทรนด์ใหม่ แต่บางครั้งนักพัฒนาซอฟต์แวร์ก็ยังคงแนะนำให้ใช้ DOM หากยังไม่แน่ใจว่าจะเลือกใช้ฟีเจอร์ใด ต่อไปนี้เป็นตัวอย่างวิธีที่ <canvas>
ช่วยเราประหยัดเวลาและพลังงานได้มาก
เมื่อศัตรูโดนโจมตีในการโจมตีอย่างต่อเนื่อง Arena ไฟจะกะพริบสีแดงและแสดงภาพเคลื่อนไหว "ความเจ็บปวด" สั้นๆ เราแสดงศัตรูที่ "เจ็บปวด" ในลักษณะคว่ำหน้าลงเท่านั้นเพื่อจำกัดจำนวนกราฟิกที่เราต้องสร้าง ลักษณะนี้ดูดีในเกมและช่วยประหยัดเวลาในการสร้างสไปรท์ได้เป็นอย่างมาก อย่างไรก็ตาม สำหรับบอสมอนสเตอร์ การที่ได้เห็นสไปรท์ขนาดใหญ่ (ขนาด 64x64 พิกเซลขึ้นไป) เปลี่ยนจากหันไปทางซ้ายหรือขึ้นเป็นหันลงอย่างกะทันหันสำหรับเฟรมเจ็บปวดนั้นดูน่าอึดอัด
วิธีที่ชัดเจนคือวาดเฟรมปัญหาสำหรับบอสแต่ละตัวในแต่ละทิศทางทั้ง 8 ทิศทาง แต่วิธีนี้ใช้เวลานานมาก ขอขอบคุณ
<canvas>
ที่ช่วยให้เราแก้ปัญหานี้ในโค้ดได้
ก่อนอื่นเราจะวาดมอนสเตอร์ไปยัง "บัฟเฟอร์" <canvas>
ที่ซ่อนอยู่ แล้ววางซ้อนด้วยสีแดง จากนั้นจึงแสดงผลลัพธ์กลับไปยังหน้าจอ โค้ดจะมีลักษณะดังนี้
// Get the "buffer" canvas (that isn't visible to the user)
var bufferCanvas = document.getElementById("buffer");
var buffer = bufferCanvas.getContext("2d");
// Draw your image on the buffer
buffer.drawImage(image, 0, 0);
// Draw a rectangle over the image using a nice translucent overlay
buffer.save();
buffer.globalCompositeOperation = "source-in";
buffer.fillStyle = "rgba(186, 51, 35, 0.6)"; // red
buffer.fillRect(0, 0, image.width, image.height);
buffer.restore();
// Copy the buffer onto the visible canvas
document.getElementById("stage").getContext("2d").drawImage(bufferCanvas, x, y);
Game Loop
การพัฒนาเกมมีความแตกต่างจากการพัฒนาเว็บในบางประการ ในแพ็กเกจเว็บ เป็นเรื่องปกติที่ระบบจะตอบสนองต่อเหตุการณ์ที่เกิดขึ้นผ่าน Listener เหตุการณ์ ดังนั้นโค้ดเริ่มต้นอาจไม่ทําอะไรเลยนอกจากรอเหตุการณ์อินพุต ตรรกะของเกมจะแตกต่างออกไป เนื่องจากจำเป็นต้องอัปเดตตัวเองอยู่เสมอ ตัวอย่างเช่น หากผู้เล่นไม่ได้ขยับ ก็ไม่ได้หมายความว่าพวกก็อบลินจะหยุดโจมตีผู้เล่นไม่ได้
ตัวอย่างของลูปเกมมีดังนี้
function main () {
handleInput();
update();
render();
};
setInterval(main, 1);
ความแตกต่างที่สําคัญประการแรกคือ handleInput
function
ไม่ได้ทําอะไรเลยในทันที หากผู้ใช้กดแป้นในเว็บแอปทั่วไป การดำเนินการที่ต้องการควรเกิดขึ้นทันที แต่สำหรับเกม ทุกอย่างต้องเกิดขึ้นตามลำดับเวลาเพื่อให้ดำเนินไปอย่างถูกต้อง
window.addEventListener("mousedown", function(e) {
// A mouse click means the players wants to attack.
// We don't actually do that yet, but instead tell the rest
// of the program about the request.
buttonStates[e.button] = true;
}, false);
function handleInput() {
// Here is where we respond to the click
if (buttonStates[LEFT_BUTTON]) {
player.attacking = true;
delete buttonStates[LEFT_BUTTON];
}
};
ตอนนี้เราทราบข้อมูลอินพุตแล้วและสามารถพิจารณาข้อมูลดังกล่าวในฟังก์ชัน update
โดยที่ทราบว่าข้อมูลดังกล่าวจะเป็นไปตามกฎอื่นๆ ของเกม
function update() {
// Check for collisions, states, whatever else is needed
// If after that the player can still attack, do it!
if (player.attacking && player.canAttack()) {
player.attack();
}
};
สุดท้าย เมื่อประมวลผลทุกอย่างเสร็จแล้ว ก็ถึงเวลาวาดหน้าจออีกครั้ง
ใน DOM เบราว์เซอร์จะจัดการกับภาระหนักนี้ แต่หากใช้ <canvas>
คุณจะต้องวาดภาพใหม่ด้วยตนเองทุกครั้งที่มีเหตุการณ์เกิดขึ้น (ซึ่งมักจะเป็นทุกเฟรม)
function render() {
// First erase everything, something like:
context.clearRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
// Draw the player (and whatever else you need)
context.drawImage(
player.getImage(),
player.x, player.y
);
};
การประมาณตามเวลา
การประมาณตามเวลาเป็นแนวคิดในการย้ายสไปรท์ตามระยะเวลาที่ผ่านไปนับตั้งแต่การอัปเดตเฟรมล่าสุด เทคนิคนี้ช่วยให้เกมทำงานได้เร็วที่สุดเท่าที่จะเป็นไปได้ ในขณะเดียวกันก็ช่วยให้มั่นใจว่าสไปรท์จะเคลื่อนไหวด้วยความเร็วที่สอดคล้องกัน
หากต้องการใช้การประมาณตามเวลา เราต้องบันทึกเวลาผ่านไปนับตั้งแต่วาดเฟรมล่าสุด เราจะต้องเพิ่มฟังก์ชัน update()
ของลูปเกมเพื่อติดตามเรื่องนี้
function update() {
// NOTE: You'll need to initially seed this.lastUpdate
// with the current time when your game loop starts
// this.lastUpdate = Date.now();
// Calculate elapsed time since last frame
var now = Date.now();
var elapsed = (now - this.lastUpdate);
this.lastUpdate = now;
// Do stuff with elapsed
};
เมื่อทราบเวลาผ่านไปแล้ว เราก็คำนวณได้ว่าสไปรต์หนึ่งๆ ควรเลื่อนไปแต่ละเฟรมไกลแค่ไหน ก่อนอื่น เราต้องติดตามข้อมูลบางอย่างบนออบเจ็กต์สไปรท์ ได้แก่ ตำแหน่งปัจจุบัน ความเร็ว และทิศทาง
var Sprite = function() {
// The sprite's position relative to the top left of the game world
this.position = {x: 0, y: 0};
// The sprite's direction. A positive x value indicates moving to the right
this.direction = {x: 1, y: 0};
// How many pixels the sprite moves per second
this.speed = 50;
};
เมื่อพิจารณาตัวแปรเหล่านี้แล้ว วิธีที่เราย้ายอินสแตนซ์ของคลาสสไปรต์ข้างต้นโดยใช้โมเดลตามเวลามีดังนี้
// Determine how far this sprite will move this frame
var distance = (sprite.speed / 1000) * elapsed;
// Apply the movement distance to the sprite's current position
// taking into account its direction
sprite.position.x += (distance * sprite.direction.x);
sprite.position.y += (distance * sprite.direction.y);
โปรดทราบว่าค่า direction.x
และ direction.y
ควรเป็นค่ามาตรฐาน ซึ่งหมายความว่าค่าดังกล่าวควรอยู่ระหว่าง -1
ถึง 1
เสมอ
การควบคุม
การควบคุมอาจเป็นปัญหาที่ใหญ่ที่สุดในการพัฒนา Onslaught Arena เดโมแรกสุดรองรับเฉพาะแป้นพิมพ์ โดยผู้เล่นจะบังคับตัวละครหลักไปรอบๆ หน้าจอด้วยแป้นลูกศรและยิงไปในทิศทางที่ตัวละครหันหน้าไปโดยใช้แป้นเว้นวรรค แม้ว่าจะค่อนข้างใช้งานง่ายและเข้าใจได้ แต่ฟีเจอร์นี้ทำให้เกมเล่นได้ยากมากในด่านที่ยากขึ้น เมื่อศัตรูและกระสุนหลายสิบลูกพุ่งเข้าใส่ผู้เล่นทุกเมื่อ คุณจึงต้องหลบหลีกศัตรูขณะยิงไปทุกทิศทาง
เราได้เพิ่มการรองรับเมาส์เพื่อควบคุมเป้าเล็ง ซึ่งตัวละครจะใช้เพื่อเล็งโจมตี เพื่อเปรียบเทียบกับเกมที่คล้ายกันในประเภทเดียวกัน ตัวละครยังคงบังคับด้วยแป้นพิมพ์ได้ แต่หลังจากการเปลี่ยนแปลงนี้ ตัวละครจะยิงได้พร้อมกันในทุกทิศทาง 360 องศา ผู้เล่นที่เล่นอย่างจริงจังชื่นชอบฟีเจอร์นี้ แต่ฟีเจอร์นี้กลับส่งผลเสียต่อผู้ใช้แทร็กแพด
เราได้นําการควบคุมด้วยแป้นลูกศรกลับมาอีกครั้งเพื่อรองรับผู้ใช้แทร็กแพด โดยครั้งนี้จะอนุญาตให้ยิงในทิศทางที่กด แม้ว่าเราจะคิดว่าได้ให้บริการแก่ผู้เล่นทุกประเภทแล้ว แต่เราก็ได้เพิ่มองค์ประกอบที่ซับซ้อนมากเกินไปลงในเกมโดยไม่รู้ตัว เราประหลาดใจที่ได้ทราบในภายหลังว่าผู้เล่นบางรายไม่ทราบว่ามีการควบคุมด้วยเมาส์ (หรือแป้นพิมพ์) สำหรับการโจมตี ซึ่งเป็นสิ่งที่ไม่บังคับ แม้ว่าจะมีโมดัลบทแนะนำก็ตาม แต่ผู้เล่นส่วนใหญ่ไม่ได้สนใจ
นอกจากนี้ เรายังโชคดีที่มีแฟนๆ ชาวยุโรปบางส่วน แต่เราได้ยินความหงุดหงิดจากแฟนๆ เหล่านี้ว่าพวกเขาอาจไม่มีแป้นพิมพ์ QWERTY ทั่วไปและใช้แป้น WASD เพื่อการเคลื่อนไหวตามทิศทางไม่ได้ ผู้เล่นที่ใช้มือซ้ายก็แสดงการร้องเรียนที่คล้ายกัน
รูปแบบการควบคุมที่ซับซ้อนที่เรานำมาใช้นี้ยังทำให้เกิดปัญหาในการเล่นบนอุปกรณ์เคลื่อนที่ด้วย หนึ่งในคำขอที่พบบ่อยที่สุดของเราคือการทำให้ Onslaught Arena พร้อมให้บริการใน Android, iPad และอุปกรณ์อื่นๆ แบบสัมผัส (ที่ไม่มีแป้นพิมพ์) จุดแข็งหลักอย่างหนึ่งของ HTML5 คือความสามารถในการทำงานข้ามแพลตฟอร์ม ดังนั้นการนำเกมไปไว้ในอุปกรณ์เหล่านี้จึงทำได้อย่างแน่นอน เราแค่ต้องแก้ปัญหาต่างๆ (ที่สำคัญที่สุดคือการควบคุมและประสิทธิภาพ)
ในการแก้ไขปัญหาเหล่านี้ เราจึงเริ่มทดลองใช้วิธีการเล่นเกมแบบอินพุตเดียวซึ่งเกี่ยวข้องกับการโต้ตอบด้วยเมาส์ (หรือการสัมผัส) เท่านั้น ผู้เล่นคลิกหรือแตะหน้าจอ แล้วตัวละครหลักจะเดินไปยังตำแหน่งที่กด โจมตีคนร้ายที่อยู่ใกล้ที่สุดโดยอัตโนมัติ โค้ดจะมีลักษณะดังนี้
// Find the nearest hostile target (if any) to the player
var player = this.getPlayerObject();
var hostile = this.getNearestHostile(player);
if (hostile !== null) {
// Found one! Shoot in its direction
var shoot = hostile.boundingBox().center().subtract(
player.boundingBox().center()
).normalize();
}
// Move towards where the player clicked/touched
var move = this.targetReticle.position.clone().subtract(
player.boundingBox().center()
).normalize();
var distance = this.targetReticle.position.clone().subtract(
player.boundingBox().center()
).magnitude();
// Prevent jittering if the character is close enough
if (distance < 3) {
move.zero();
}
// Move the player
if ((move.x !== 0) || (move.y !== 0)) {
player.setDirection(move);
}
การยกเลิกปัจจัยเพิ่มเติมในการต้องเล็งศัตรูอาจทำให้เกมเล่นง่ายขึ้นในบางสถานการณ์ แต่เราคิดว่าการทำให้ผู้เล่นทำสิ่งต่างๆ ได้ง่ายขึ้นมีข้อดีหลายประการ กลยุทธ์อื่นๆ ก็เกิดขึ้น เช่น การวางตำแหน่งตัวละครให้อยู่ใกล้กับศัตรูที่เป็นอันตรายเพื่อกำหนดเป้าหมาย และความสามารถในการรองรับอุปกรณ์แบบสัมผัสก็มีประโยชน์อย่างยิ่ง
เสียง
การควบคุมและประสิทธิภาพเป็นหนึ่งในปัญหาที่ใหญ่ที่สุดของเราขณะพัฒนา Onslaught Arena คือแท็ก <audio>
ของ HTML5
ปัญหาที่ร้ายแรงที่สุดน่าจะเป็นเวลาในการตอบสนอง: ในเบราว์เซอร์เกือบทั้งหมดจะมีความล่าช้าระหว่างการเรียกใช้ .play()
กับการเล่นเสียงจริง ซึ่งอาจทำให้ประสบการณ์ของเกมเมอร์แย่ลง โดยเฉพาะเมื่อเล่นเกมที่มีจังหวะรวดเร็วอย่างเกมของเรา
ปัญหาอื่นๆ ได้แก่ เหตุการณ์ "progress" ไม่เริ่มทํางาน ซึ่งอาจทําให้ขั้นตอนการโหลดของเกมค้างไว้อย่างไม่มีกําหนด ด้วยเหตุนี้ เราจึงใช้วิธีการที่เรียกว่า "เปลี่ยนไปใช้วิธีอื่น" ซึ่งในกรณีที่ Flash โหลดไม่สำเร็จ เราจะเปลี่ยนไปใช้เสียง HTML5 โค้ดจะมีลักษณะดังนี้
/*
This example uses the SoundManager 2 library by Scott Schiller:
http://www.schillmania.com/projects/soundmanager2/
*/
// Default to sm2 (Flash)
var api = "sm2";
function initAudio (callback) {
switch (api) {
case "sm2":
soundManager.onerror = (function (init) {
return function () {
api = "html5";
init(callback);
};
}(arguments.callee));
break;
case "html5":
var audio = document.createElement("audio");
if (
audio
&& audio.canPlayType
&& audio.canPlayType("audio/mpeg;")
) {
callback();
} else {
// No audio support :(
}
break;
}
};
นอกจากนี้ เกมยังควรรองรับเบราว์เซอร์ที่ไม่เล่นไฟล์ MP3 (เช่น Mozilla Firefox) ด้วย ในกรณีนี้ ระบบจะตรวจหาการรองรับและเปลี่ยนไปใช้ Ogg Vorbis โดยมีโค้ดดังนี้
/*
Note: you could instead use "new Audio()" here,
but the client will throw an error if it doesn't support Audio,
which makes using "document.createElement" a safer approach.
*/
var audio = document.createElement("audio");
if (audio && audio.canPlayType) {
if (!audio.canPlayType("audio/mpeg;")) {
// Here you know you CANNOT use .mp3 files
if (audio.canPlayType("audio/ogg; codecs=vorbis")) {
// Here you know you CAN use .ogg files
}
}
}
การประหยัดอินเทอร์เน็ต
เกมยิงในสไตล์อาร์เคดจะไม่มีคะแนนสูงได้อย่างไร เรารู้ว่าจะต้องเก็บข้อมูลเกมบางส่วนไว้ และแม้ว่าจะใช้วิธีการแบบเก่าอย่างคุกกี้ได้ แต่เราก็อยากลองใช้เทคโนโลยี HTML5 ใหม่ที่น่าสนใจ ตัวเลือกมีมากมาย เช่น พื้นที่เก็บข้อมูลในเครื่อง พื้นที่เก็บข้อมูลเซสชัน และฐานข้อมูล SQL ในเว็บ
เราตัดสินใจใช้ localStorage
เนื่องจากเป็นเครื่องมือใหม่ ยอดเยี่ยม และใช้งานง่าย รองรับการบันทึกคู่คีย์/ค่าพื้นฐาน ซึ่งเป็นสิ่งที่เกมง่ายๆ ของเราต้องการ ตัวอย่างวิธีใช้ที่เข้าใจง่ายมีดังนี้
if (typeof localStorage == "object") {
localStorage.setItem("foo", "bar");
localStorage.getItem("foo"); // Value is "bar"
localStorage.removeItem("foo");
localStorage.getItem("foo"); // Value is now null
}
โปรดระวัง "ข้อควรระวัง" บางประการ ไม่ว่าคุณจะส่งค่าใด ระบบจะจัดเก็บค่าเป็นสตริง ซึ่งอาจทําให้ได้ผลลัพธ์ที่ไม่คาดคิดดังต่อไปนี้
localStorage.setItem("foo", false);
typeof localStorage.getItem("foo"); // Value is "false" (a string literal)
if (localStorage.getItem("foo")) {
// It's true!
}
// Don't pass objects into setItem
localStorage.setItem("bar", {"key": "value"});
localStorage.getItem("bar"); // Value is "[object Object]" (a string literal)
// JSON stringify and parse when dealing with localStorage
localStorage.setItem("json", JSON.stringify({"key": "value"}));
typeof localStorage.getItem("json"); // string
JSON.parse(localStorage.getItem("json")); // {"key": "value"}
สรุป
HTML5 เป็นเครื่องมือที่ยอดเยี่ยม การใช้งานส่วนใหญ่จะจัดการทุกอย่างที่นักพัฒนาเกมต้องการ ตั้งแต่กราฟิกไปจนถึงการบันทึกสถานะเกม แม้ว่าจะมีปัญหาบางอย่าง (เช่น ปัญหาเกี่ยวกับแท็ก <audio>
) แต่นักพัฒนาเบราว์เซอร์ก็พัฒนาไปอย่างรวดเร็ว และอนาคตของเกมที่สร้างด้วย HTML5 ก็ดูสดใสแล้ว