Case Study: Building Racer

HTML5 Rocks

소개

RacerActive Theory에서 개발된 웹기반 모바일 크롬 실험입니다. 5명까지 그들의 폰과 태블릿에서 접속하여 각 스크린에서 레이스를 즐길 수 있습니다. 구글 크리에이티브 랩의 컨셉트, 디자인, 프로토타입과 Plan8의 사운드로 구글 I/O '13 까지 8주간의 반복적인 빌드로 구축하였습니다. 게임이 어떻게 동작하는지에 대해 개발자 커뮤니티로부터 필드에서의 몇몇 질문을 받을 기회를 몇주간 가졌습니다. 아래는 우리가 가장 많이 질문 받았던 질문들에 대해 주요 기능 및 답변으로 나누어져 있습니다.

트랙

우리가 직면한 매우 분명한 문제는 다양한 장치를 통해 잘 작동하는 웹기반 모바일 게임을 만드는 방법이었습니다. 플레이어는 다른 폰과 태블릿과 함께 레이스를 구축할 수 있어야 했습니다. 한 명의 플레이어는 넥서스 4를 가졌으며 iPad를 가진 그의 친구와 함께 레이스를 하고 싶어 했습니다. 우리는 각 레이스에 대해 공통적인 트랙 크기를 결정할 방법이 필요했습니다. 해결책은 레이스에 포함된 각 단말의 사양에 따라 다양한 크기의 트랙을 사용하는 것이었습니다.

트랙의 크기 계산

각 플레이어의 참여에 따라 그들 단말에 대한 정보가 서버로 보내지고 다른 플레이어들과 공유 되었습니다. 트랙이 생성될 때 이 데이터는 트랙의 높이와 폭을 계산하는데 사용되었습니다. 우리는 가장 작은 화면의 높이를 찾아 높이를 계산하였고 폭은 모든 화면의 폭 합계를 사용하였습니다. 그래서 아래 예제의 트랙은 1152 픽셀의 폭과 519 픽셀의 높이를 가지게 되었습니다.

빨간색 영역은 이 예제에서 전체 폭과 높이를 보여줍니다.

this.getDimensions = function() {
    var response = {};
    response.width = 0;
    response.height = _gamePlayers[0].scrn.h; // 첫번째 화면 높이
    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) {
            // 가장 작은 화면의 높이 찾기
            response.height = player.scrn.h;
        }
        
        response.screens.push(player.scrn);
    }
    
    return response;
}

트랙 그리기

Paper.js는 HTML5 Canvas의 최상위에서 실행되는 오픈 소스 벡터 그래픽 스크립트 프레임워크입니다. 우리는 트랙을 벡터 모양으로 생성할 수 있는 완벽한 도구인 Paper.js를 찾았습니다. 그래서 우리는 Canvas 엘리먼트 상에 Adobe Illustrator에서 작업된 SVG 트랙을 렌더링 하는데 Paper.js의 능력을 사용하였습니다. 트랙을 생성하기 위해 트랙 모델 클래스는 DOM에 SVG 코드를 추가하고 Canvas에 그려질 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 오프셋은 다음 트랙의 적절한 부분을 보여주기 위해 사용될 수 있습니다.

CSS 애니메이션

Paper.js는 트랙 레인을 그리는데 많은 CPU 프로세싱을 사용하며 이것은 다른 단말에서 더 많은 프로세스가 필요하거나 시간을 부족하게 합니다. 이것을 처리하기 위해 우리는 모든 단말들의 트랙 처리가 끝날때가지 루프를 돌기 위한 로더가 필요했습니다. 문제는 어떤 자바스크립트 기반 애니메이션 이라도 Paper.js의 CPU 요구사항 때문에 프레임이 스킵된다는 것입니다. CSS 애니메이션은 UI 쓰레드와 분리되어 동작하며 "BUILDING TRACK" 텍스트를 가로질러 반짝이는 부드러운 애니메이션이 동작할 수 있도록 합니다.

.glow {
    width: 290px;
    height: 290px;
    background: url(img/track-glow.png) 0 0 no-repeat;
    background-size: 100%;
    top: 0px;
    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: 0px 0px; }
    100%   { background-position: -900px 0px; }
}

