مطالعه موردی - داستان یک بازی HTML5 با صدای وب

فیلدرانرها

اسکرین شات Fieldrunners
اسکرین شات Fieldrunners

Fieldrunners یک بازی برنده جوایز در سبک دفاع از برج است که در ابتدا برای آیفون در سال 2008 منتشر شد. از آن زمان به بعد به بسیاری از پلتفرم های دیگر منتقل شده است. یکی از جدیدترین پلتفرم‌ها مرورگر کروم در اکتبر 2011 بود. یکی از چالش‌های انتقال Fieldrunners به ​​پلتفرم HTML5 نحوه پخش صدا بود.

Fieldrunners استفاده پیچیده‌ای از جلوه‌های صوتی نمی‌کند، اما انتظاراتی از نحوه تعامل با جلوه‌های صوتی خود دارد. این بازی دارای 88 افکت صوتی است که می توان انتظار داشت تعداد زیادی از آن ها در یک زمان اجرا شود. اکثر این صداها بسیار کوتاه هستند و باید تا حد امکان به موقع پخش شوند تا از ایجاد هرگونه قطع ارتباط با نمایش گرافیکی جلوگیری شود.

برخی از چالش ها ظاهر شد

در حین انتقال Fieldrunners به ​​HTML5، ما با مشکلات پخش صدا با تگ Audio مواجه شدیم و در اوایل تصمیم گرفتیم به جای آن بر Web Audio API تمرکز کنیم. استفاده از WebAudio به ما کمک کرد تا مشکلاتی مانند ارائه تعداد بالای افکت‌های همزمان که Fieldrunners نیاز دارد را حل کنیم. با این حال، در حالی که در حال توسعه یک سیستم صوتی برای Fieldrunners HTML5 هستیم، با چند مشکل ظریف روبرو شدیم که سایر توسعه دهندگان ممکن است بخواهند از آن آگاه باشند.

ماهیت AudioBufferSourceNodes

AudioBufferSourceNodes روش اصلی شما برای پخش صداها با WebAudio است. درک این نکته بسیار مهم است که آنها یک شی یک بار مصرف هستند. شما یک AudioBufferSourceNode ایجاد می‌کنید، به آن بافر اختصاص می‌دهید، آن را به نمودار متصل می‌کنید و آن را با noteOn یا noteGrainOn پخش می‌کنید. پس از آن می توانید noteOff را برای توقف پخش فراخوانی کنید، اما نمی توانید با فراخوانی noteOn یا noteGrainOn دوباره منبع را پخش کنید - باید AudioBufferSourceNode دیگری ایجاد کنید. با این حال، می‌توانید - و این نکته کلیدی است - از همان شی زیربنایی AudioBuffer استفاده مجدد کنید (در واقع، حتی می‌توانید چندین AudioBufferSourceNode فعال داشته باشید که به یک نمونه AudioBuffer اشاره می‌کنند!). می‌توانید یک قطعه پخش از Fieldrunners را در Give Me a Beat پیدا کنید.

محتوای غیر کش

در زمان انتشار، سرور Fieldrunners HTML5 تعداد زیادی درخواست برای فایل های موسیقی را نشان داد. این نتیجه از Chrome 15 ناشی می‌شود که فایل را به صورت تکه‌ای بارگیری می‌کند و سپس آن را کش نمی‌کند. در پاسخ در آن زمان تصمیم گرفتیم فایل های موسیقی را مانند بقیه فایل های صوتی خود بارگذاری کنیم. انجام این کار کمتر از حد مطلوب است، اما برخی از نسخه های مرورگرهای دیگر همچنان این کار را انجام می دهند.

ساکت کردن در هنگام خارج از تمرکز

تشخیص زمانی که برگه بازی شما خارج از فوکوس است قبلاً دشوار بود. Fieldrunner ها قبل از Chrome 13 شروع به انتقال کردند، جایی که API مشاهده صفحه جایگزین نیاز به کد پیچیده ما برای تشخیص محو شدن برگه ها شد. هر بازی باید از Visibility API برای نوشتن یک قطعه کوچک استفاده کند تا صدای خود را قطع یا مکث کند، در صورتی که کل بازی را متوقف نکنید. از آنجایی که Fieldrunners از requestAnimationFrame API استفاده می کرد، مکث بازی به طور ضمنی انجام می شد، اما مکث صدا انجام نمی شد.

مکث صداها

به طرز عجیبی هنگام دریافت بازخورد به این مقاله، به ما اطلاع داده شد که تکنیکی که برای توقف صداها استفاده می‌کنیم مناسب نیست - ما از یک اشکال در اجرای فعلی Web Audio برای توقف پخش صداها استفاده می‌کنیم. از آنجایی که این مشکل در آینده برطرف خواهد شد، نمی‌توانید صدا را با قطع ارتباط یک گره یا زیرگراف برای توقف پخش، متوقف کنید.

یک معماری ساده گره صوتی وب

