우수사례 - Build Racer

Active Theory
Active Theory

소개

RacerActive Theory에서 개발한 웹 기반 모바일 Chrome 실험입니다. 최대 5명의 친구가 휴대전화나 태블릿을 연결하여 모든 화면에서 경주할 수 있습니다. Google Creative Lab의 개념, 디자인, 프로토타입과 Plan8의 사운드를 바탕으로 I/O 2013 출시에 앞서 8주 동안 빌드를 반복했습니다. 게임이 출시된 지 몇 주가 지났으므로 작동 방식에 관한 개발자 커뮤니티의 질문을 받을 수 있었습니다. 다음은 주요 기능과 가장 자주 묻는 질문에 대한 답변입니다.

트랙

가장 큰 문제는 다양한 기기에서 잘 작동하는 웹 기반 모바일 게임을 만드는 것이었습니다. 플레이어는 다양한 휴대전화와 태블릿으로 레이스를 구성할 수 있어야 했습니다. 한 플레이어는 Nexus 4를 가지고 있고 iPad를 사용하는 친구와 경주하고 싶어 할 수 있습니다. 각 경주의 공통적인 트랙 크기를 결정하는 방법을 찾아야 했습니다. 솔루션은 경주에 포함된 각 기기의 사양에 따라 크기가 다른 트랙을 사용해야 했습니다.

트랙 크기 계산

각 플레이어가 참여하면 기기 정보가 서버로 전송되고 다른 플레이어와 공유됩니다. 트랙을 빌드할 때 이 데이터는 트랙의 높이와 너비를 계산하는 데 사용됩니다. 높이는 가장 작은 화면의 높이를 찾아 계산하고 너비는 모든 화면의 총 너비입니다. 따라서 아래 예시에서 트랙의 너비는 1152픽셀이고 높이는 519픽셀입니다.

빨간색 영역은 이 예시에서 트랙의 전체 너비와 높이를 보여줍니다.
빨간색 영역은 이 예시에서 트랙의 총 너비와 높이를 보여줍니다.
this.getDimensions = function () {
  var response = {};
  response.width = 0;
  response.height = _gamePlayers[0].scrn.h; // First screen height
  response.screens = [];
  
  for (var i = 0; i < _gamePlayers.length; i++) {
    var player = _gamePlayers[i];
    response.width += player.scrn.w;

    if (player.scrn.h < response.height) {
      // Find the smallest screen height
      response.height = player.scrn.h;
    }
      
    response.screens.push(player.scrn);
  }
  
  return response;
}

트랙 그리기

Paper.js는 HTML5 캔버스에서 실행되는 오픈소스 벡터 그래픽 스크립팅 프레임워크입니다. Paper.js가 트랙의 벡터 도형을 만드는 데 적합한 도구라는 사실을 발견하여 이 기능을 사용하여 Adobe Illustrator에서 빌드된 SVG 트랙을 <canvas> 요소에 렌더링했습니다. 트랙을 만들기 위해 TrackModel 클래스는 SVG 코드를 DOM에 추가하고 원래 크기 및 위치에 관한 정보를 수집하여 트랙을 캔버스에 그릴 TrackPathView에 전달합니다.

paper.install(window);
_paper = new paper.PaperScope();
_paper.setup('track_canvas');
                    
var svg = document.getElementById('track');
var layer = new _paper.Layer();

_path = layer.importSvg(svg).firstChild.firstChild;
_path.strokeColor = '#14a8df';
_path.strokeWidth = 2;

트랙이 그려지면 각 기기는 기기 선별 순서에서의 위치를 기반으로 x 오프셋을 찾고 그에 따라 트랙을 배치합니다.

var x = 0;

for (var i = 0; i < screens.length; i++) {
  if (i < PLAYER_INDEX) {
    x += screens[i].w;
  }
}
그런 다음 x 오프셋을 사용하여 트랙의 적절한 부분을 표시할 수 있습니다.
그런 다음 x 오프셋을 사용하여 트랙의 적절한 부분을 표시할 수 있습니다.

