우수사례 - Onslaught! 아레나

소개

2010년 6월, 현지 출판 '잡지'인 Boing Boing에서 게임 개발 대회를 개최한다는 소식이 전해졌습니다. 이는 JavaScript 및 <canvas>로 빠르고 간단한 게임을 만들기에 좋은 기회라고 생각하여 작업을 시작했습니다. 대회가 끝난 후에도 아이디어가 많았고 시작했던 일을 완료하고 싶었습니다. 다음은 이 결과에 관한 우수사례인 Onslaught! Arena를 참고하세요.

레트로한 모자이크 느낌

칩튠을 기반으로 게임을 개발한다는 대회 전제를 고려할 때 게임의 모양과 느낌이 레트로 Nintendo Entertainment System 게임처럼 느껴지는 것이 중요했습니다. 대부분의 게임에는 이 요구사항이 없지만 애셋을 쉽게 만들 수 있고 향수를 불러일으키는 게이머에게 자연스럽게 어필하기 때문에 특히 인디 개발자 사이에서 여전히 일반적인 예술적 스타일입니다.

맹공격! 경기장 픽셀 크기
픽셀 크기를 늘리면 그래픽 디자인 작업이 줄어들 수 있습니다.

이러한 스프라이트가 작기 때문에 픽셀을 두 배로 늘리기로 결정했습니다. 즉, 16x16 스프라이트가 이제 32x32 픽셀이 되는 식입니다. 처음부터 YouTube는 브라우저가 대규모 작업을 처리하도록 하는 대신 애셋 생성 측면에서 작업을 두 배로 늘렸습니다. 이 방법은 구현하기가 더 쉬웠을 뿐만 아니라 확실한 표시 이점도 있었습니다.

고려한 시나리오는 다음과 같습니다.

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

이 방법은 애셋 생성 측면에서 스프라이트를 두 배로 늘리는 대신 1x1 스프라이트로 구성됩니다. 그러면 CSS가 이를 인계받아 캔버스 자체의 크기를 조절합니다. 벤치마킹 결과 이 방법은 더 큰 (배율이 두 배인) 이미지를 렌더링하는 것보다 약 2배 빠를 수 있지만 안타깝게도 CSS 크기 조정에는 앤티앨리어싱이 포함되어 있으며 이를 방지할 방법을 찾을 수 없습니다.

캔버스 크기 조정 옵션
왼쪽: Photoshop에서 픽셀 하나까지 완벽한 확장 소재를 두 배로 늘렸습니다. 오른쪽: CSS 크기 조절로 인해 흐리게 처리된 효과가 추가되었습니다.

개별 픽셀이 매우 중요하기 때문에 이는 게임에 치명적인 문제였습니다. 하지만 캔버스의 크기를 조절해야 하고 프로젝트에 안티앨리어싱이 적합한 경우 성능상의 이유로 이 접근 방식을 고려해 볼 수 있습니다.

재미있는 캔버스 트릭

<canvas>가 새로운 트렌드라는 것은 모두 알고 있지만 개발자가 여전히 DOM을 사용하는 것이 좋을 때도 있습니다. 어느 것을 사용할지 고민하고 있다면 다음은 <canvas>가 Google에 얼마나 많은 시간과 노력을 절약해 주었는지 보여주는 예입니다.

공격! Arena를 탭하면 빨간색으로 깜박이고 '통증' 애니메이션이 잠시 표시됩니다. 생성해야 하는 그래픽의 수를 제한하기 위해 '고통'에 처한 적은 아래쪽을 향하는 방향으로만 표시합니다. 게임 내에서 괜찮아 보이고 스프라이트 제작에 드는 시간을 많이 절약할 수 있었습니다. 하지만 보스 몬스터의 경우 큰 스프라이트 (64x64픽셀 이상)가 왼쪽 또는 위쪽을 향하다가 고통 프레임에서 갑자기 아래를 향하는 모습이 불편했습니다.

명백한 해결 방법은 각 보스의 고통 프레임을 8방향으로 모두 그리는 것이지만, 이렇게 하면 시간이 많이 걸립니다. <canvas> 덕분에 코드에서 이 문제를 해결할 수 있었습니다.

맹공격에서 피해를 입은 비홀더 아레나
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);

게임 루프

