กรณีศึกษา - สังหาร! สนามกีฬา

บทนำ

ในเดือนมิถุนายน 2010 เราพบว่าBoing Boing ซึ่งเป็น "นิตยสาร" ท้องถิ่นกำลังจัดการแข่งขันการพัฒนาเกม เราจึงถือโอกาสนี้สร้างเกมง่ายๆ อย่างรวดเร็วใน JavaScript และ <canvas> หลังจากการแข่งขัน เรายังมีไอเดียมากมายและอยากทำสิ่งที่เริ่มไว้ให้เสร็จ ต่อไปนี้คือกรณีศึกษาของผลลัพธ์ที่ได้ ซึ่งเป็นเกมเล็กๆ ชื่อ Onslaught Arena

รูปลักษณ์ย้อนยุคแบบพิกเซล

เกมของเราต้องดูและรู้สึกเหมือนเกม Nintendo Entertainment System แบบย้อนยุค เนื่องจากโจทย์การแข่งขันคือการพัฒนาเกมตามชิปทิวน์ เกมส่วนใหญ่ไม่มีข้อกำหนดนี้ แต่ยังคงเป็นสไตล์ศิลปะที่พบได้ทั่วไป (โดยเฉพาะในหมู่นักพัฒนาเกมอินดี้) เนื่องจากการสร้างชิ้นงานได้ง่ายและดึงดูดใจเกมเมอร์ที่โหยหาอดีต

การโจมตี ขนาดพิกเซลของ Arena
การเพิ่มขนาดพิกเซลอาจทำให้งานออกแบบกราฟิกน้อยลง

เนื่องจากสไปรต์เหล่านี้มีขนาดเล็ก เราจึงตัดสินใจเพิ่มขนาดพิกเซลเป็น 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
ซ้าย: ชิ้นงานที่แสดงผลอย่างละเอียดระดับพิกเซลซึ่งทำซ้ำใน Photoshop ขวา: การปรับขนาด CSS เพิ่มเอฟเฟกต์เบลอ

ปัญหานี้ทำให้เกมของเราไม่ผ่านเกณฑ์ เนื่องจากพิกเซลแต่ละพิกเซลมีความสำคัญมาก แต่หากจำเป็นต้องปรับขนาดแคนวาสและการปรับเกลี่ยภาพเหมาะสมกับโปรเจ็กต์ของคุณ คุณก็ลองใช้แนวทางนี้เพื่อเหตุผลด้านประสิทธิภาพได้

เคล็ดลับสนุกๆ สำหรับภาพพิมพ์แคนวาส

เราทุกคนทราบดีว่า <canvas> เป็นเทรนด์ใหม่ แต่บางครั้งนักพัฒนาซอฟต์แวร์ก็ยังคงแนะนำให้ใช้ DOM หากยังไม่แน่ใจว่าจะเลือกใช้ฟีเจอร์ใด ต่อไปนี้เป็นตัวอย่างวิธีที่ <canvas> ช่วยเราประหยัดเวลาและพลังงานได้มาก

เมื่อศัตรูโดนโจมตีในการโจมตีอย่างต่อเนื่อง Arena ไฟจะกะพริบสีแดงและแสดงภาพเคลื่อนไหว "ความเจ็บปวด" สั้นๆ เราแสดงศัตรูที่ "เจ็บปวด" ในลักษณะคว่ำหน้าลงเท่านั้นเพื่อจำกัดจำนวนกราฟิกที่เราต้องสร้าง ลักษณะนี้ดูดีในเกมและช่วยประหยัดเวลาในการสร้างสไปรท์ได้เป็นอย่างมาก อย่างไรก็ตาม สำหรับบอสมอนสเตอร์ การที่ได้เห็นสไปรท์ขนาดใหญ่ (ขนาด 64x64 พิกเซลขึ้นไป) เปลี่ยนจากหันไปทางซ้ายหรือขึ้นเป็นหันลงอย่างกะทันหันสำหรับเฟรมเจ็บปวดนั้นดูน่าอึดอัด

วิธีที่ชัดเจนคือวาดเฟรมปัญหาสำหรับบอสแต่ละตัวในแต่ละทิศทางทั้ง 8 ทิศทาง แต่วิธีนี้ใช้เวลานานมาก ขอขอบคุณ <canvas>ที่ช่วยให้เราแก้ปัญหานี้ในโค้ดได้

Beholder ได้รับบาดเจ็บในโหมดโจมตีอย่างต่อเนื่อง สนามกีฬา
สร้างเอฟเฟกต์ที่น่าสนใจได้โดยใช้ context.globalCompositeOperation

ก่อนอื่นเราจะวาดมอนสเตอร์ไปยัง "บัฟเฟอร์" <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 องศา ผู้เล่นที่เล่นอย่างจริงจังชื่นชอบฟีเจอร์นี้ แต่ฟีเจอร์นี้กลับส่งผลเสียต่อผู้ใช้แทร็กแพด

การโจมตี โมดัลการควบคุมอารีน่า (เลิกใช้งานแล้ว)
การควบคุมแบบเก่าหรือโมดัล "วิธีเล่น" ใน Onslaught Arena

เราได้นําการควบคุมด้วยแป้นลูกศรกลับมาอีกครั้งเพื่อรองรับผู้ใช้แทร็กแพด โดยครั้งนี้จะอนุญาตให้ยิงในทิศทางที่กด แม้ว่าเราจะคิดว่าได้ให้บริการแก่ผู้เล่นทุกประเภทแล้ว แต่เราก็ได้เพิ่มองค์ประกอบที่ซับซ้อนมากเกินไปลงในเกมโดยไม่รู้ตัว เราประหลาดใจที่ได้ทราบในภายหลังว่าผู้เล่นบางรายไม่ทราบว่ามีการควบคุมด้วยเมาส์ (หรือแป้นพิมพ์) สำหรับการโจมตี ซึ่งเป็นสิ่งที่ไม่บังคับ แม้ว่าจะมีโมดัลบทแนะนำก็ตาม แต่ผู้เล่นส่วนใหญ่ไม่ได้สนใจ

การโจมตี บทแนะนำการควบคุมใน Arena
ผู้เล่นส่วนใหญ่ไม่สนใจบทแนะนำที่วางซ้อนกัน ผู้เล่นต้องการเล่นและสนุกมากกว่า

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

ALT_TEXT_HERE
ระบบจะบันทึกคะแนนสูงสุด รวมถึงอันดับของคุณในเกมหลังจากเอาชนะบอสแต่ละตัว

เราตัดสินใจใช้ 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 ก็ดูสดใสแล้ว

การโจมตี สนามกีฬาที่มีโลโก้ HTML5 ซ่อนอยู่
คุณจะได้รับโล่ HTML5 โดยพิมพ์ "html5" เมื่อเล่น Onslaught Arena