CSS 애니메이션

Paper.js는 트랙 레인을 그리기 위해 많은 CPU 처리를 사용하며 이 프로세스는 기기에 따라 다소 시간이 걸립니다. 이를 처리하려면 모든 기기가 트랙 처리를 완료할 때까지 로더가 반복되어야 했습니다. 문제는 Paper.js의 CPU 요구사항으로 인해 JavaScript 기반 애니메이션이 프레임을 건너뛰는 것이었습니다. 별도의 UI 스레드에서 실행되므로 'BUILDING TRACK' 텍스트 전체에 걸쳐 광택을 부드럽게 애니메이션할 수 있는 CSS 애니메이션을 도입합니다.

.glow {
  width: 290px;
  height: 290px;
  background: url('img/track-glow.png') 0 0 no-repeat;
  background-size: 100%;
  top: 0;
  left: -290px;
  z-index: 1;
  -webkit-animation: wipe 1.3s linear 0s infinite;
}

@-webkit-keyframes wipe {
  0% {
    -webkit-transform: translate(-300px, 0);
  }

  25% {
    -webkit-transform: translate(-300px, 0);
  }

  75% {
    -webkit-transform: translate(920px, 0);
  }

  100% {
    -webkit-transform: translate(920px, 0);
  }
}
}

CSS 스프라이트

CSS는 인게임 효과에도 유용했습니다. 제한된 전력을 사용하는 휴대기기는 트랙을 달리는 자동차의 애니메이션을 계속 처리하느라 바쁩니다. 따라서 더욱 흥미를 더하기 위해 사전 렌더링된 애니메이션을 게임에 구현하는 방법으로 스프라이트를 사용했습니다. CSS 스프라이트에서 전환은 background-position 속성을 변경하여 자동차 폭발을 만드는 단계 기반 애니메이션을 적용합니다.

#sprite {
  height: 100px; 
  width: 100px;
  background: url('sprite.jpg') 0 0 no-repeat;
  -webkit-animation: play-sprite 0.33s linear 0s steps(9) infinite;
}

@-webkit-keyframes play-sprite {
  0% {
    background-position: 0 0;
  }

  100% {
    background-position: -900px 0;
  }
}

이 기법의 문제는 단일 행에 배치된 스프라이트 시트만 사용할 수 있다는 점입니다. 여러 행을 반복하려면 애니메이션을 여러 키프레임 선언을 통해 체이닝해야 합니다.

#sprite {
  height: 100px; 
  width: 100px;
  background: url('sprite.jpg') 0 0 no-repeat;
  -webkit-animation-name: row1, row2, row3;
  -webkit-animation-duration: 0.2s;
  -webkit-animation-delay: 0s, 0.2s, 0.4s;
  -webkit-animation-timing-function: steps(5), steps(5), steps(5);
  -webkit-animation-fill-mode: forwards;
}

@-webkit-keyframes row1 {
  0% {
    background-position: 0 0;
  }

  100% {
    background-position: -500px 0;
  }
}

@-webkit-keyframes row2 {
  0% {
    background-position: 0 -100px;
  }

  100% {
    background-position: -500px -100px;
  }
}

@-webkit-keyframes row3 {
  0% {
    background-position: 0 -200px;
  }

  100% {
    background-position: -500px -200px;
  }
}

자동차 렌더링

다른 자동차 레이싱 게임과 마찬가지로 사용자에게 가속과 핸들링 느낌을 주는 것이 중요하다는 것을 알고 있었습니다. 게임 밸런스와 재미 요소를 위해 다양한 양의 트랙션을 적용하는 것이 중요했습니다. 이를 통해 플레이어가 물리학을 익히면 성취감을 느끼고 더 나은 레이서가 될 수 있었습니다.