Fieldrunners یک مدل صوتی بسیار ساده دارد. آن مدل می تواند مجموعه ویژگی های زیر را پشتیبانی کند:

  • کنترل حجم جلوه های صوتی.
  • صدای آهنگ موسیقی پس‌زمینه را کنترل کنید.
  • تمام صداها را بی صدا کنید.
  • هنگام توقف بازی، پخش صداها را خاموش کنید.
  • وقتی بازی از سر گرفته شد، همان صداها را دوباره روشن کنید.
  • وقتی زبانه بازی تمرکز خود را از دست داد، همه صداها را خاموش کنید.
  • پس از پخش صدا در صورت لزوم، پخش را مجدداً شروع کنید.

برای دستیابی به ویژگی های فوق با Web Audio، از 3 گره ممکن ارائه شده استفاده کرد: DestinationNode، GainNode، AudioBufferSourceNode. AudioBufferSourceNodes صداها را پخش می کند. GainNodes AudioBufferSourceNodes را به هم متصل می کند. DestinationNode که توسط زمینه صوتی وب ایجاد شده است که مقصد نامیده می شود، صداها را برای پخش کننده پخش می کند. Web Audio انواع بیشتری از گره‌ها دارد، اما تنها با آنها می‌توانیم یک نمودار بسیار ساده برای صداهای یک بازی ایجاد کنیم.

نمودار گره گره

یک گراف گره صوتی وب از گره های برگ به گره مقصد هدایت می شود. فیلدرانرها از 6 نود افزایش دائمی استفاده می‌کردند، اما 3 نود برای کنترل آسان صدا و اتصال تعداد بیشتری از گره‌های موقت که بافرهای پخش را انجام می‌دهند، کافی است. ابتدا یک گره به دست آوردن اصلی که هر گره فرزند را به مقصد متصل می کند. فوراً به گره افزایش اصلی دو گره افزایش متصل است، یکی برای یک کانال موسیقی و دیگری برای پیوند دادن تمام جلوه های صوتی.

فیلدرانرها به دلیل استفاده نادرست از یک باگ به عنوان یک ویژگی، 3 گره افزایش اضافی داشتند. ما از این گره‌ها برای جدا کردن گروه‌هایی از صداهای پخش شده از نمودار استفاده کردیم که پیشرفت آنها را متوقف می‌کند. ما این کار را برای توقف صداها انجام دادیم. از آنجایی که درست نیست، اکنون فقط از 3 گره بهره کل همانطور که در بالا توضیح داده شد استفاده می کنیم. بسیاری از قطعه‌های زیر شامل گره‌های نادرست ما می‌شوند، نشان می‌دهند که ما چه کاری انجام داده‌ایم و چگونه آن را در کوتاه‌مدت برطرف می‌کنیم. اما در دراز مدت شما نمی خواهید از گره های ما بعد از گره coreEffectsGain ما استفاده نکنید.

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

ما می توانیم از همین توانایی برای کنترل صدای همه چیز، جلوه های صوتی و موسیقی استفاده کنیم. تنظیم بهره گره اصلی بر تمام صداهای بازی تأثیر می گذارد. اگر مقدار Gain را روی 0 تنظیم کنید، صدا و موسیقی را بی صدا خواهید کرد. AudioBufferSourceNodes یک پارامتر افزایش نیز دارند. می‌توانید فهرستی از تمام صداهای در حال پخش را ردیابی کنید و مقادیر افزایش آن‌ها را به صورت جداگانه برای حجم کلی تنظیم کنید. اگر با تگ های صوتی جلوه های صوتی می ساختید، این کاری است که باید انجام دهید. در عوض، گراف گره Web Audio تغییر حجم صدای صداهای بی شمار را بسیار آسان تر می کند. کنترل صدا از این طریق نیز به شما قدرت اضافی بدون عارضه می دهد. ما فقط می‌توانیم یک AudioBufferSourceNode را برای پخش موسیقی مستقیماً به گره اصلی متصل کنیم و بهره خود را کنترل کنیم. اما هر بار که یک AudioBufferSourceNode را به منظور پخش موسیقی ایجاد می کنید، باید این مقدار را تنظیم کنید. در عوض، تنها زمانی که پخش‌کننده صدای موسیقی را تغییر می‌دهد و هنگام راه‌اندازی، یک گره را تغییر می‌دهید. اکنون ما یک مقدار افزایش در منابع بافر برای انجام کار دیگری داریم. برای موسیقی، یکی از استفاده‌های رایج می‌تواند ایجاد یک محو شدن متقاطع از یک آهنگ صوتی به آهنگ دیگر باشد که یکی از آن‌ها خارج می‌شود و دیگری وارد می‌شود. Web Audio روش خوبی برای اجرای آسان این کار ارائه می‌دهد.

function arbitraryCrossfade( track1, track2 ) {
  track1.gain.linearRampToValueAtTime( 0, 1 );
  track2.gain.linearRampToValueAtTime( 1, 1 );
}

