Fieldrunners
Fieldrunners는 2008년에 iPhone용으로 처음 출시된 수상 경력에 빛나는 타워 디펜스 스타일 게임입니다. 이후 다른 많은 플랫폼으로 이식되었습니다. 가장 최근에 추가된 플랫폼 중 하나는 2011년 10월에 추가된 Chrome 브라우저입니다. Fieldrunners를 HTML5 플랫폼으로 이식하는 데 있어 한 가지 과제는 사운드를 재생하는 방법이었습니다.
Fieldrunners는 음향 효과를 복잡하게 사용하지는 않지만 음향 효과와 상호작용하는 방식에 대한 기대치가 있습니다. 게임에는 88개의 음향 효과가 있으며, 이 중 상당수가 한 번에 재생될 것으로 예상됩니다. 이러한 사운드는 대부분 매우 짧으며 그래픽 프레젠테이션과 단절되지 않도록 최대한 적시에 재생되어야 합니다.
일부 챌린지가 표시됨
Fieldrunners를 HTML5로 이식하는 동안 오디오 태그를 사용한 오디오 재생에 문제가 발생하여 초기에 Web Audio API에 집중하기로 결정했습니다. WebAudio를 사용하면 Fieldrunners에 필요한 많은 수의 동시 효과를 재생하는 것과 같은 문제를 해결할 수 있었습니다. 하지만 Fieldrunners HTML5용 오디오 시스템을 개발하는 동안 다른 개발자가 알아야 할 몇 가지 미묘한 문제가 발생했습니다.
AudioBufferSourceNodes의 특성
AudioBufferSourceNode는 WebAudio로 사운드를 재생하는 기본 방법입니다. 이 객체는 한 번만 사용된다는 점을 이해하는 것이 매우 중요합니다. AudioBufferSourceNode를 만들고 버퍼를 할당하고 그래프에 연결한 후 noteOn 또는 noteGrainOn으로 재생합니다. 그 후 noteOff를 호출하여 재생을 중지할 수 있지만 noteOn 또는 noteGrainOn을 호출하여 소스를 다시 재생할 수는 없습니다. 다른 AudioBufferSourceNode를 만들어야 합니다. 하지만 동일한 기본 AudioBuffer 객체를 재사용할 수 있습니다. 이는 핵심적인 부분입니다. 실제로 동일한 AudioBuffer 인스턴스를 가리키는 여러 개의 활성 AudioBufferSourceNode를 가질 수도 있습니다. Give Me a Beat에서 Fieldrunners의 재생 스니펫을 확인할 수 있습니다.
캐싱되지 않는 콘텐츠
출시 당시 Fieldrunners HTML5 서버는 음악 파일에 대한 엄청난 수의 요청을 보여주었습니다. 이 결과는 Chrome 15에서 파일을 청크로 다운로드한 후 캐시하지 않기 때문에 발생했습니다. 이에 따라 당시에는 다른 오디오 파일과 마찬가지로 음악 파일을 로드하기로 결정했습니다. 이렇게 하는 것은 최적의 방법이 아니지만 다른 브라우저의 일부 버전에서는 여전히 이 작업을 수행합니다.
포커스를 벗어날 때 음소거
이전에는 게임 탭이 포커스를 벗어난 시점을 감지하기가 어려웠습니다. Fieldrunners는 Chrome 13 이전에 포팅을 시작했으며, Page Visibility API를 통해 탭 블러링을 감지하기 위한 복잡한 코드를 대체했습니다. 모든 게임은 Visibility API를 사용하여 전체 게임을 일시중지하지 않는 경우 사운드를 음소거하거나 일시중지하는 작은 스니펫을 작성해야 합니다. Fieldrunners는 requestAnimationFrame API를 사용했기 때문에 게임 일시중지는 암시적으로 처리되었지만 사운드 일시중지는 처리되지 않았습니다.
소리 일시중지
이상하게도 이 도움말에 대한 의견을 받는 동안 사운드를 일시중지하는 데 사용한 기술이 적절하지 않다는 사실을 알게 되었습니다. Web Audio의 현재 구현에서 버그를 활용하여 사운드 재생을 일시중지했습니다. 이 문제는 향후 수정될 예정이므로 노드 또는 하위 그래프의 연결을 끊어 재생을 중지하는 방법으로는 사운드를 일시중지할 수 없습니다.
간단한 Web Audio 노드 아키텍처
Fieldrunners에는 매우 간단한 오디오 모델이 있습니다. 이 모델은 다음 특성 세트를 지원할 수 있습니다.
- 음향 효과의 볼륨을 제어합니다.
- 배경 음악 트랙의 볼륨을 제어합니다.
- 모든 오디오를 음소거합니다.
- 게임이 일시중지되면 소리 재생을 사용 중지합니다.
- 게임이 재개되면 동일한 사운드를 다시 사용 설정합니다.
- 게임 탭이 포커스를 잃으면 모든 오디오를 사용 중지합니다.
- 필요에 따라 소리가 재생된 후 재생을 다시 시작합니다.
Web Audio로 위의 기능을 구현하기 위해 제공된 노드 중 DestinationNode, GainNode, AudioBufferSourceNode의 3가지를 사용했습니다. AudioBufferSourceNodes는 사운드를 재생합니다. GainNode는 AudioBufferSourceNode를 함께 연결합니다. Web Audio 컨텍스트에서 생성된 DestinationNode(destination이라고 함)는 플레이어의 사운드를 재생합니다. Web Audio에는 더 많은 유형의 노드가 있지만, 이 노드만으로도 게임에서 사운드를 위한 매우 간단한 그래프를 만들 수 있습니다.
Web Audio 노드 그래프는 리프 노드에서 대상 노드로 이어집니다. Fieldrunners는 6개의 영구 게인 노드를 사용했지만, 3개만으로도 볼륨을 쉽게 제어하고 버퍼를 재생할 더 많은 수의 임시 노드를 연결할 수 있습니다. 먼저 모든 하위 노드를 대상에 연결하는 마스터 게인 노드입니다. 마스터 게인 노드에 바로 연결된 두 개의 게인 노드가 있습니다. 하나는 음악 채널용이고 다른 하나는 모든 음향 효과를 연결하는 노드입니다.
Fieldrunners에는 버그를 기능으로 잘못 사용하여 3개의 추가 게인 노드가 있었습니다. 이러한 노드를 사용하여 그래프에서 재생 중인 사운드 그룹을 잘라내어 진행을 중지했습니다. 소리를 일시중지하기 위해 이 작업을 수행했습니다. 이는 올바르지 않으므로 이제 위에서 설명한 대로 총 3개의 게인 노드만 사용합니다. 다음 스니펫 중 상당수에는 잘못된 노드가 포함되어 있어 Google에서 수행한 작업과 단기적으로 이를 수정하는 방법을 보여줍니다. 하지만 장기적으로는 coreEffectsGain 노드 이후에 Google 노드를 사용하지 않는 것이 좋습니다.
function AudioManager() {
// map for loaded sounds
this.sounds = {};
// create our permanent nodes
this.nodes = {
destination: this.audioContext.destination,
masterGain: this.audioContext.createGain(),
backgroundMusicGain: this.audioContext.createGain(),
coreEffectsGain: this.audioContext.createGain(),
effectsGain: this.audioContext.createGain(),
pausedEffectsGain: this.audioContext.createGain()
};
// and setup the graph
this.nodes.masterGain.connect( this.nodes.destination );
this.nodes.backgroundMusicGain.connect( this.nodes.masterGain );
this.nodes.coreEffectsGain.connect( this.nodes.masterGain );
this.nodes.effectsGain.connect( this.nodes.coreEffectsGain );
this.nodes.pausedEffectsGain.connect( this.nodes.coreEffectsGain );
}
대부분의 게임에서는 음향 효과와 음악을 별도로 제어할 수 있습니다. 위 그래프를 사용하면 이 작업을 쉽게 수행할 수 있습니다. 각 게인 노드에는 0과 1 사이의 소수점 값으로 설정할 수 있는 '게인' 속성이 있으며, 이 속성은 기본적으로 볼륨을 제어하는 데 사용할 수 있습니다. 음악 및 음향 효과 채널의 볼륨을 별도로 제어하려고 하므로 볼륨을 제어할 수 있는 게인 노드가 각각 있습니다.
function setArbitraryVolume() {
var musicGainNode = this.nodes.backgroundMusicGain;
// set music volume to 50%
musicGainNode.gain.value = 0.5;
}
이 기능을 사용하여 음향 효과와 음악 등 모든 항목의 볼륨을 제어할 수 있습니다. 마스터 노드의 게인을 설정하면 게임의 모든 사운드에 영향을 미칩니다. 게인 값을 0으로 설정하면 사운드와 음악이 음소거됩니다. AudioBufferSourceNode에도 게인 매개변수가 있습니다. 재생 중인 모든 사운드의 목록을 추적하고 전체 볼륨에 맞게 게인 값을 개별적으로 조정할 수 있습니다. 오디오 태그로 음향 효과를 만들었다면 이렇게 해야 합니다. 대신 Web Audio의 노드 그래프를 사용하면 수많은 사운드의 볼륨을 훨씬 쉽게 수정할 수 있습니다. 이러한 방식으로 볼륨을 제어하면 복잡하지 않으면서도 추가적인 제어 기능을 사용할 수 있습니다. AudioBufferSourceNode를 마스터 노드에 직접 연결하여 음악을 재생하고 자체 게인을 제어할 수 있습니다. 하지만 음악을 재생하기 위해 AudioBufferSourceNode를 만들 때마다 이 값을 설정해야 합니다. 대신 플레이어가 음악 볼륨을 변경할 때와 실행 시에만 하나의 노드를 변경합니다. 이제 버퍼 소스에 게인 값이 있으므로 다른 작업을 할 수 있습니다. 음악의 경우 한 오디오 트랙이 끝나고 다른 오디오 트랙이 시작될 때 크로스페이드를 만드는 데 사용할 수 있습니다. Web Audio는 이를 쉽게 수행할 수 있는 좋은 방법을 제공합니다.
function arbitraryCrossfade( track1, track2 ) {
track1.gain.linearRampToValueAtTime( 0, 1 );
track2.gain.linearRampToValueAtTime( 1, 1 );
}
Fieldrunners는 크로스페이딩을 구체적으로 사용하지 않았습니다. 사운드 시스템을 처음 구현할 때 WebAudio의 값 설정 기능을 알았다면 사용했을 것입니다.
소리 일시중지
플레이어가 게임을 일시중지하면 일부 소리가 계속 재생될 수 있습니다. 사운드는 게임 메뉴에서 사용자 인터페이스 요소를 누르는 일반적인 동작에 대한 피드백에서 중요한 부분을 차지합니다. Fieldrunners에는 게임이 일시중지된 동안 사용자가 상호작용할 수 있는 여러 인터페이스가 있으므로 계속 플레이할 수 있습니다. 하지만 길거나 반복되는 소리가 계속 재생되는 것은 원하지 않습니다. Web Audio를 사용하면 이러한 소리를 쉽게 중지할 수 있습니다. 적어도 저희는 그렇게 생각했습니다.
AudioManager.prototype.pauseEffects = function() {
this.nodes.effectsGain.disconnect();
}
일시중지된 효과 노드는 계속 연결되어 있습니다. 게임의 일시중지 상태를 무시하도록 허용된 사운드는 계속 재생됩니다. 게임이 일시중지 해제되면 이러한 노드를 다시 연결하고 모든 사운드를 즉시 다시 재생할 수 있습니다.
AudioManager.prototype.resumeEffects = function() {
this.nodes.effectsGain.connect( this.nodes.coreEffectsGain );
}
Fieldrunners를 출시한 후 노드 또는 하위 그래프를 연결 해제하는 것만으로는 AudioBufferSourceNodes의 재생이 일시중지되지 않는다는 사실을 발견했습니다. Google은 현재 그래프의 대상 노드에 연결되지 않은 노드의 재생을 중지하는 WebAudio의 버그를 활용했습니다. 따라서 향후 수정에 대비하려면 다음과 같은 코드가 필요합니다.
AudioManager.prototype.pauseEffects = function() {
this.nodes.effectsGain.disconnect();
var now = Date.now();
for ( var name in this.sounds ) {
var sound = this.sounds[ name ];
if ( !sound.ignorePause && ( now - sound.source.noteOnAt < sound.buffer.duration * 1000 ) ) {
sound.pausedAt = now - sound.source.noteOnAt;
sound.source.noteOff();
}
}
}
AudioManager.prototype.resumeEffects = function() {
this.nodes.effectsGain.connect( this.nodes.coreEffectsGain );
var now = Date.now();
for ( var name in this.sounds ) {
if ( sound.pausedAt ) {
this.play( sound.name );
delete sound.pausedAt;
}
}
};
버그를 악용하고 있다는 사실을 더 일찍 알았더라면 오디오 코드의 구조가 매우 달라졌을 것입니다. 이에 따라 이 도움말의 여러 섹션이 영향을 받았습니다. 이 설정은 여기뿐만 아니라 Losing Focus 및 Give Me a Beat의 코드 스니펫에도 직접적인 영향을 미칩니다. 이것이 실제로 어떻게 작동하는지 알기 위해서는 Fieldrunners 노드 그래프 (재생을 단축하기 위한 노드를 만들었기 때문에)와 Web Audio가 자체적으로 수행하지 않는 일시중지 상태를 기록하고 제공하는 추가 코드를 모두 변경해야 합니다.
집중력 저하
이 기능에는 마스터 노드가 사용됩니다. 브라우저 사용자가 다른 탭으로 전환하면 게임이 더 이상 표시되지 않습니다. 눈에 보이지 않으면 신경 쓰이지 않으므로 소리도 사라져야 합니다. 게임 페이지의 특정 공개 상태를 확인하는 방법은 여러 가지가 있지만 Visibility API를 사용하면 훨씬 더 쉬워집니다.
Fieldrunners는 requestAnimationFrame을 사용하여 업데이트 루프를 호출하므로 활성 탭으로만 재생됩니다. 하지만 사용자가 다른 탭에 있는 동안에도 Web Audio 컨텍스트는 반복 효과와 배경 트랙을 계속 재생합니다. 하지만 Visibility API를 인식하는 아주 작은 스니펫을 사용하면 이를 막을 수 있습니다.
function AudioManager() {
// map and node setup
// ...
// disable all sound when on other tabs
var self = this;
window.addEventListener( 'webkitvisibilitychange', function( e ) {
if ( document.webkitHidden ) {
self.nodes.masterGain.disconnect();
// As noted in Pausing Sounds disconnecting isn't enough.
// For Fieldrunners calling our new pauseEffects method would be
// enough to accomplish that, though we may still need some logic
// to not resume if already paused.
self.pauseEffects();
} else {
self.nodes.masterGain.connect( this.nodes.destination );
self.resumeEffects();
}
});
}
이 도움말을 작성하기 전에는 마스터를 연결 해제하면 음소거하는 대신 모든 사운드를 일시중지할 수 있을 것이라고 생각했습니다. 그 시점에 노드를 연결 해제하여 노드와 하위 노드의 처리 및 재생을 중지했습니다. 다시 연결되면 게임플레이가 중단된 지점부터 계속되는 것처럼 모든 사운드와 음악이 중단된 지점부터 재생됩니다. 하지만 이는 예상치 못한 동작입니다. 연결을 해제하는 것만으로는 재생을 중지할 수 없습니다.
Page Visibility API를 사용하면 탭이 더 이상 포커스에 있지 않은 시점을 매우 쉽게 알 수 있습니다. 소리를 일시중지하는 효과적인 코드가 이미 있다면 게임 탭이 숨겨져 있을 때 소리를 일시중지하는 코드를 몇 줄만 작성하면 됩니다.
비트 줘
이제 몇 가지 설정이 완료되었습니다. 노드 그래프가 있습니다. 플레이어가 게임을 일시중지할 때 소리를 일시중지하고 게임 메뉴와 같은 요소에 새로운 소리를 재생할 수 있습니다. 사용자가 새 탭으로 전환하면 모든 소리와 음악을 일시중지할 수 있습니다. 이제 실제로 소리를 재생해야 합니다.
Fieldrunners는 캐릭터가 죽는 것과 같은 게임 항목의 여러 인스턴스에 대해 여러 사운드 복사본을 재생하는 대신 지속 시간 동안 하나의 사운드를 한 번만 재생합니다. 재생이 완료된 후 사운드가 필요한 경우 다시 시작할 수 있지만 이미 재생 중인 동안에는 다시 시작할 수 없습니다. 이는 Fieldrunners의 오디오 디자인을 위한 결정입니다. Fieldrunners에는 빠르게 재생되도록 요청된 사운드가 있는데, 다시 시작하도록 허용하면 끊김이 발생하고 여러 인스턴스를 재생하도록 허용하면 불쾌한 불협화음이 발생하기 때문입니다. AudioBufferSourceNode는 원샷으로 사용될 것으로 예상됩니다. 노드를 만들고, 버퍼를 연결하고, 필요한 경우 루프 불리언 값을 설정하고, 대상으로 이어지는 그래프의 노드에 연결하고, noteOn 또는 noteGrainOn을 호출하고, 선택적으로 noteOff를 호출합니다.
Fieldrunners의 경우 다음과 같습니다.
AudioManager.prototype.play = function( options ) {
var now = Date.now(),
// pull from a map of loaded audio buffers
sound = this.sounds[ options.name ],
channel,
source,
resumeSource;
if ( !sound ) {
return;
}
if ( sound.source ) {
var source = sound.source;
if ( !options.loop && now - source.noteOnAt > sound.buffer.duration * 1000 ) {
// discard the previous source node
source.stop( 0 );
source.disconnect();
} else {
return;
}
}
source = this.audioContext.createBufferSource();
sound.source = source;
// track when the source is started to know if it should still be playing
source.noteOnAt = now;
// help with pausing
sound.ignorePause = !!options.ignorePause;
if ( options.ignorePause ) {
channel = this.nodes.pausedEffectsGain;
} else {
channel = this.nodes.effectsGain;
}
source.buffer = sound.buffer;
source.connect( channel );
source.loop = options.loop || false;
// Fieldrunners' current code doesn't consider sound.pausedAt.
// This is an added section to assist the new pausing code.
if ( sound.pausedAt ) {
source.start( ( sound.buffer.duration * 1000 - sound.pausedAt ) / 1000 );
source.noteOnAt = now + sound.buffer.duration * 1000 - sound.pausedAt;
// if you needed to precisely stop sounds, you'd want to store this
resumeSource = this.audioContext.createBufferSource();
resumeSource.buffer = sound.buffer;
resumeSource.connect( channel );
resumeSource.start(
0,
sound.pausedAt,
sound.buffer.duration - sound.pausedAt / 1000
);
} else {
// start play immediately with a value of 0 or less
source.start( 0 );
}
}
스트리밍이 너무 많음
Fieldrunners는 원래 오디오 태그로 재생되는 배경 음악과 함께 출시되었습니다. 출시 시점에 음악 파일이 다른 게임 콘텐츠에 비해 과도하게 많이 요청되는 것으로 확인되었습니다. 조사 결과 당시 Chrome 브라우저가 음악 파일의 스트리밍된 청크를 캐시하지 않는 것으로 확인되었습니다. 이로 인해 브라우저가 재생 중인 트랙이 끝날 때마다 몇 분 간격으로 요청하게 되었습니다. 최근 테스트에서 Chrome은 스트리밍된 트랙을 캐시했지만 다른 브라우저에서는 아직 이 작업을 수행하지 않을 수 있습니다. 음악 재생과 같은 기능을 위해 Audio 태그를 사용하여 대용량 오디오 파일을 스트리밍하는 것이 최적이지만 일부 브라우저 버전에서는 음향 효과를 로드하는 것과 동일한 방식으로 음악을 로드하는 것이 좋습니다.
모든 음향 효과가 Web Audio를 통해 재생되었으므로 배경 음악 재생도 Web Audio로 옮겼습니다. 즉, XMLHttpRequests 및 arraybuffer 응답 유형을 사용하여 모든 효과를 로드하는 것과 동일한 방식으로 트랙을 로드합니다.
AudioManager.prototype.load = function( options ) {
var xhr,
// pull from a map of name, object pairs
sound = this.sounds[ options.name ];
if ( sound ) {
// this is a great spot to add success methods to a list or use promises
// for handling the load event or call success if already loaded
if ( sound.buffer && options.success ) {
options.success( options.name );
} else if ( options.success ) {
sound.success.push( options.success );
}
// one buffer is enough so shortcut here
return;
}
sound = {
name: options.name,
buffer: null,
source: null,
success: ( options.success ? [ options.success ] : [] )
};
this.sounds[ options.name ] = sound;
xhr = new XMLHttpRequest();
xhr.open( 'GET', options.path, true );
xhr.responseType = 'arraybuffer';
xhr.onload = function( e ) {
sound.buffer = self._context.createBuffer( xhr.response, false );
// call all waiting handlers
sound.success.forEach( function( success ) {
success( sound.name );
});
delete sound.success;
};
xhr.onerror = function( e ) {
// failures are uncommon but you want to do deal with them
};
xhr.send();
}
요약
Fieldrunners를 Chrome과 HTML5로 가져오는 것은 정말 즐거운 일이었습니다. 수천 줄의 C++ 코드를 JavaScript로 변환하는 엄청난 작업 외에도 HTML5와 관련된 몇 가지 흥미로운 딜레마와 결정이 발생했습니다. 다른 것 중 하나가 아닌 경우 AudioBufferSourceNodes는 일회용 객체입니다. 노드를 만들고, Audio Buffer를 연결하고, Web Audio 그래프에 연결하고, noteOn 또는 noteGrainOn으로 재생합니다. 소리를 다시 재생해야 하나요? 그런 다음 다른 AudioBufferSourceNode를 만듭니다.