광범위한 수학 유틸리티가 포함된 Paper.js를 다시 한번 호출했습니다. 일부 메서드를 사용하여 경로를 따라 자동차를 이동하고 각 프레임마다 자동차 위치와 회전을 부드럽게 조정했습니다.

var trackOffset = _path.length - (_elapsed % _path.length);
var trackPoint = _path.getPointAt(trackOffset);
var trackAngle = _path.getTangentAt(trackOffset).angle;

// Apply the throttle
_velocity.length += _throttle;

if (!_throttle) {
  // Slow down since the throttle is off
  _velocity.length *= FRICTION;
}

if (_velocity.length > MAXVELOCITY) {
  _velocity.length = MAXVELOCITY;
}

_velocity.angle = trackAngle;
trackOffset -= _velocity.length;
_elapsed += _velocity.length;

// Find if a lap has been completed
if (trackOffset < 0) {
  while (trackOffset < 0) trackOffset += _path.length;

  trackPoint = _path.getPointAt(trackOffset);
  console.log('LAP COMPLETE!');
}

if (_velocity.length > 0.1) {
  // Render the car if there is actually velocity
  renderCar(trackPoint);
}

자동차 렌더링을 최적화하는 과정에서 흥미로운 점을 발견했습니다. iOS에서는 자동차에 translate3d 변환을 적용하여 최상의 성능을 얻었습니다.

_car.style.webkitTransform = 'translate3d('+_position.x+'px, '+_position.y+'px, 0px)rotate('+_rotation+'deg)';

Android용 Chrome에서는 행렬 값을 계산하고 행렬 변환을 적용하여 최상의 성능을 얻었습니다.

var rad = _rotation.rotation * (Math.PI * 2 / 360);
var cos = Math.cos(rad);
var sin = Math.sin(rad);
var a = parseFloat(cos).toFixed(8);
var b = parseFloat(sin).toFixed(8);
var c = parseFloat(-sin).toFixed(8);
var d = a;
_car.style.webkitTransform = 'matrix(' + a + ', ' + b + ', ' + c + ', ' + d + ', ' + _position.x + ', ' + _position.y + ')';

기기 동기화 유지

개발에서 가장 중요하고 어려운 부분은 게임을 여러 기기에서 동기화하는 것이었습니다. 연결이 느려서 자동차가 가끔 몇 프레임을 건너뛰는 경우 사용자는 관대한 태도를 보일 수 있다고 생각했지만, 자동차가 여기저기 튀어다니며 한 번에 여러 화면에 표시되면 재미가 없을 것입니다. 이 문제를 해결하기 위해 수많은 시행착오가 있었지만 결국 작동하는 몇 가지 트릭을 찾았습니다.

지연 시간 계산

기기 동기화의 시작점은 Compute Engine 릴레이에서 메시지를 수신하는 데 걸리는 시간을 파악하는 것입니다. 문제는 각 기기의 시계가 완전히 동기화되지 않는다는 점입니다. 이 문제를 해결하려면 기기와 서버 간의 시간 차이를 찾아야 했습니다.

기기와 기본 서버 간의 시간 오프셋을 찾기 위해 현재 기기 타임스탬프가 포함된 메시지를 전송합니다. 그러면 서버는 서버의 타임스탬프와 함께 원래 타임스탬프로 응답합니다. Google에서는 응답을 사용하여 실제 시간 차이를 계산합니다.

var currentTime = Date.now();
var latency = Math.round((currentTime - e.time) * .5);
var serverTime = e.serverTime;
currentTime -= latency;
var difference = currentTime - serverTime;

서버 왕복이 항상 대칭적이지 않으므로 한 번만 실행하는 것으로는 충분하지 않습니다. 즉, 응답이 서버에 도달하는 데 서버가 응답을 반환하는 데 걸리는 시간보다 오래 걸릴 수 있습니다. 이 문제를 해결하기 위해 서버를 여러 번 폴링하여 중간값 결과를 가져옵니다. 이렇게 하면 기기와 서버 간의 실제 차이를 10ms 이내로 가져올 수 있습니다.