게임 개발은 웹 개발과 몇 가지 주목할 만한 차이점이 있습니다. 웹 스택에서는 이벤트 리스너를 통해 발생한 이벤트에 반응하는 것이 일반적입니다. 따라서 초기화 코드는 입력 이벤트를 수신 대기하는 것 외에는 아무것도 하지 않을 수 있습니다. 게임 로직은 끊임없이 자체 업데이트해야 하므로 다릅니다. 예를 들어 플레이어가 움직이지 않았더라도 고블린이 플레이어를 공격하는 것을 막을 수는 없습니다.

다음은 게임 루프의 예입니다.

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

setInterval(main, 1);

첫 번째 중요한 차이점은 handleInput 함수가 실제로 즉시 아무것도 하지 않는다는 것입니다. 사용자가 일반적인 웹 앱에서 키를 누르면 원하는 작업을 즉시 실행하는 것이 좋습니다. 하지만 게임에서는 올바르게 진행되려면 사건이 시간 순으로 발생해야 합니다.

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.xdirection.y 값은 정규화되어야 합니다. 즉, 항상 -11 사이여야 합니다.

컨트롤

Onslaught! Arena를 참고하세요. 첫 번째 데모는 키보드만 지원했습니다. 플레이어는 화살표 키를 사용하여 화면에서 주인공을 움직이고 스페이스바를 사용하여 주인공이 바라보는 방향으로 발사했습니다. 직관적이고 이해하기 쉬웠지만, 난이도가 높은 레벨에서는 게임을 거의 플레이할 수 없었습니다. 언제든지 수십 명의 적과 발사체가 플레이어를 향해 날아오므로 어떤 방향으로든 발사하는 동시에 적 사이를 드나들 수 있어야 합니다.

장르 내 유사한 게임과 비교하기 위해 캐릭터가 공격을 조준하는 데 사용할 타겟팅 레티클을 제어하는 마우스 지원을 추가했습니다. 캐릭터는 여전히 키보드로 이동할 수 있지만, 이 변경사항을 적용한 후에는 360도 모든 방향으로 동시에 발사할 수 있었습니다. 하드코어 플레이어는 이 기능을 좋아했지만 트랙패드 사용자에게는 불편을 끼치는 부작용이 있었습니다.

맹공격! 경기장 컨트롤 모달 (지원 중단됨)
공격 모드에서 이전 컨트롤 또는 '플레이 방법' 모달이 표시됩니다. 경기장

트랙패드 사용자를 수용하기 위해 이번에는 누른 방향으로 발사할 수 있도록 화살표 키 컨트롤을 다시 도입했습니다. 모든 유형의 플레이어를 만족시키고 있다고 생각했지만, 알지 못하는 사이에 게임에 너무 많은 복잡성을 도입하고 있었습니다. 놀랍게도 나중에 일부 플레이어는 대부분 무시되는 튜토리얼 모달에도 불구하고 공격을 위한 선택적 마우스 (또는 키보드!) 컨트롤을 알지 못했다고 말했습니다.

맹공격! 아레나 컨트롤 튜토리얼
플레이어는 대부분 튜토리얼 오버레이를 무시합니다. 게임을 즐기고 싶어하기 때문입니다.

또한 유럽 팬도 일부 있습니다. 하지만 일반적인 QWERTY 키보드를 사용하지 못하고 WASD 키를 사용하여 방향 이동을 할 수 없다는 불만을 제기하는 팬들이 있었습니다. 왼손잡이 플레이어도 비슷한 불만을 제기했습니다.

구현한 이 복잡한 제어 스킴으로 인해 휴대기기에서 재생하는 문제도 있습니다. 실제로 가장 많이 요청되는 기능 중 하나는 공격! Android, iPad 및 기타 터치 기기 (키보드가 없는 기기)에서 사용할 수 있는 Arena 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는 HTML5의 <audio> 태그였습니다. 가장 나쁜 점은 지연 시간일 수 있습니다. 거의 모든 브라우저에서 .play()를 호출하고 실제로 소리가 재생되는 사이에 지연이 발생합니다. 특히 템포가 빠른 게임을 플레이할 때는 게이머의 환경을 망칠 수 있습니다.

다른 문제로는 '진행률' 이벤트가 실행되지 않는 문제가 있습니다. 이로 인해 게임의 로드 흐름이 무한히 중단될 수 있습니다. 이러한 이유로 YouTube에서는 '전환' 메서드를 채택했습니다. 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 로고가 숨겨진 경기장
Onslaught를 플레이할 때 'html5'를 입력하면 HTML5 방패를 얻을 수 있습니다. 경기장