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 nhà xuất bản địa phương "zine" Boing Boing đang tổ chức một cuộc thi phát triển trò chơi. Chúng tôi thấy đây là một lý do hoàn toàn hợp lý để tạo ra một trò chơi đơn giản, nhanh chóng trong JavaScript và <canvas>, vì vậy, chúng tôi bắt tay vào thực hiện. Sau cuộc thi, chúng tôi vẫn có rất 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! Đấu trường.

Giao diện kiểu pixel xưa cũ

Điều quan trọng là trò chơi của chúng tôi phải có giao diện như một trò chơi Hệ thống giải trí Nintendo hoài cổ, dựa trên bối cảnh cuộc thi để phát triển trò chơi dựa trên tinh vân. Hầu hết 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à đối với các nhà phát triển trò chơi độc lập) do nó dễ dàng tạo tài sản và có sức hút tự nhiên đối với những người chơi hoài niệm.

Sẵn sàng tấn công! Kích thước pixel của sân vận động
Việc tăng kích thước pixel có thể làm giảm công việc thiết kế đồ hoạ.

Dựa vào việc các sprite này nhỏ đến mức nào, chúng tôi đã quyết định nhân đôi pixel của mình, nghĩa là sprite 16x16 giờ đây sẽ là 32x32 pixel, v.v. Ngay từ đầu, chúng tôi đã tăng cường gấp đôi khía cạnh tạo thành phần thay vì để trình duyệt thực hiện các công việc khó khăn. Cách này đơn giản dễ triển khai hơn nhưng cũng có một số ưu điểm rõ ràng về giao diện.

Dưới đây là 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 chúng ở phía tạo thành phần. Tại đó, CSS sẽ tiếp quản và đổi kích thước canvas. Đ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 phương thức kết xuất hình ảnh lớn hơn (gấp đôi), nhưng rất tiếc, việc đổi kích thước CSS bao gồm cả việc khử răng cưa, một vấn đề 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
Trái: số lượng thành phần pixel hoàn hảo tăng gấp đôi trong Photoshop. Phải: Việc đổi kích thước CSS đã thêm hiệu ứng mờ.

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

Thủ thuật canvas thú vị

Chúng ta đều biết <canvas> là mới nổ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 công cụ nào, sau đây là ví dụ về cách <canvas> đã giúp chúng tôi tiết kiệm rất nhiều thời gian và công sức.

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

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

Hãy xem đang bị thiệt hại trong Onslaught! Sân khấu/Vũ đài
Có thể tạo ra các hiệu ứng thú vị bằng cách sử dụng Contextual.globalComponentOperation.

Trước tiên, chúng ta vẽ quái vật vào một "vùng đệm" <canvas> ẩn, lớp phủ nó bằng màu đỏ, sau đó hiển thị 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

Phát triển trò chơi có một số khác biệt đáng chú ý so với phát triển web. Trong ngăn xếp web, chúng ta thường 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ì khác ngoài việc theo dõi các sự kiện đầu vào. Logic của trò chơi thì khác, vì trò chơi cần phải liên tục tự cập nhật. Chẳng hạn, nếu một người chơi chưa di chuyển thì điều đó không ngăn yêu tinh có được anh ta!

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 không thực sự thực hiện bất kỳ hành động nào ngay lập tức. Nếu người dùng nhấn một phím trong một ứng dụng web thông thường, thì việc thực hiện ngay hành động mong muốn là điều hợp lý. Tuy nhiên, trong trò chơi, mọi thứ phải xảy ra theo thứ tự thời gian thì mới diễn ra chính xác.

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 vì 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 tính toán xong mọi thứ, đã đến lúc vẽ lại màn hình! Trong DOM-land, trình duyệt sẽ xử lý quá trình nâng cấp lên này. Tuy nhiên, khi sử dụng <canvas>, bạn cần phải vẽ lại theo cách thủ công bất cứ khi nào có điều gì đó xảy ra (thường là mỗi một 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

Lập mô hình 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 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 ở tốc độ nhất quán.

Để sử dụng tính năng lập mô hình dựa trên thời gian, chúng ta cần thu thập dữ liệu về thời gian đã trôi qua kể từ khi khung hình gần đây nhất được vẽ. Chúng tôi cần tăng cường hàm update() của vòng lặp trò chơi để theo dõi thông tin 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

};