가속/감속

플레이어 1이 화면을 누르거나 떼면 가속도 이벤트가 서버로 전송됩니다. 서버는 수신한 후 현재 타임스탬프를 추가한 다음 이 데이터를 다른 모든 플레이어에게 전달합니다.

기기에서 '가속 시작' 또는 '가속 중지' 이벤트를 수신하면 위에서 계산된 서버 오프셋을 사용하여 해당 메시지가 수신되는 데 걸린 시간을 확인할 수 있습니다. 이는 플레이어 1은 메시지를 20ms 만에 수신할 수 있지만 플레이어 2는 50ms가 걸릴 수 있으므로 유용합니다. 이렇게 하면 기기 1이 더 빨리 가속을 시작하기 때문에 자동차가 두 개의 서로 다른 위치에 있게 됩니다.

이벤트를 수신하는 데 걸린 시간을 프레임으로 변환할 수 있습니다. 60fps에서는 각 프레임이 16.67ms이므로 자동차에 속도 (가속) 또는 마찰 (감속)을 더하여 누락된 프레임을 고려할 수 있습니다.

var frames = time / 16.67;
var onScreen = this.isOnScreen() && time < 75;

for (var i = 0; i < frames; i++) {
  if (onScreen) {
    _velocity.length += _throttle * Math.round(frames * .215);
  } else {
    _this.render();
  }
}}

위 예에서 플레이어 1의 화면에 자동차가 있고 메시지를 수신하는 데 걸린 시간이 75ms 미만이면 자동차의 속도를 조정하여 속도를 높여 차액을 보충합니다. 기기가 화면에 없거나 메시지가 너무 오래 걸린 경우 렌더링 함수를 실행하고 실제로 자동차가 있어야 할 위치로 이동합니다.

자동차 동기화 유지하기

가속의 지연 시간을 고려한 후에도 자동차가 동기화되지 않고 한 기기에서 다른 기기로 전환할 때 특히 여러 화면에 한 번에 표시될 수 있습니다. 이를 방지하기 위해 업데이트 이벤트가 자주 전송되어 모든 화면에서 자동차가 트랙의 동일한 위치를 유지합니다.

로직은 자동차가 화면에 표시되는 4프레임마다 해당 기기가 다른 각 기기에 값을 전송한다는 것입니다. 자동차가 보이지 않으면 앱은 수신된 값으로 값을 업데이트한 다음 업데이트 이벤트를 가져오는 데 걸린 시간을 기준으로 자동차를 앞으로 이동합니다.

this.getValues = function () {
  _values.p = _position.clone();
  _values.r = _rotation;
  _values.e = _elapsed;
  _values.v = _velocity.length;
  _values.pos = _this.position;

  return _values;
}

this.setValues = function (val, time) {
  _position.x = val.p.x;
  _position.y = val.p.y;
  _rotation = val.r;
  _elapsed = val.e;
  _velocity.length = val.v;

  var frames = time / 16.67;

  for (var i = 0; i < frames; i++) {
    _this.render();
  }
}

결론

Racer의 개념을 듣자마자 매우 특별한 프로젝트가 될 수 있음을 알았습니다. 지연 시간과 네트워크 성능을 극복하는 방법을 대략적으로 파악할 수 있는 프로토타입을 신속하게 빌드했습니다. 늦은 밤과 긴 주말에 바쁘게 지내야 하는 어려운 프로젝트였지만 게임이 점차 윤곽을 드러내기 시작할 때는 기분이 좋았습니다. 최종 결과에 매우 만족합니다. Google Creative Lab의 개념은 재미있는 방식으로 브라우저 기술의 한계를 뛰어넘었습니다. 개발자로서 더 바랄 수 없습니다.