Nghiên cứu điển hình – Onslaught! Sân khấu/Vũ đài

Geoff Blair
Geoff Blair
Matt Hackett
Matt Hackett

Giới thiệu

Vào tháng 6 năm 2010, chúng tôi nhận thấy rằng "zine" phát hành tại địa phương Boing Boing đang tổ chức một cuộc thi phát triển trò chơi. Chúng tôi coi đây là lý do hoàn hảo để tạo một trò chơi nhanh chóng và đơn giản bằng JavaScript và <canvas>, vì vậy, chúng tôi bắt tay vào làm việc. Sau cuộc thi, chúng tôi vẫn còn nhiều ý tưởng và muốn hoàn thành những gì đã bắt đầu. Sau đây là nghiên cứu điển hình về kết quả, một trò chơi nhỏ có tên là Onslaught! Arena.

Giao diện hoài cổ, bị vỡ ảnh

Điều quan trọng là trò chơi của chúng tôi phải có giao diện giống như một trò chơi Nintendo Entertainment System cổ điển, vì điều kiện của cuộc thi là phát triển một trò chơi dựa trên chiptune. Hầu hết các trò chơi không có yêu cầu này, nhưng đây vẫn là một phong cách nghệ thuật phổ biến (đặc biệt là trong số các nhà phát triển độc lập) do dễ tạo tài sản và thu hút tự nhiên đối với những người chơi hoài cổ.

Onslaught! Kích thước pixel của đấu trường
Việc tăng kích thước pixel có thể làm giảm công việc thiết kế đồ hoạ.

Do các sprite này quá nhỏ, nên chúng tôi quyết định tăng gấp đôi số pixel, tức là một sprite 16x16 hiện sẽ có kích thước 32x32 pixel, v.v. Ngay từ đầu, chúng tôi đã tăng gấp đôi hoạt động tạo thành phần thay vì để trình duyệt thực hiện các thao tác nặng. Điều này đơn giản là dễ triển khai hơn nhưng cũng có một số lợi thế rõ ràng về giao diện.

Sau đây là một tình huống mà chúng tôi đã xem xét:

<style>
canvas {
  width: 640px;
  height: 320px;
}
</style>
<canvas width="320" height="240">
  Sorry, your browser is not supported.
</canvas>

Phương thức này sẽ bao gồm các sprite 1x1 thay vì nhân đôi các sprite đó ở phía tạo tài sản. Từ đó, CSS sẽ tiếp quản và tự đổi kích thước canvas. Các phép đo điểm chuẩn của chúng tôi cho thấy rằng phương thức này có thể nhanh gấp đôi so với việc kết xuất hình ảnh lớn hơn (gấp đôi), nhưng đáng tiếc là việc đổi kích thước CSS bao gồm cả tính năng khử răng cưa, một điều mà chúng tôi không thể tìm ra cách ngăn chặn.

Tuỳ chọn đổi kích thước canvas
Bên trái: các thành phần pixel hoàn hảo được nhân đôi trong Photoshop. Phải: CSS đổi kích thước đã thêm hiệu ứng mờ.

Đây là một vấn đề lớn đối với trò chơi của chúng ta vì từng pixel đều rất quan trọng. Tuy nhiên, nếu cần đổi kích thước canvas và tính năng khử răng cưa phù hợp với dự án, bạn có thể cân nhắc phương pháp này vì lý do hiệu suất.

Các thủ thuật thú vị trên canvas

Chúng ta đều biết rằng <canvas> là xu hướng mới, nhưng đôi khi các nhà phát triển vẫn khuyên bạn nên sử dụng DOM. Nếu bạn đang phân vân không biết nên sử dụng phương thức nào, sau đây là một ví dụ về cách <canvas> giúp chúng tôi tiết kiệm nhiều thời gian và công sức.

