우수사례 - Onslaught! 아레나

소개

2010년 6월, 현지에서 출판된 'zine'인 Boing Boing에서 게임 개발 경연대회를 진행 중이라는 사실을 알게 되었습니다. 우리는 이를 JavaScript 및 <canvas>로 빠르고 간단한 게임을 만들 수 있는 완벽한 구실로 보고 작동하도록 설정했습니다. 대회 후에도 많은 아이디어가 떠올랐고 우리가 시작한 일을 마무리하고 싶었습니다. 이 게임의 우수사례입니다. Onslaught! 아레나.

픽셀화된 레트로 스타일

칩춘을 기반으로 게임을 개발한다는 콘테스트 전제를 고려할 때 게임의 디자인과 분위기를 복고풍의 Nintendo Entertainment System 게임처럼 만드는 것이 중요했습니다. 대부분의 게임에는 이러한 요구사항이 없지만, 애셋을 쉽게 만들 수 있고 향수를 불러일으키는 게이머에게 자연스러운 매력으로 인해 여전히 일반적인 예술 스타일입니다 (특히 인디 개발자에게서).

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

이 스프라이트가 얼마나 작은지 감안할 때 픽셀을 두 배로 늘리기로 결정했습니다. 즉, 16x16 스프라이트는 이제 32x32픽셀이 됩니다. 처음부터 우리는 브라우저의 번거로운 작업을 하는 대신 애셋 생성 측면을 두 배로 늘려 왔습니다. 이 방식은 구현이 쉬웠지만 디자인에도 몇 가지 확실한 장점이 있었습니다.

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

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

이 메서드는 애셋 생성 측면에서 스프라이트를 두 배로 늘리지 않고 1x1 스프라이트로 구성됩니다. 여기에서 CSS가 캔버스 자체의 크기를 조정합니다. 벤치마크에서는 이 방법이 더 큰 (두 배로 된) 이미지를 렌더링하는 것보다 약 두 배 빠른 것으로 나타났지만, CSS 크기 조절에는 앤티앨리어싱이 포함되는데, 이는 Google에서 방지할 방법을 찾지 못했습니다.

캔버스 크기 조절 옵션
왼쪽: Photoshop에서 픽셀 단위의 완벽한 애셋이 2배로 늘어났습니다. 오른쪽: CSS 크기 조절로 흐릿한 효과가 추가되었습니다.

개별 픽셀의 중요성이 매우 크므로 게임에는 큰 타격을 입었지만 캔버스의 크기를 조절해야 하고 앤티앨리어싱이 프로젝트에 적합하다면 성능 측면에서 이 접근 방식을 고려할 수 있습니다.

재미있는 캔버스 기술

<canvas>가 새로운 핫스팟이라는 것은 잘 알지만, 때로는 개발자가 DOM 사용을 권장하는 경우도 있습니다. 무엇을 사용해야 할지 잘 모르겠다면 <canvas>를 사용하여 많은 시간과 에너지를 절약한 방법을 보여주는 다음 예를 참고하세요.

적에게 휩쓸리며! 아레나의 상태이면 빨간색으로 깜박이고 잠깐 '고통스러운' 애니메이션이 표시됩니다. 만들어야 하는 그래픽 수를 제한하기 위해 '고통'에 있는 적을 아래쪽을 향한 방향으로만 표시합니다. 이는 게임 내에서 허용 가능한 것처럼 보이고 스프라이트 생성 시간을 상당히 절약했습니다. 하지만 보스 몬스터의 경우 통증 프레임에 맞춰 왼쪽 또는 위로 향하는 커다란 스프라이트 (64x64픽셀 이상)가 갑자기 아래를 향하게 되는 것이 불편했습니다.

확실한 해결책은 8개 방향에서 각 보스의 문제점을 그리는 것이지만 시간이 매우 오래 걸렸을 것입니다. <canvas> 덕분에 코드에서 이 문제를 해결할 수 있었습니다.

Onslaught에서 데미지를 가하는 걸 보세요! 아레나
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-land에서는 브라우저가 이러한 어려운 리프팅을 처리합니다. 그러나 <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! 아레나. 첫 번째 데모는 키보드만 지원했습니다. 플레이어는 화살표 키를 사용하여 화면에서 주 캐릭터를 이동하고 스페이스바를 보고 바라보는 방향으로 실행했습니다. 다소 직관적이고 이해하기 쉬웠지만 더 어려운 레벨에서는 게임을 거의 플레이하기 힘들었습니다. 언제든지 수십 개의 적과 발사체가 플레이어를 향해 날아가는 상황에서, 발사 방향과 관계없이 발사하는 동안 악의적인 공격자 사이에 끼어들 수 있어야 합니다.

장르의 유사한 게임과 비교하기 위해 캐릭터가 공격을 조준하는 데 사용하는 타겟팅 레티클을 제어하는 마우스 지원을 추가했습니다. 캐릭터는 여전히 키보드로 움직일 수 있지만 이 변경 후에는 동시에 완전한 360도 방향으로 발사할 수 있습니다. 하드코어 플레이어는 이 기능에 만족했지만 트랙패드 사용자를 실망시키는 아쉽게도 부작용을 일으켰습니다.

맹공격! 아레나 컨트롤 모달 (지원 중단됨)
Onslaught의 이전 컨트롤 또는 '플레이 방법' 모달 아레나

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

맹공격! 아레나 컨트롤 튜토리얼
플레이어는 대부분 튜토리얼 오버레이를 무시하며, 그저 재미로 플레이하기를 원합니다.

유럽 팬도 있어 다행이지만 일반적인 QWERTY 키보드가 없고 방향성 이동에 WASD 키를 사용할 수 없다는 불만이 제기되었습니다. 왼손잡이 선수들도 비슷한 불만을 제기했습니다.

이와 같이 복잡한 제어 방식을 구현했기 때문에 휴대기기에서 재생해야 하는 문제도 있습니다. 실제로 가장 일반적인 요청 중 하나는 Onslaught! 아레나 - Android, iPad 및 기타 터치 기기 (키보드가 없는 기기)에서 사용 가능). HTML5의 핵심 강점 중 하나는 이식성입니다. 따라서 이러한 기기에서 게임을 실행할 수 있으며, 특히 컨트롤, 성능 등 많은 문제만 해결해야 합니다.

이러한 많은 문제를 해결하기 위해 Google은 마우스 (또는 터치) 상호작용만 포함된 게임플레이의 단일 입력 방법을 사용하기 시작했습니다. 플레이어가 화면을 클릭하거나 터치하면 주인공이 눌린 위치로 걸어가며 가장 가까운 악당을 자동으로 공격합니다. 코드는 다음과 같습니다.

// 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! 아레나는 HTML5의 <audio> 태그였습니다. 아마 최악의 측면은 지연 시간입니다. 거의 모든 브라우저에서 .play() 호출과 실제로 재생되는 사운드 사이에 지연이 발생합니다. 특히 우리처럼 빠르게 진행되는 게임에서 플레이할 때 게이머의 경험을 망칠 수 있습니다.

다른 문제로는 게임의 로드 흐름이 무기한 중단되는 'progress' 이벤트 실행 실패가 있습니다. 이러한 이유로 Google에서는 플래시가 로드되지 않으면 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
최고 점수가 저장되며 보스를 이기면 내 위치가 저장됩니다.

Google에서는 새롭고 멋지고 사용하기 쉬운 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 방패를 받을 수 있습니다. 아레나