이 기술에 대한 문제는 여러분이 단 하나의 row에 배치된 스프라이트 시트만을 사용할 수 있다는 것 입니다. 루프에 따라 여러개의 row에서 애니메이션은 여러개의 키프레임 선언으로 연결되어야만 합니다.

#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: 0px 0px; }
    100%   { background-position: -500px 0px; }
}

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

@-webkit-keyframes row3 {
    0%     { background-position: 0px -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;

_velocity.length += _throttle; //스로틀 적용

if (!_throttle) {
    //스로틀이 꺼진 후부터 천천히 내려가도록 함.
    _velocity.length *= FRICTION;
}

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

_velocity.angle = trackAngle;

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

//lap이 완료되었는지 찾는다.
if (trackOffset < 0) {
    while (trackOffset < 0) trackOffset += _path.length;
    trackPoint = _path.getPointAt(trackOffset);
    console.log('LAP COMPLETE!');
}

if (_velocity.length > 0.1) {
    //실제 속도가 있다면 차를 렌더링한다.
    renderCar(trackPoint);
}

우리가 차 렌더링을 최적하는 하는 동안 우리는 흥미로운 점을 찾았습니다. iOS 에서는 차에 translate3d 변환을 적용하는 것으로 최적의 성능을 이룰수 있었습니다:

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

안드로이드 상의 크롬에서는 행렬변환을 적용하고 행렬값을 계산하는 것으로 최적의 성능을 이룰수 있었습니다:

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 릴레이로부터 메시지가 수신되는데 걸리는 시간을 아는 것입니다. 까다로운 부분은 각 단말상의 시계가 완벽하게 일치하지 않을 것이라는 점입니다. 이러한 문제를 해결하기 위해 우리는 단말과 서버 사이의 시간의 차이를 알수 있는 방법이 필요했습니다.

단말과 주서버사이의 시간 오프셋을 찾기위해 우리는 현재 단말의 타임스탬프와 함께 메시지를 전송했습니다. 서버는 서버의 타임스탬프와 수신된 단말의 타임스탬프를 함께 응답하였습니다. 우리는 실제 시간 차이를 계산하는데 응답을 사용하였습니다.

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

이것은 한번 수행으로 충분하지 않습니다. 서버로의 왕복은 항상 대칭적이지 않습니다. 그것은 응답시간에서 서버가 요청을 받는 시간보다 요청에 대한 응답을 돌려주는 시간이 더 길게 걸릴수 있음을 의미합니다. 이러한 문제를 해결하기 위해 우리는 서버로 여러번의 폴링을 수행하여 평균 결과값을 확인하였습니다. 이것은 10ms 이내 오차의 단말과 서버의 실제 차이값을 알수 있게 하였습니다.

가속/감속

플레이어1이 누르거나 뗄 때 가속 이벤트는 서버로 보내집니다. 한번 수신되면 서버는 현재 타임스탬프를 추가하고 모든 다른 플레이어에게 전달합니다.

“accelerate on” 또는 “accelerate off” 이벤트를 단말로부터 전달받았을 때 우리는 메시지를 수신받는데 얼마나 걸렸는지 알기 위해 (위에서 계산된) 서버 오프셋을 사용할 수 있습니다. 플레이어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의 화면상에 플레이어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에 대한 컨셉을 듣자마자 우리는 매우 특별한 프로젝트가 될 수 있을 것이라는 잠재력을 알 수 있었습니다. 우리는 레이턴시 및 네트워크 성능을 극복하기 위한 대략의 아이디어로 프로토타입을 빠르게 빌드하였습니다. 늦은 밤과 주말 동안을 바쁘게 작업해 왔던 도전적인 프로젝트였습니다. 궁극적으로 우리는 최종 결과에 매우 만족합니다. 구글 크리에이티브 랩의 컨셉트는 재미있는 방식으로 브라우저 기술의 한계까지 진행하였고 개발자로서 우리는 더 바랄 것이 없습니다.

다운로드

코드 예제 다운로드

Comments

0