Khi một kẻ thù bị trúng đòn trong Onslaught! Arena, nó sẽ nhấp nháy màu đỏ và hiển thị nhanh ảnh động "đau". Để giới hạn số lượng đồ hoạ cần tạo, chúng ta chỉ hiển thị kẻ thù đang "đau đớn" theo hướng mặt xuống. Điều này có vẻ chấp nhận được trong trò chơi và tiết kiệm rất nhiều thời gian tạo sprite. Tuy nhiên, đối với các con trùm, thật khó chịu khi thấy một sprite lớn (ở độ phân giải 64x64 pixel trở lên) chụp từ hướng trái hoặc lên đột nhiên hướng xuống cho khung hình đau đớn.

Giải pháp rõ ràng là vẽ một khung đau đớn cho mỗi trùm theo tám hướng, nhưng việc này sẽ rất tốn thời gian. Nhờ <canvas>, chúng ta có thể giải quyết vấn đề này trong mã:

Beholder đang chịu sát thương trong Onslaught! Sân khấu/Vũ đài
Bạn có thể tạo các hiệu ứng thú vị bằng cách sử dụng context.globalCompositeOperation.

Trước tiên, chúng ta vẽ quái vật vào một "vùng đệm" <canvas> ẩn, phủ màu đỏ lên rồi kết xuất kết quả trở lại màn hình. Mã sẽ có dạng như sau:

// 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);

Vòng lặp trò chơi

Việc phát triển trò chơi có một số điểm khác biệt đáng chú ý so với phát triển web. Trong ngăn xếp web, thường thì bạn sẽ phản ứng với các sự kiện đã xảy ra thông qua trình nghe sự kiện. Vì vậy, mã khởi chạy có thể không làm gì ngoài việc nghe các sự kiện đầu vào. Logic của trò chơi khác vì cần phải liên tục tự cập nhật. Ví dụ: nếu người chơi chưa di chuyển, điều đó không có nghĩa là yêu tinh sẽ không bắt được người chơi!

Sau đây là ví dụ về vòng lặp trò chơi:

function main () {
  handleInput();
  update();
  render();
};

setInterval(main, 1);

Điểm khác biệt quan trọng đầu tiên là hàm handleInput thực sự không làm gì ngay lập tức. Nếu người dùng nhấn phím trong một ứng dụng web thông thường, thì bạn nên thực hiện ngay hành động mong muốn. Tuy nhiên, trong trò chơi, mọi thứ phải diễn ra theo thứ tự thời gian để diễn ra đúng cách.

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];
  }
};

Bây giờ, chúng ta đã biết về dữ liệu đầu vào và có thể xem xét dữ liệu đó trong hàm update, biết rằng dữ liệu đó sẽ tuân thủ các quy tắc còn lại của trò chơi.

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();
  }
};

Cuối cùng, sau khi mọi thứ đã được tính toán, đã đến lúc vẽ lại màn hình! Trong DOM, trình duyệt sẽ xử lý việc nâng lên này. Tuy nhiên, khi sử dụng <canvas>, bạn cần vẽ lại theo cách thủ công bất cứ khi nào có sự kiện xảy ra (thường là mỗi khung hình!).

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
  );
};

Lập mô hình dựa trên thời gian

Mô hình hoá dựa trên thời gian là khái niệm về việc di chuyển các sprite dựa trên khoảng thời gian đã trôi qua kể từ lần cập nhật khung hình gần đây nhất. Kỹ thuật này cho phép trò chơi của bạn chạy nhanh nhất có thể trong khi vẫn đảm bảo các sprite di chuyển với tốc độ nhất quán.

Để sử dụng mô hình dựa trên thời gian, chúng ta cần ghi lại thời gian đã trôi qua kể từ khi khung hình cuối cùng được vẽ. Chúng ta cần tăng cường hàm update() của vòng lặp trò chơi để theo dõi điều này.

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

};

Bây giờ, khi đã có thời gian đã trôi qua, chúng ta có thể tính toán khoảng cách mà một sprite nhất định sẽ di chuyển trong mỗi khung hình. Trước tiên, chúng ta cần theo dõi một số thông tin trên đối tượng sprite: Vị trí, tốc độ và hướng hiện tại.

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;
};

Với các biến này, sau đây là cách chúng ta di chuyển một thực thể của lớp sprite ở trên bằng cách lập mô hình dựa trên thời gian:

// 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);

Xin lưu ý rằng các giá trị direction.xdirection.y phải được chuẩn hoá, tức là các giá trị này phải luôn nằm trong khoảng từ -1 đến 1.