فیلدرانرها از crossfading استفاده خاصی نکردند. اگر از عملکرد تنظیم ارزش 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 نمی شود. ما در واقع از یک اشکال در 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 برای فراخوانی حلقه به روز رسانی آن، فقط به عنوان تب فعال بازی می کند. اما زمینه صوتی وب به پخش جلوه‌های حلقه‌ای و آهنگ‌های پس‌زمینه در زمانی که کاربر در برگه دیگری است، ادامه می‌دهد. اما می‌توانیم با یک قطعه بسیار کوچک 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();
    }
  });
}

قبل از نوشتن این مقاله، فکر می‌کردیم که قطع کردن Master برای مکث کردن همه صداها به جای بی‌صدا کردن آن کافی است. با قطع اتصال گره در آن زمان، آن و فرزندانش را از پردازش و بازی منع کردیم. وقتی دوباره وصل شد، همه صداها و موسیقی از جایی که رها شده بودند شروع به پخش می‌کردند، همانطور که بازی در همان جایی که ترک کرده بود ادامه می‌یابد. اما این یک رفتار غیرمنتظره است. برای توقف پخش، فقط قطع اتصال کافی نیست.

صفحه Visibility API تشخیص اینکه چه زمانی برگه شما دیگر در فوکوس نیست را بسیار آسان می کند. اگر از قبل کد موثری برای مکث صداها دارید، زمانی که برگه بازی‌ها پنهان است، نوشتن در مکث صدا فقط چند خط طول می‌کشد.

به من ضربه بزن

ما اکنون چند مورد را تنظیم کرده ایم. ما یک نمودار از گره ها داریم. وقتی بازیکن بازی را متوقف می‌کند، می‌توانیم صداها را متوقف کنیم و صداهای جدیدی را برای عناصری مانند منوهای بازی پخش کنیم. وقتی کاربر به برگه جدیدی تغییر می‌کند، می‌توانیم تمام صدا و موسیقی را متوقف کنیم. اکنون باید در واقع یک صدا را پخش کنیم.

Fieldrunners به ​​جای پخش چندین نسخه از صدا برای چندین نمونه از یک موجودیت بازی مانند یک کاراکتر در حال مرگ، یک صدا را فقط یک بار در طول مدت پخش می کند. اگر صدا پس از اتمام پخش مورد نیاز باشد، می تواند دوباره راه اندازی شود، اما نه در حال پخش. این تصمیم برای طراحی صوتی Fieldrunners است زیرا صداهایی دارد که درخواست می‌شود به سرعت پخش شوند که در غیر این صورت اگر اجازه راه‌اندازی مجدد داده شود، لکنت ایجاد می‌کند یا اگر اجازه پخش چندین نمونه را داده شود، صدای ناخوشایندی ایجاد می‌کند. انتظار می رود AudioBufferSourceNodes به عنوان یک عکس استفاده شود. یک گره ایجاد کنید، یک بافر متصل کنید، در صورت نیاز مقدار بولین حلقه را تنظیم کنید، به گرهی در نمودار که به مقصد منتهی می شود متصل شوید، 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 در ابتدا با موسیقی پس‌زمینه‌ای که با یک برچسب صوتی پخش می‌شد، راه‌اندازی شد. در زمان انتشار، متوجه شدیم که فایل‌های موسیقی به تعداد نامتناسبی با آنچه که بقیه محتوای بازی درخواست شده بود، درخواست می‌شوند. پس از چند تحقیق، متوجه شدیم که در آن زمان مرورگر کروم تکه های پخش شده فایل های موسیقی را در حافظه پنهان ذخیره نمی کرد. این باعث شد که مرورگر هر چند دقیقه یکبار پس از اتمام آهنگ پخش را درخواست کند. در آزمایش‌های اخیر، کروم آهنگ‌های پخش‌شده را در حافظه پنهان ذخیره کرد، اما ممکن است مرورگرهای دیگر هنوز این کار را انجام ندهند. پخش جریانی فایل های صوتی بزرگ با برچسب صوتی برای عملکردهایی مانند پخش موسیقی بهینه است، اما برای برخی از نسخه های مرورگر ممکن است بخواهید موسیقی خود را به همان روشی که جلوه های صوتی را بارگذاری می کنید بارگیری کنید.

از آنجایی که تمام جلوه‌های صوتی از طریق Web Audio پخش می‌شد، پخش موسیقی پس‌زمینه را به Web Audio نیز منتقل کردیم. این بدان معنی است که ما آهنگ ها را به همان روشی که تمام افکت ها را با XMLHttpRequests و نوع پاسخ آرایه بافر بارگذاری کردیم، بارگذاری می کنیم.

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 یک انفجار برای کروم و HTML5 بود. خارج از کوه کاری خود که هزاران خط C++ را به جاوا اسکریپت وارد می کند، برخی معضلات و تصمیمات جالب خاص برای HTML5 برمی انگیزد. برای تکرار یکی از موارد دیگر، AudioBufferSourceNodes اشیایی یک بار مصرف هستند. آنها را ایجاد کنید، یک بافر صوتی وصل کنید، آن را به نمودار Web Audio متصل کنید و با noteOn یا noteGrainOn بازی کنید. آیا نیاز به پخش مجدد آن صدا دارید؟ سپس AudioBufferSourceNode دیگری ایجاد کنید.