Giờ đây, 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 vài yếu tố 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 sử dụng phương pháp 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á, có nghĩa 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ác chế độ kiểm soát có thể là trở ngại lớn nhất trong quá trình phát triển Onslaught! Đấu trường. 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à kích hoạt theo hướng phím cách mà họ nhìn thấy. Mặc dù có phần trực quan và dễ nắm bắt, nhưng trò chơi này gần như không chơi được ở các cấp độ khó hơn. Với hàng chục kẻ địch và đạn bay vào người chơi bất cứ lúc nào, bắt buộc phải có thể đan xen 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ự cùng thể loại, chúng tôi đã thêm tính năng hỗ trợ chuột để điều khiển ô nhắm mục tiêu mà nhân vật sẽ sử dụng để nhắm các cuộc tấn công. Nhân vật vẫn có thể di chuyển bằng bàn phím, nhưng sau thay đổi này, anh ấy có thể kích hoạt đồng thời theo mọi hướng 360 độ đầy đủ. Người chơi kỳ cựu đánh giá cao tính năng này, nhưng tính năng này lại gây ra tác dụng phụ đáng tiếc là người dùng bàn di chuột cảm thấy khó chịu.

Sẵn sàng tấn công! Phương thức điều khiển sân vận động (không dùng nữa)
Một chế độ điều khiển cũ hoặc "cách chơi" trong Onslaught! Sân.

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

Sẵn sàng tấn công! Hướng dẫn điều khiển sân đấu
Người chơi hầu như bỏ qua lớp phủ hướng dẫn; họ muốn được chơi vui vẻ!

Chúng tôi cũng may mắn có một số người hâm mộ ở Châu Âu, nhưng họ cũng thấy thất vọng về việc họ 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 để chuyển động theo hướ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 lược đồ điều khiển phức tạp mà chúng tôi đã triển khai này, cũng có vấn đề khi chơi trên thiết bị di động. Trên thực tế, một trong những yêu cầu phổ biến nhất của chúng tôi là thực hiện Onslaught! Arena có trên Android, iPad và các thiết bị cảm ứng khác (khi không có bàn phím). Một trong những điểm mạnh cốt lõi của HTML5 là khả năng di động. Vì vậy, việc đưa trò chơi lên các thiết bị này chắc chắn là có thể làm được, chúng tôi chỉ phải giải quyết nhiều vấn đề (đáng chú ý nhất là các vấn đề kiểm soát 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 chơi một đầu vào chỉ bao gồm 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 đi về phía vị trí đã 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à việc phải nhắm vào kẻ thù có thể giúp trò chơi trở nên dễ dàng hơn trong một số tình huống, nhưng chúng tôi cảm thấy rằng việc làm đơn giản hơn cho người chơi sẽ có nhiều lợi thế. Xuất hiện các chiến lược khác, chẳng hạn như việc phải đặt nhân vật ở gần kẻ thù nguy hiểm để nhắm vào chúng và khả năng hỗ trợ các thiết bị cảm ứng là vô giá.

Âm thanh

Trong số các biện pháp kiểm soát và hiệu suất, một trong những vấn đề lớn nhất của chúng tôi trong quá trình 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 mọi trình duyệt, sẽ 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 quy trình 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 đã áp dụng phương thức "tiếp theo", trong đó 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;
  }
};

Một điều quan trọng nữa là một trò chơi phải hỗ trợ các trình duyệt không phát các tệp MP3 (chẳng hạn như Mozilla Firefox). Trong trường hợp này, bạn có thể phát hiện và chuyển dịch vụ hỗ trợ sang 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
    }
  }
}

Đang lưu dữ liệu

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

ALT_TEXT_HERE
Điểm cao sẽ được lưu, cũng như vị trí của bạn trong trò chơi sau khi đánh bại từng 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. API này hỗ trợ lưu các cặp giá trị/khoá cơ bản, tất cả những gì trò chơi đơn giản của chúng tôi cần có. Sau đây là ví dụ đơn giản về cách sử dụng mã này:

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

Có một số "giao diện người dùng" cần lưu ý. Bất kể bạn truyền dữ liệu gì, các giá trị vẫn đượ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 rất tuyệt vời. Hầu hết các quá trình triển khai đều xử lý mọi thứ mà nhà phát triển trò chơi cần, từ đồ hoạ cho đến lưu trạng thái trò chơi. Mặc dù vẫn còn một số vấn đề ngày càng tăng (chẳng hạn như vấn đề về thẻ <audio>), các nhà phát triển trình duyệt đang phát triển nhanh chóng và với những thứ vốn đã tuyệt vời như vậy, nhưng tương lai có vẻ tươi sáng với các trò chơi được xây dựng trên HTML5.

Sẵn sàng tấn công! Nhà thi đấu có biểu trưng HTML5 ẩn
Bạn có thể nhận khiên HTML5 bằng cách nhập "html5" khi chơi Onslaught! Sân.