Các chế độ kiểm soát

Có thể nói, hệ thống điều khiển là trở ngại lớn nhất trong quá trình phát triển Onslaught! Arena. Bản minh hoạ đầu tiên chỉ hỗ trợ bàn phím; người chơi di chuyển nhân vật chính xung quanh màn hình bằng các phím mũi tên và bắn theo hướng nhân vật đang đối mặt bằng phím cách. Mặc dù khá trực quan và dễ nắm bắt, nhưng điều này khiến trò chơi gần như không thể chơi được ở các cấp độ khó hơn. Với hàng chục kẻ thù và đạn bay về phía người chơi tại bất kỳ thời điểm nào, bạn phải có khả năng lách giữa những kẻ xấu trong khi bắn theo bất kỳ hướng nào.

Để so sánh với các trò chơi tương tự trong cùng thể loại, chúng tôi đã thêm tính năng hỗ trợ chuột để điều khiển một mục tiêu nhắm mục tiêu mà nhân vật sẽ sử dụng để nhắm mục tiêu tấn công. Bạn vẫn có thể di chuyển nhân vật bằng bàn phím, nhưng sau thay đổi này, nhân vật có thể đồng thời bắn theo bất kỳ hướng nào 360 độ. Những người chơi chuyên nghiệp rất thích tính năng này, nhưng nó lại có tác dụng phụ đáng tiếc là gây khó chịu cho người dùng bàn di chuột.

Onslaught! Chế độ điều khiển Arena (không dùng nữa)
Chế độ điều khiển cũ hoặc "cách chơi" trong Onslaught! Sân khấu.

Để phục vụ người dùng bàn di chuột, chúng tôi đã đưa các nút điều khiển mũi tên trở lại, lần này cho phép bắn theo(các) hướng đã nhấn. Mặc dù cảm thấy mình đang phục vụ mọi loại người chơi, nhưng chúng tôi cũng vô tình đưa quá nhiều yếu tố phức tạp vào trò chơi. Thật ngạc nhiên, sau này chúng tôi được biết rằng một số người chơi không biết đến các chế độ điều khiển bằng chuột (hoặc bàn phím!) không bắt buộc để tấn công, mặc dù các cửa sổ hướng dẫn chủ yếu bị bỏ qua.

Onslaught! Hướng dẫn về các nút điều khiển trong Arena
Người chơi thường bỏ qua lớp phủ hướng dẫn; họ muốn chơi và tận hưởng trò chơi!

Chúng tôi cũng rất may mắn khi có một số người hâm mộ ở Châu Âu. Tuy nhiên, chúng tôi nhận thấy họ gặp khó khăn vì có thể không có bàn phím QWERTY thông thường và không thể sử dụng các phím WASD để di chuyển theo hướng. Những người chơi thuận tay trái cũng bày tỏ những khiếu nại tương tự.

Với giao diện điều khiển phức tạp mà chúng tôi đã triển khai, cũng có vấn đề khi chơi trên thiết bị di động. Thật vậy, một trong những yêu cầu phổ biến nhất của chúng tôi là tạo Onslaught! Arena có trên Android, iPad và các thiết bị cảm ứng khác (không có bàn phím). Một trong những thế mạnh cốt lõi của HTML5 là khả năng di chuyển, vì vậy, việc đưa trò chơi lên các thiết bị này là hoàn toàn khả thi, chúng ta chỉ cần giải quyết nhiều vấn đề (đáng chú ý nhất là các chế độ điều khiển và hiệu suất).

Để giải quyết nhiều vấn đề này, chúng tôi bắt đầu chơi bằng phương thức nhập một thao tác duy nhất trong lối chơi chỉ liên quan đến hoạt động tương tác bằng chuột (hoặc chạm). Người chơi nhấp hoặc chạm vào màn hình và nhân vật chính sẽ đi về phía vị trí được nhấn, tự động tấn công kẻ xấu gần nhất. Mã sẽ có dạng như sau:

// 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);
}

