우수사례 - 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> 요소에 렌더링하는 데 Paper.js가 사용되었습니다. 트랙을 만들기 위해 TrackModel 클래스는 DOM에 SVG 코드를 추가하고 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 요구사양으로 인해 프레임을 건너뛰는 것이었습니다. 별도의 UI 스레드에서 실행되는 CSS 애니메이션을 입력하면 'BUILDING TRACK' 텍스트에서 광택을 부드럽게 애니메이션으로 표시할 수 있습니다.

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

Google은 자동차 렌더링을 최적화하는 과정에서 흥미로운 점을 발견했습니다. 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 + ')';

기기 동기화 유지

개발 과정에서 가장 중요하고 어려운 부분은 게임을 기기 간에 동기화하는 것이었습니다. Google에서는 연결 속도가 느리기 때문에 자동차가 가끔 몇 개의 프레임을 건너뛰는 것을 싫어할 수도 있다고 생각했지만, 자동차가 이리저리 돌아다니면서 한 번에 여러 화면에 표시되는 것은 재미없을 것입니다. 이 문제를 해결하기 위해서는 수많은 시행착오가 필요했지만, 결국 몇 가지 요령으로 해결하게 되었습니다.

지연 시간 계산

기기 동기화의 시작점은 Compute Engine 릴레이에서 메시지를 수신하는 데 걸리는 시간을 확인하는 것입니다. 까다로운 부분은 각 기기의 시계가 완전히 동기화되지 않는다는 것입니다. 이 문제를 해결하기 위해서는 기기와 서버 간의 시간 차이를 알아내야 했습니다.

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

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은 20밀리초 이내에 메시지를 수신할 수 있지만 플레이어 2는 메시지를 수신하는 데 50밀리초가 걸릴 수 있으므로 이 기능이 유용합니다. 그러면 기기 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의 개념은 재미있게 브라우저 기술의 한계를 넓혔으며, 개발자 입장에서는 더 이상 요구할 수 없는 점이었습니다.