Việc loại bỏ yếu tố bổ sung là phải nhắm vào kẻ thù có thể giúp trò chơi dễ dàng hơn trong một số trường hợp, nhưng chúng tôi cảm thấy việc đơn giản hoá mọi thứ cho người chơi có nhiều lợi thế. Các chiến lược khác cũng xuất hiện, chẳng hạn như phải đặt nhân vật gần kẻ thù nguy hiểm để nhắm mục tiêu vào chúng, và khả năng hỗ trợ thiết bị cảm ứng là vô giá.

Âm thanh

Trong số các chế độ điều khiển và hiệu suất, một trong những vấn đề lớn nhất của chúng tôi khi phát triển Onslaught! Arena là thẻ <audio> của HTML5. Có lẽ khía cạnh tệ nhất là độ trễ: trong hầu hết các trình duyệt, có độ trễ giữa việc gọi .play() và âm thanh thực sự phát. Điều này có thể làm hỏng trải nghiệm của người chơi, đặc biệt là khi chơi một trò chơi có nhịp độ nhanh như trò chơi của chúng tôi.

Các vấn đề khác bao gồm sự kiện "tiến trình" không kích hoạt được, điều này có thể khiến luồng tải của trò chơi bị treo vô thời hạn. Vì những lý do này, chúng tôi đã sử dụng phương thức "chuyển tiếp". Nếu Flash không tải được, chúng tôi sẽ chuyển sang Âm thanh HTML5. Mã sẽ có dạng như sau:

/*
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;
  }
};

Trò chơi cũng cần hỗ trợ các trình duyệt không phát được tệp MP3 (chẳng hạn như Mozilla Firefox). Nếu đúng như vậy, bạn có thể phát hiện tính năng hỗ trợ và chuyển sang một tính năng như Ogg Vorbis bằng mã như sau:

/*
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
    }
  }
}

Lưu dữ liệu

Trò chơi bắn súng kiểu arcade không thể thiếu điểm số cao! Chúng tôi biết rằng mình cần lưu giữ một số dữ liệu trò chơi. Mặc dù có thể sử dụng một số công nghệ cũ như cookie, nhưng chúng tôi muốn tìm hiểu các công nghệ HTML5 mới mẻ và thú vị. Chắc chắn là bạn sẽ có nhiều lựa chọn, bao gồm Bộ nhớ cục bộ, Bộ nhớ phiên và Cơ sở dữ liệu SQL trên web.

ALT_TEXT_HERE
Điểm số cao cũng như vị trí của bạn trong trò chơi sẽ được lưu sau khi đánh bại mỗi trùm.

Chúng tôi quyết định sử dụng localStorage vì đây là một tính năng mới, tuyệt vời và dễ sử dụng. Phương thức này hỗ trợ việc lưu các cặp khoá/giá trị cơ bản, đây là tất cả những gì cần thiết cho trò chơi đơn giản của chúng ta. Sau đây là ví dụ đơn giản về cách sử dụng:

if (typeof localStorage == "object") {
  localStorage.setItem("foo", "bar");
  localStorage.getItem("foo"); // Value is "bar"
  localStorage.removeItem("foo");
  localStorage.getItem("foo"); // Value is now null
}

Bạn cần lưu ý một số "bẫy". Bất kể bạn truyền giá trị nào vào, giá trị sẽ được lưu trữ dưới dạng chuỗi, điều này có thể dẫn đến một số kết quả không mong muốn:

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"}

Tóm tắt

HTML5 là một công cụ tuyệt vời để làm việc. Hầu hết các phương thức triển khai đều xử lý mọi thứ mà nhà phát triển trò chơi cần, từ đồ hoạ đến việc lưu trạng thái trò chơi. Mặc dù có một số vấn đề khi phát triển (chẳng hạn như sự cố thẻ <audio>), nhưng các nhà phát triển trình duyệt đang tiến hành nhanh chóng và với những điều tuyệt vời như vậy, tương lai của các trò chơi được xây dựng trên HTML5 sẽ rất sáng sủa.

Onslaught! Trường đấu có biểu trưng HTML5 bị ẩn
Bạn có thể nhận được khiên HTML5 bằng cách nhập "html5" khi chơi Onslaught! Sân khấu.