admin管理员组

文章数量:1633182

原文:Advanced Game Design with HTML5 and JavaScript

协议:CC BY-NC-SA 4.0

九、网络音频 API 的声音

HTML5 有两个不同的声音播放系统:旧的 HTML5 音频元素和新的 Web 音频 API。你应该用哪一个?对于游戏,使用非常强大的网络音频 API。它加载声音可靠,让您同时播放多种声音,并给你精确的播放控制。此外,它还允许您应用效果,创建自己的合成声音,以及指定不同种类的输入和输出源。一旦你克服了这个小小的学习曲线,你将能够使用 Web Audio API 快速创建任何你能想象的声音系统。

在这一章中,你将会学到所有你需要知道的关于网络音频 API 的知识,来使用和创建你的游戏需要的所有音乐和声音效果。您将学习如何加载声音文件,访问它们,控制它们如何播放,以及添加特殊效果,如回声和混响。您还将学习如何用纯代码从头开始生成声音,以及如何构建您自己的游戏音效定制库。

不过,在我们深入研究新的 Web 音频 API 之前,让我们先了解一下最初的 HTML 音频元素。

HTML 音频元素

HTML5 音频元素是专门为在网站上播放音频文件而设计的,通常过于局限,不能很好地用于游戏。用 HTML5 音频元素加载和播放声音的 JavaScript 格式与加载图像的格式相同。创建一个新的Audio对象,监听一个canplaythrough事件,然后在声音加载完成后调用一个事件处理程序。你可以用一个play方法开始声音,并设置其他属性,如volumeloop。这里有一个典型的例子:

let sound = new Audio();
sound.addEventListener("canplaythrough", playSoundHandler, false);
sound.src = "sounds/music.wav";

function playSoundHandler(event) {
  sound.play();
  sound.volume = 0.5;
  sound.loop = true;
}

很懂事,很熟悉,可惜,很有限。回放控制不精确,同时重叠的声音要么不起作用,要么表现古怪。尽管在撰写本文时,HTML5 Audio element 规范已经稳定了很多年,但是没有一个浏览器供应商已经完全实现了它,或者看起来可能很快就会实现。所以如果你在浏览器中运行这个例子,首先希望loop属性能够工作。如果是这样,您可能会听到音乐循环重复,但您几乎肯定会在它重复之前听到几毫秒的间隙。HTML5 音频元素对于一个声音设计要求不高的游戏来说已经足够了,但是对于其他任何东西来说,你需要更多的控制。

进入,网络音频 API!

了解网络音频 API

无论你如何努力地浏览 Web Audio API 规范,你都不会找到任何一个叫做play 的方法,或者任何一个叫做loop或者volume的属性。你必须自己建立这些。Web Audio API 给你的是一个装满音频组件的大玩具盒,你可以用它来创建你可能需要的任何音频系统。你的工作是把那个盒子倒在你的厨房桌子上,然后花一个阳光明媚的下午把这些组件连接起来,组合成任何你觉得有用的东西。Web Audio API 最大的优点是它是完全模块化的,所以你可以用任何你喜欢的方式连接不同的组件。您可以构建自己的模拟风格合成器或采样器、3D 全息音乐播放器、乐谱解释器、程序音乐生成器或声音可视化器。或者,你可以做我们在本章将要做的事情:为游戏创建可重复使用的音效和音乐播放器和生成器。

注意Web Audio API 足够复杂和深入,它本身就值得写一整本书。在这一章中,我不会触及 API 的每一个特性,只是那些你需要知道的来创建一个在游戏中使用声音的实用系统。您可以把这看作是对 Web Audio API 的广泛介绍,如果您想自己进一步探索它,这将为您打下坚实的基础。

以下是使用网络音频 API 加载和播放声音时需要遵循的基本步骤:

  1. 用 XHR 加载声音文件并解码。你最终得到一个叫做缓冲区的原始音频文件。
  2. 将缓冲器连接到音频效果节点。可以把每个节点想象成一个小小的音效盒,上面有可以调节的旋钮,也就是说,可以用代码设置参数和属性。你可能有一个控制音量的盒子,另一个控制左/右平移的盒子,还有一个控制混响的盒子。任何电吉他演奏者都可以把你的吉他想象成声音缓冲器,把节点想象成效果踏板。您可以按任意顺序将任意数量的节点连接在一起。
  3. 要听到声音,将效果链中的最后一个节点连接到目的地。目标通常是系统上的默认播放设备;它可以是扬声器、耳机、7.1 环绕声或电视。然而,目标也可以是新的声音文件。
  4. 开始播放声音。

图 9-1 显示了这个过程的样子。

图 9-1 。如何使用网络音频 API 播放声音

这在实际代码中更容易理解,所以接下来让我们看一个实际的例子。

加载和播放声音文件

让我们从最简单的例子开始。我们将加载一个声音文件,并通过按键盘上的数字 1 来播放它。评论一步一步地解释了这一切是如何运作的。

//1\. Create an audio context
    let actx = new AudioContext();
//2\. Declare a variable to hold the sound we're going to load
    let soundBuffer;
//3\. Load the sound.
//a. Use an XMLHttpRequest object to load the sound
    let xhr = new XMLHttpRequest();
//b. Set properties for the file we want to load.
//Use GET and set the path to the sound file.
//`true` means that the file will load asynchronously and will create
//an event when the file has finished loading
    xhr.open("GET", "sounds/test.wav", true);
//c. Set the `responseType`, which is the file format we're expecting to
//load. Sound files should be loaded as binary files, so the `responseType`
//needs to be `arraybuffer`
    xhr.responseType = "arraybuffer";
//d. Load the sound into the program
    xhr.send();
//e. Create a `loadHandler` that runs when the sound has been loaded
    xhr.addEventListener("load", loadHandler, false);

    function loadHandler(event) {
//f. Decode the audio file and store it in the `soundBuffer`
//variable. The `buffer` is the raw audio data
  actx.decodeAudioData(
    xhr.response,
    buffer => {

//g. Copy the audio file into the `soundBuffer` variable
soundBuffer = buffer;
},

//Optionally throw an error if the audio can't be decoded
error => {
  throw new Error("Audio could not be decoded: " + error);
  }
 );
}

//f. Play a sound when a key is pressed
window.addEventListener("keydown", keydownHandler, false);

function keydownHandler(event) {
  switch (event.keyCode) {
    case 49:
      if (soundBuffer) {

        //4\. Play the sound
        //a. Create a new `soundNode` and tell it to use the
        //sound that we loaded as its audio source
        let soundNode = actx.createBufferSource();
        soundNode.buffer = soundBuffer;

        //b. Connect the sound to the destination.
        //(There are no effects in this example.)
        soundNode.connect(actx.destination);

        //c. Finally, play the sound. Use the `start` method to
        //play the sound “right now”, which is the audio context’s `currentTime`
        soundNode.start(actx.currentTime);
      }
      break;
  }
}

第一步是创建一个AudioContext 。这是您将创建和控制声音的编程空间:

var actx = new AudioContext();

这就像画布的背景,除了它是声音而不是图像。

接下来,创建一个名为soundBuffer 的变量,用于存储原始的二进制声音文件。

let soundBuffer;

使用 XHR 加载声音。responseTypearrayBuffer,它只是告诉 XHR 你正在加载一个二进制文件,而不是一个文本文件。

let xhr = new XMLHttpRequest();
xhr.open("GET", "sounds/test.wav", true);
xhr.responseType = "arraybuffer";
xhr.send();
xhr.addEventListener("load", loadHandler, false);

loadHandler 使用音频上下文的decodeAudioData方法将声音文件转换为原始音频数据。它将这些数据保存在soundBuffer中:

function loadHandler(event) {
  actx.decodeAudioData(
    xhr.response,
    buffer => {
      soundBuffer = buffer;
    },
    error => {
      throw new Error("Audio could not be decoded: " + error);
    }
  );
}

decodeAudioData方法有一个可选的第三个参数,这是一个在解码音频出错时运行的函数。你应该总是加上这一点,因为如果音频由于某种原因没有正确解码,你肯定需要得到通知。如果您试图加载不兼容的音频格式,可能会出现解码错误。

最后一步是实际播放声音,在本例中是通过按键盘上的数字 1 来实现的。要播放声音,您至少需要运行以下四行代码:

let soundNode = actx.createBufferSource();
soundNode.buffer = soundBuffer;
soundNode.connect(actx.destination);
soundNode.start(actx.currentTime);

下面是这四行的工作原理。首先创建一个用于播放声音的soundNode 。它保存了对我们加载的缓冲区(原始音频数据)的引用。然后connect``soundNode到音频上下文的destination,在这种情况下是你的计算机的扬声器。最后,用start的方法来播放声音。参数actx.currentTime表示“现在播放声音”

soundNode.start(actx.currentTime);

start方法用于安排声音播放的时间,作为当前时间的偏移量。如果您向它提供音频上下文的currentTime值,这意味着“立即播放声音,没有任何延迟。”如果您希望声音开始前有 2 秒钟的延迟,可以使用以下语法:

start(actx.currentTime + 2)

请注意,Web Audio API 使用的时间单位是秒,而不是毫秒。

提示或者,也可以通过提供值为 0 的 start 方法,立即进行声音播放,这样: start(0) 。这是因为任何小于 currentTime 的值都会导致音频上下文立即播放声音。你可以使用你喜欢的任何一种风格。

这里有一件你需要知道的非常重要的事情:每次你想播放声音的时候都要运行这最后四行代码。对于一种声音来说,这似乎需要编写很多代码,但是我们很快就会解决这个问题。

您已经了解了如何加载和播放基本的声音,但是如果您想添加一些更高级的功能,该怎么办呢?

音量、声相和循环

要改变音量和平移,创建一个volumeNode和一个panNode 和一个:

let volumeNode = actx.createGain();
let panNode = actx.createStereoPanner()

你可以把它们想象成两个音效盒,你可以把它们连接在声音和扬声器之间。下面是如何将这些节点connectsoundNodedestination:

soundNode.connect(volumeNode);
volumneNode.connect(panNode);
panNode.connect(actx.destination);

你可以看到你正在创建一个连接链:soundNode volumeNode panNode destination。最后一个效果节点,在这个例子中是panNode,应该总是连接到destination,这样你就可以听到声音。

注意如果需要断开一个节点,使用disconnect方法。例如,您可以使用以下语法断开平移节点:

panNode.disconnect()

这将断开平移节点与其所连接的任何设备的连接,在本例中是卷节点。但请记住,如果你这样做,你已经打破了原始声音和目的之间的连接链。这意味着当你开始播放声音时,你将听不到任何声音,除非你将音量节点直接连接到目的节点或链中的另一个连接节点。

现在它们已经连接好了,调整这些新节点的设置以达到你想要的效果。以下是将音量设置为 50%的方法:

volumeNode.gain.value = 0.5;

0 的gain.value 是没有声音,1 的值是全音量。(注意增益是音量的音频技术术语。更具体地说,它指的是音频信号被放大的程度。)

要设置左右扬声器平移,请将panNode.pan.value属性设置为–1 和 1 之间的一个数字。值为–1 会将声音导向左扬声器,值为 1 会将声音导向右扬声器。值为 0 会使两个扬声器的音量相等。例如,以下是如何将声相设定为在左扬声器中稍微突出一些:

panNode.pan.value = -0.2;

注意你也可以使用createPanner方法创建一个更高级的声相对象,它返回一个声相节点,让你使用 xyz 空间坐标在 3D 空间定位声音。它非常适合为 3D 游戏创建复杂的声音环境。有关更多信息,请参见位于http://webaudio.github.io/web-audio-api/createPanner方法的网络音频规范。

您想在结束时重复播放声音吗?将soundNodeloop属性设置为true:

soundNode.loop = true;

声音将会在结束时重复播放。

提示在循环声音重复之前,你有没有听到短暂的延迟?如果是这样,请在任何音频编辑软件中打开您的声音文件。您可能会发现,在声音开始之前,该文件包含几毫秒的额外静默。这在 MP3 文件中很常见。还可以考虑删除 MP3 头或元数据,已知这会导致一些音频渲染引擎在重复声音之前打嗝一两毫秒。

现在让我们把所有这些新技术放在一起。如果你想播放一个通过音量和声相控制循环播放的声音,下面是你需要运行的完整代码:

let soundNode = actx.createBufferSource();
soundNode.buffer = soundBuffer;

//Create volume and pan nodes
let volumeNode = actx.createGain();
let panNode = actx.createStereoPanner();

//Connect the sound source to the pan node, the pan node to
//volume node, and the volume node to the destination
soundNode.connect(panNode);
panNode.connect(volumeNode);
volumeNode.connect(actx.destination);

//Set the volume
volumeNode.gain.value = 0.5;

//Set the pan fully to the left
panNode.pan.value = -1;

//Optionally loop the sound
soundNode.loop = true;

//Finally, play the sound
soundNode.start(actx.currentTime);

而且,就像我们的第一个例子一样,每次想要播放声音时,您都需要运行所有这些代码。

仅仅演奏一种声音似乎就要做很多工作,不是吗?但这是有原因的:这是因为 Web Audio API 不希望您每次播放声音时都编写所有这些代码。相反,它希望为您提供强大而灵活的细粒度工具,以便您可以从头开始构建任何类型的声音系统。它不是指定一个你应该用来播放声音的 API,而是给了你制作你自己的 API 所需要的构件。

因此,这正是我们接下来要做的:构建一个易于使用和可重用的声音对象,以在游戏中播放音效和音乐。

WEB 音频 API 节点

到目前为止,我们在这些例子中只使用了四个节点:音频源节点(加载的声音文件)、增益节点(音量)、声相器节点和目的节点(扬声器)。)但是 Web Audio API 有丰富的不同节点集合供您使用:

  • DelayNode :创建回声、相位和镶边效果。
  • ConvolverNode :让你模拟一个声学环境,比如大教堂、音箱或者电话扬声器。
  • AnalyserNode :获取关于你声音的数据,帮助你制作类似音乐可视化器或图形均衡器的东西。
  • ChannelSplitterNodeChannelMergerNode:让你捕捉左右立体声信号作为单声道输出,然后,如果你想,将它们重新混合成一个新的立体声信号。
  • DynamicsCompressorNode :将非常安静或非常嘈杂的声音正常化到中等音量水平。
  • 帮助你建立低音、中音和高音均衡器。
  • WaveShaperNode:扭曲声音。
  • OscillatorNode :生成合成音。制作自己的 Moog 合成器!
  • ScriptProcessorNode :如果你需要你的声音做一些内置节点没有涵盖的事情,使用 JavaScript 创建你自己的自定义效果节点。

除了这些节点,Web Audio API 让你设置一个移动的AudioListener 。声音强度和方向将根据收听者在 3D 空间中的位置而变化。您还可以从麦克风或线路输入源采集声音,并将声音文件写入磁盘。有关详细信息,请在http://webaudio.github.io/web-audio-api/查看完整的、编写良好的、可读的网络音频 API 规范。

网络音频声音对象

你希望如何控制游戏中的声音?你应该能够加载声音并播放它们。如果你能暂停它们,重新启动它们,或者从一个特定的位置播放它们,那就太好了。你也应该能够控制音量和设置左,右扬声器平移。在一个完美的世界中,我们能够用简单的属性和方法来控制我们的声音,可能看起来像这样:

sound.load();
sound.play();
sound.pause();
sound.restart();
sound.volume = 0.8;
sound.pan = -0.5;
sound.playFrom(15);

多亏了网络音频 API,这个完美的世界才得以存在。嗯,差不多了…还没有。我们必须先建造它!

我们如何构建这样一个声音对象?我们的 dream API 布局给了你一些线索。声音对象需要名为loadplaypauserestartplayFrom的方法。并且它需要名为volumepan的属性。我们希望能够为游戏中的所有声音制作尽可能多的声音对象。这意味着我们可以把每种声音想象成一种音频精灵。但是音频精灵将播放声音,而不是显示图像。这意味着,我们可以使用我们用于视觉精灵的相同模型,并对其进行调整,使其适用于声音。

在下一节中,我们将创建一个完成所有这些工作的Sound类。正如您将看到的,它只是您已经知道的不同模式的组合。它融合了我们刚刚学到的关于 Web Audio API 的知识和我们所知道的如何使用类来创建对象的知识。唯一真正新的东西是它用来暂停、重启和播放声音的系统。但是我们将在后面详细讨论,以及如何实现这个类来发出新的声音。这里是完整的Sound类。仔细通读一遍,你看完了我在另一边等你!

//Create the audio context
let actx = new AudioContext();

//The sound class
class Sound {
  constructor(source, loadHandler) {

    //Assign the `source` and `loadHandler` values to this object
    this.source = source;
    this.loadHandler = loadHandler;

    //Set the default properties
    this.actx = actx;
    this.volumeNode = this.actx.createGain();
    this.panNode = this.actx.createStereoPanner();
    this.soundNode = null;
    this.buffer = null;
    this.loop = false;
    this.playing = false;

    //Values for the pan and volume getters/setters
    this.panValue = 0;
    this.volumeValue = 1;

    //Values to help track and set the start and pause times
    this.startTime = 0;
    this.startOffset = 0;

    //Load the sound
    this.load();
  }

  //The sound object's methods

  load() {

    //Use xhr to load the sound file
    let xhr = new XMLHttpRequest();
    xhr.open("GET", this.source, true);
    xhr.responseType = "arraybuffer";
    xhr.addEventListener("load", () => {

      //Decode the sound and store a reference to the buffer
      this.actx.decodeAudioData(
        xhr.response,
        buffer => {
          this.buffer = buffer;
          this.hasLoaded = true;

          //This next bit is optional, but important.
          //If you have a load manager in your game, call it here so that
          //the sound is registered as having loaded.
          if (this.loadHandler) {
            this.loadHandler();
          }
        },

        //Throw an error if the sound can't be decoded
        error => {
          throw new Error("Audio could not be decoded: " + error);
        }
      );
    });

    //Send the request to load the file
    xhr.send();
  }

  play() {

    //Set the start time (it will be `0` when the first sound starts)
    this.startTime = this.actx.currentTime;

    //Create a sound node
    this.soundNode = this.actx.createBufferSource();

    //Set the sound node's buffer property to the loaded sound
    this.soundNode.buffer = this.buffer;

    //Connect the sound to the volume, connect the volume to the
    //pan, and connect the pan to the destination
    this.soundNode.connect(this.volumeNode);
    this.volumeNode.connect(this.panNode);
    this.panNode.connect(this.actx.destination);

    //Will the sound loop? This can be `true` or `false`
    this.soundNode.loop = this.loop;

    //Finally, use the `start` method to play the sound.
    //The start time will be either `0`,
    //or a later time if the sound was paused
    this.soundNode.start(
      this.startTime,
      this.startOffset % this.buffer.duration
    );

    //Set `playing` to `true` to help control the
    //`pause` and `restart` methods
    this.playing = true;
  }

  pause() {

    //Pause the sound if it's playing, and calculate the
    //`startOffset` to save the current position
    if (this.playing) {
      this.soundNode.stop(this.actx.currentTime);
      this.startOffset += this.actx.currentTime - this.startTime;
      this.playing = false;
    }
  }

  restart() {

    //Stop the sound if it's playing, reset the start and offset times,
    //then call the `play` method again
    if (this.playing) {
      this.soundNode.stop(this.actx.currentTime);
    }
    this.startOffset = 0,
    this.play();
  }

  playFrom(value) {
    if (this.playing) {
      this.soundNode.stop(this.actx.currentTime);
    }
    this.startOffset = value;
    this.play();
  }

  //Volume and pan getters/setters

  get volume() {
    return this.volumeValue;
  }
  set volume(value) {
    this.volumeNode.gain.value = value;
    this.volumeValue = value;
  }

  get pan() {
    return this.panNode.pan.value;
  }
  set pan(value) {
    this.panNode.pan.value = value;
  }
}

//Create a high-level wrapper to keep our general API style consistent and flexible
function makeSound(source, loadHandler) {
  return new Sound(source, loadHandler);
}

若要使用该类创建 sound 对象,请使用声音的源路径和一个可选的加载处理程序对其进行初始化,该处理程序应在声音完成加载后运行。以下是创建新音乐声音的方法:

let music = makeSound("sounds/music.wav", setupMusic);

声音加载后,setup功能将立即运行。使用它来设置声音的任何属性。然后决定你想如何控制声音。下面是一些使用keyboard函数来监听按键的代码。它可以让你按下“a”键播放声音,“b”键暂停声音,“c”键重启声音,“d”键从 10 秒开始播放。

function setupMusic() {

  //Make the music loop
  music.loop = true;

  //Set the pan
  music.pan = -0.8;

  //Set the volume
  music.volume = 0.3;

  //Capture keyboard key events
  let a = keyboard(65),
      b = keyboard(66),
      c = keyboard(67),
      d = keyboard(68);

  //Use the key `press` methods to control the sound
  //Play the music with the `a` key
  a.press = () => {
    if (!music.playing) music.play();
    console.log("music playing");
  };

  //Pause the music with the `b` key
  b.press = () => {
    music.pause();
    console.log("music paused");
  };

  //Restart the music with the `c` key
  c.press = () => {
    music.restart();
    console.log("music restarted");
  };

  //Play the music from the 10 second mark
  //with the `d` key
  d.press = () => {
    music.playFrom(10);
    console.log("music start point changed");
  };
}

去看(和听!)这段代码在运行中,运行章节的源文件中的soundObject.html文件,如图图 9-2 所示。

图 9-2 。使用一个声音类来加载和控制音乐

从给定时间开始暂停、重启和播放

sound 对象的一个重要特性是声音可以暂停、重新开始和从任何位置播放。AudioContext有一个名为currentTime 的属性,它告诉您从上下文创建的那一刻起的时间,以秒为单位。它就像时钟的秒针,永远在前进。有悖常理的是,它并没有告诉你声音播放的时间。这意味着如果你在 10 秒标记处暂停声音,等待 5 秒,然后从 0 开始重新开始声音,currentTime将是 15。currentTime只是一直向前滴答,直到声音物体被摧毁。

是的,很奇怪。这与视频或音频中的时间码是完全不同的概念。例如,在任何音频应用程序(如 Logic、Audacity 或 Ableton Live)中,如果您停止声音,时间码也会停止。如果您倒转声音,时间码会向后移动以匹配您想要移动到的时间段;如果您前进声音,时间码也会前进到相同的位置。网络音频 API 中的时间则不是这样:它只是不停地向前移动,不能暂停、前进或倒退。但是不要为此担心:这只是网络音频 API 的底层特性,从长远来看,它给了你更多的灵活性。但这也意味着你必须在此基础上构建自己的系统,以便在正确的时间点启动和播放声音。

为了帮助计算时间开始和停止点,使用初始化为零的startTimestartOffset变量:

this.startTime = 0;
this.startOffset = 0;

要暂停声音,首先使用stop方法停止声音。然后通过加上currentTime减去startTime来计算startOffset时间。

pause() {
  if (this.playing) {
    this.soundNode.stop(this.actx.currentTime);
    this.startOffset += this.actx.currentTime - this.startTime;
    this.playing = false;
  }
},

再次播放声音时,捕捉新的startTime:

play() {
  this.startTime = this.actx.currentTime;
  //...

通过将start方法的第一个参数设置为currentTime来播放声音。意思是“现在播放声音”

  //...
  this.soundNode.start(
    this.startTime,                          //1: "play right now" `this.startOffset % this.buffer.duration`  **//2:** `"`**Play the correct section of the sound**`"`
  `);`
  `this.playing = true;`
`},`

第二个参数是要播放的声音文件部分。这是一个简单的计算,指定播放声音文件的哪一部分。该点是通过找到startOffset除以buffer.duration的余数来计算的。(buffer.duration`是载入声音的时间,以秒为单位。)这将使声音从暂停的地方开始播放。

深呼吸!这可能是在 Web 音频 API 中处理时间最复杂的部分,但是我们现在已经克服了它。多亏了这个小小的计算,我们有办法在时间上来回移动音频,并从暂停的地方恢复声音。

注意start方法还有第三个可选参数,即声音播放的持续时间,以秒为单位。例如,如果您有一个 10 秒长的声音,但您只想播放该声音的前 3 秒,则提供持续时间 3。如果你想让那部分声音循环,你必须将sounceNodeloopStart属性设置为 0(声音的开始)和loopEnd属性设置为 3(持续时间的结束时间)。)

restart方法以同样的方式工作。它将startOffset设置为currentTime,这将导致声音再次从头开始播放。

restart() {
  if (this.playing) {
    this.soundNode.stop(this.actx.currentTime);
  }
  this.startOffset = 0,
  this.play();
},

第三个新特性是playFrom方法。这让您可以随时播放声音。以下是从 10 秒钟开始播放音乐的方法:

music.playFrom(10);

它几乎与restart方法相同,但是允许您指定开始播放的时间,以秒为单位。

playFrom(value) {
  if (this.playing) {
    this.soundNode.stop(this.actx.currentTime);
  }
  this.startOffset = value;
  this.play();
},

现在我们有了一个简洁的、可重复使用的声音对象,可以添加到任何游戏中。

注意你会在library/sound.js文件中找到完整的Sound类和makeSound函数。

一个可靠的素材加载器

现在你知道了如何发出声音,你需要一些方法来有效地将声音文件加载到你的游戏程序中。幸运的是,我们已经在第三章中创建了一个通用素材加载器。只需做一些小的修改,我们就可以扩展它来帮助我们加载声音文件,就像它加载字体、图像和 JSON 数据文件一样容易。你会在library/utilities.js文件中找到assets对象——如果你需要快速回顾一下它是如何工作的,请翻回到第三章。

第一步是从library/sound模块导入makeSound方法。将这段代码添加到utilities模块的开头:

import {makeSound} from "../library/sound";

现在找到assets对象并添加一个名为audioExtensions 的属性,这是一个数组,列出了您可能需要加载的各种音频文件的所有文件扩展名:

audioExtensions: ["mp3", "ogg", "wav", "webm"],

然后,在加载每个源的循环中,检查是否有任何源具有这些音频文件扩展名之一。如果是,代码应该调用一个名为loadSound 的新方法:

sources.forEach(source => {
  //...

  else if (this.audioExtensions.indexOf(extension) !== -1) {
    this.loadSound(source, loadHandler);
  }

  //...
});

loadSound方法使用makeSound创建声音对象并加载声音文件。然后,它将声音对象指定为assets对象的属性。它赋予声音对象一个与声音文件名称相匹配的名称。

loadSound(source, loadHandler) {

  //Create a sound object and alert the `loadHandler`
  //when the sound file has loaded
  let sound = makeSound(source, loadHandler);

  //Get the sound file name
  sound.name = source;

  //Assign the sound as a property of the assets object so
  //we can access it this way: `assets["sounds/sound.mp3"]`
  this[sound.name] = sound;
}

这意味着在声音加载后,您可以使用以下语法访问游戏文件中的声音对象:

assets["sounds/soundFileName.mp3"];

你如何在一个真实的游戏程序中使用它?首先,使用assets.load方法加载文件,完成后调用一个setup函数。以下是如何将两个声音文件加载到游戏中的方法:

assets.load([
  "sounds/music.wav",
  "sounds/shoot.wav"
]).then(() => setup());

当然,您也可以列出游戏可能需要的任何其他资源,如图像或 JSON 文件,并同时加载它们。

接下来,在setup函数中,只需使用assets对象来获取您想要使用的已加载声音的引用。然后你可以像使用本章前面例子中的任何其他声音对象一样使用它们。

function setup() {

  //Get references to the loaded sound objects
  let music = assets["sounds/music.wav"],
      shoot = assets["sounds/shoot.wav"];

  //Capture keyboard key events
  let a = keyboard(65),
      b = keyboard(66);

  //Play the music with the `a` key
  a.press = () => {
    if (!music.playing) music.play();
  };

  //Play the shoot sound with the `b` key
  b.press = () => {
    shoot.play();
  };
}

你现在已经有了一个统一一致的界面来加载和使用游戏中的所有资源。

添加效果

为游戏加载和播放声音文件固然很好,但这只是强大的网络音频 API 所能做到的一小部分。现在你已经知道了基础知识,让我们进一步探索,看看我们的好奇心能把我们带到哪里。也许对我们的声音做一些特殊的效果会很好?只需多做一点工作,我们就可以实现这三种效果:

  • 播放速度:让声音以更快或更慢的速度播放。
  • 回声:衰减的回声效果。
  • 混响:模拟声学空间的声音,比如一个大房间或洞穴。

这些效果很容易在我们当前的系统上实现,并且会给你一个更高级的网络音频 API 特性的概述。(运行本章源代码中的specialEffects.html文件,查看该代码的运行示例。)

改变播放速率

让声音以更快或更慢的速度播放是一种有趣且快速的效果。声音缓冲源(我们前面例子中的soundNode对象)有一个名为playbackRate的属性,可以让你改变声音播放的快慢。其默认值为 1,即正常速度。您可以通过将playbackRate设置为 0.5 来使声音以一半的速度播放,或者通过将其设置为 2 来使声音以两倍的速度播放。改变playbackRate不会影响声音的音高(音高是声音的音符频率,即声音的高低)。下面是如何给Sound类添加回放速度特性,让你改变任何声音的速度。

首先,向Sound类的构造函数添加一个playBackrate属性:

this.playbackRate = 1;

将其设置为 1 意味着默认回放速率将是正常速度。

接下来,在调用start方法之前,将下面一行代码添加到Sound类的play方法中。它将声音缓冲源的playBackrate.value设置为Sound类自己的playBackrate值。

this.soundNode.playbackRate.value = this.playbackRate;

最后,在您的游戏程序代码中,使用makeSound方法设置您发出的任何声音的playbackRate属性。下面是如何让一个叫做music的声音以半速播放:

music.playbackRate = 0.5;

如果你想让它播放快一倍,把它设置为 2。现在你有一个简单的方法来控制任何声音的播放速度!

回声

回声是一种你可能想在游戏声音中使用的效果。Web Audio API 没有内置的自动添加回声的方法,但是创建自己的回声系统非常容易。要让它工作,你需要使用一个延迟节点

let delayNode = actx.createDelay();

延迟节点唯一做的事情是在播放声音之前延迟它。如果您想设置半秒钟的延迟,您可以如下操作:

delayNode.delayTime.value = 0.5;

但是简单地将声音延迟半秒并不足以产生回声效果。声音需要延迟,然后重复,每次重复都变得更微弱。为了实现这一点,你需要另一个节点,称为反馈 ,它将使声音随着每次重复逐渐变得安静。feedbackNode只是一个增益节点,和我们用来设置音量的节点是同一类型的。

let feedbackNode = actx.createGain();

如果您希望每次重复时回声的音量降低大约 20 %,请将feedbackNode的值设置为 0.8。

feedbackNode.gain.value = 0.8;

(值为 1 表示音量不会降低,声音会一直重复。将其设置为大于 1 将逐渐增加回声的音量。)

但是我们还没完呢!为了完成所有这些工作,你需要将这些节点连接在一起。首先,通过将延迟发送到反馈来创建一个闭环,然后返回到延迟中。连接路径如下所示:

delay > feedback > delay

这个循环就是产生重复回声效果的原因。每次将延迟的声音发送到反馈时,其音量会降低 20%,因此声音会随着每次重复而逐渐消失。接下来,将延迟节点连接到您的主声音链。将其插入源节点和目标节点之间:

source > delay > destination

结果是,延迟节点从源获得输入,将其发送到反馈回路,然后将产生的回声发送到目的地,以便您可以听到它。

这在实际代码中是什么样子的?让我们稍微简化一下,现在忽略体积和平移节点。下面是创建基本回声效果所需的所有代码。

//Create the delay and feedback nodes
let delayNode = actx.createDelay(),
    feedbackNode = actx.createGain();

//Set their values
delayNode.delayTime.value = 0.2;
feedbackNode.gain.value = 0.8;

//Create the delay feedback loop
delayNode.connect(feedbackNode);
feedbackNode.connect(delayNode);

//Connect the source to the destination to play the first
//instance of the sound at full volume
source.connect(actx.destination);

//Capture the source and send it to the delay loop
//to create the echo effect. Then connect the delay to the
//destination so that you can hear the echo
source.connect(delayNode);
delayNode.connect(actx.destination);

Web Audio API 非常有效地管理声音对象,因此没有内存泄漏的危险。API 运行时(浏览器)将负责销毁不再听得见的声音。这意味着你不需要编写代码来检查和删除音量为零的声音。

这将让你有一个良好的基本回声,但我们还可以做得更多。

更加自然的回声效果

我们当前的回声重复每一个声音,作为原始声音的完美复制,每次重复音量都有所下降。通过稍微改变每个重复声音的音调,您可以赋予回声效果一种更加有机、梦幻的品质。一种简单的方法是在混音中添加一个双二阶滤波器。一个双二阶滤波器只是过滤掉任何高于某个阈值的频率。下面是如何创建一个双二阶滤波器节点,并设置其频率值。

  let filterNode = actx.createBiquadFilter();
  filterNode.frequency.value = 1000;

给滤波器一个 1000 的频率值意味着它将剪切掉 1000 Hz 以上的任何频率。

注意默认情况下,双二阶滤波器是一个低通滤波器,这意味着它允许低于给定阈值的所有频率通过。通过设置过滤器的type属性,您可以更改其过滤行为。type属性可以设置为以下任意字符串值:"lowpass""highpass""bandpass""lowshelf""highshelf""peaking""notch""allpass"

filterNode添加到延迟回路,在反馈和延迟连接之间,如下所示:

delay > feedback > filter > delay

这是包含滤波器的新延迟环路代码:

delayNode.connect(feedbackNode);
feedbackNode.connect(filterNode);
filterNode.connect(delayNode);

你可以通过改变filterNode的频率值来实现各种各样的酷科幻效果——它非常适合幻想或太空游戏。

提示双二阶滤波器还有一个有趣的特性叫做detune,可以让你改变源声音的音高。将它设定为以音分(半音的百分比)为单位的值,以按该量更改音高。一个完整的八度音程(12 个半音)是 1200 美分。

声音类添加回声功能

现在我们知道了如何创建回声效果,让我们更新我们的Sound类,这样我们就可以有选择地对任何声音应用回声。首先,在构造函数中创建我们需要的新节点:

this.delayNode = this.actx.createDelay();
this.feedbackNode = this.actx.createGain();
this.filterNode = this.actx.createBiquadFilter();

然后创建一些属性,我们可以使用它们来定制声音对象的效果:

this.echo = false;
this.delayValue = 0.3;
this.feedbackValue = 0.3;
this.filterValue = 0;

让我们也创建一个名为setEcho 的方法,它将让我们设置延迟时间、反馈时间和效果的可选过滤。

setEcho(delayValue = 0.3, feedbackValue = 0.3, filterValue = 0) {
  this.delayValue = delayValue;
  this.feedbackValue = feedbackValue;
  this.filterValue = filterValue;
  this.echo = true;
}

我们可以使用echo ( truefalse)的值来打开或关闭回声效果。为此,让我们将 echo 代码添加到Sound类的 play 方法中。所有新代码都突出显示。

play() {
  this.startTime = this.actx.currentTime;
  this.soundNode = this.actx.createBufferSource();
  this.soundNode.buffer = this.buffer;

  //Create the main node chain
  this.soundNode.connect(this.volumeNode);
  this.volumeNode.connect(this.panNode);
  this.panNode.connect(this.actx.destination);

  //Add optional echo
  if (this.echo) {

    //Set the values
    this.feedbackNode.gain.value = this.feedbackValue;
    this.delayNode.delayTime.value = this.delayValue;
    this.filterNode.frequency.value = this.filterValue;

    //Create the delay loop, with optional filtering
    this.delayNode.connect(this.feedbackNode);
    if (this.filterValue > 0) {
      this.feedbackNode.connect(this.filterNode);
      this.filterNode.connect(this.delayNode);
    } else {
      this.feedbackNode.connect(this.delayNode);
    }

    //Capture the sound from the main node chain, send it to the
    //delay loop, and send the final echo effect to the `panNode`, which
    //will then route it to the destination
    this.volumeNode.connect(this.delayNode);
    this.delayNode.connect(this.panNode);
  }

  this.soundNode.loop = this.loop;
  this.soundNode.playbackRate.value = this.playbackRate;
  this.soundNode.start(
    this.startTime,
    this.startOffset % this.buffer.duration
  );
  this.playing = true;
}

现在要创建任何声音的回声效果,使用声音的setEcho方法。提供设置延迟时间、反馈时间所需的值,如果要使用双二阶滤波器,还可以选择提供要滤波的频率上限。

let bounce = assets["sounds/bounce.mp3"];
bounce.setEcho(0.2, 0.5, 1000);

如果您需要在某个时候关闭回声效果,只需将声音的echo属性设置为false:

bounce.echo = false;

通过改变这些值,你可以为你的游戏创造出各种各样的回声效果。

混响

混响是一种模拟声音空间的效果,如房间、大教堂或空间洞穴。这是我们将要创建的最复杂的效果,它将让你更深入地了解网络音频 API 的一些更高级的工作方式。在我们讨论具体细节之前,让我们暂时停止编码,尝试一些理论知识,这样你就可以为即将到来的事情做好充分的准备。

那么什么是缓冲呢?

在这一章中,我一直在反复使用“缓冲”这个词。我之前提到过它是“原始音频文件”,但它实际上不止于此。您可以将缓冲区视为存储二进制数据的数组。数据是代表声音的 1 和 0。每个声音片段被称为一个样本。萨姆样本相当于图像中的像素,所以我喜欢把样本想象成“音频像素”这意味着您可以将缓冲区视为一个数组,其中的每个元素代表组成每段声音的最小单元。

缓冲器还包含通道。您可以将每个通道视为一个单独的数组,包含自己的声音样本。如果您有一个带两个通道的缓冲器,第一个通道可能包含左扬声器的样本,第二个通道可能包含右扬声器的样本。您可以像这样想象缓冲区:

buffer = [
  [l0, l1, l2], //channel one sample data for the left speaker
  [r0, r1, r2]  //channel two sample data for the right speaker
];

所以缓冲区有点像多维数组,每个通道代表一个子数组。数组中的每个索引位置称为一个样本- 。该示例缓冲器包含三个样本帧:l0r0都在样本帧 0 上;l2r2都在样品架 2 上。占据相同样本帧的样本将在相同的时间点播放。它们就像一条录音带上的独立音轨。单个缓冲区可以包含多达 32 个声道的音频数据。

单声道的声音,在两个扬声器中是一样的,只使用一个声道。杜比 5.1 环绕声使用 5 个声道。四声道音响使用 4。

您可以随时使用 Web Audio API 的createBuffer 方法创建一个空的声音缓冲区。它有三个参数:通道数、样本帧中缓冲区的长度和采样率。

let emptyBuffer = actx.createBuffer(numberOfChannels, length, sampleRate);

通常你只需要两个声道,一个用于左扬声器,一个用于右扬声器。length定义了缓冲器有多少样本帧。sampleRate 是每秒播放的样本帧数。采样速率会影响声音的分辨率,采样速率越高,音频质量就越高。采样率以赫兹(Hz)为单位,必须在 22050 到 96000 的范围内。Web Audio API 的默认采样率通常是 44.1kHz,但这取决于运行代码的设备。如果像刚才看到的例子那样初始化一个空的缓冲区,通道数据将用零填充,表示静音。

提示你可以通过将帧数除以采样率,以秒为单位计算出声音缓冲区有多长。

我把缓冲区作为一个“数组”来帮助你形象化它,但这并不是它的确切含义。是的,我又说谎了!它实际上是一种叫做ArrayBuffer 的“类数组”数据类型。一个ArrayBuffer 只是一个二进制数据的 JavaScript 存储容器。然而,在缓冲区的getChannelData方法的帮助下,您可以将一个ArrayBuffer转换成一个真实的、可用的数组。以下是如何使用它将左右扬声器通道数据转换为数组:

let left = buffer.getChannelData(0),
    right = buffer.getChannelData(1);

leftright现在是用音频数据打包的普通数组。(0 代表左声道,1 代表右声道。)您可以像处理任何普通数组一样处理它们。它们实际上是一种特殊的高分辨率阵列,叫做Float32Array 。但是不要为此担心——只要把它们想象成普通的数组,它们对于存储和访问二进制数据特别有效。

注意 Float32 数组也用于 WebGL 图形渲染。

最棒的是,你可以通过改变数组中的通道数据来改变缓冲区的声音。这也意味着你可以从纯代码中程序化地创造声音。只需使用一种算法来产生您想要的声音数据,并将其推入通道数据数组。这就是我们接下来要做的。

模拟声音混响

现在让我们回到混响!创造可信混响的诀窍是将两种声音结合在一起。第一个声音是你的原声,没有混响。第二种是在您想要模拟的声学空间中的中性声音(白噪音)的特殊记录:例如,房间、洞穴或剧院。这些特殊的记录被称为脉冲响应记录。然后你用一种叫做卷积器的音频处理器将这两种声音混合在一起。卷积器获取您的原始声音,将其与脉冲响应录音进行比较,并将两种声音组合在一起。结果是逼真的混响,听起来像你试图模拟的空间。

但是你从哪里得到模拟混响的脉冲响应声音呢?有数千种专业录制的脉冲响应录音可供使用,这些录音模拟了从吉他放大器音箱到电话扬声器,再到历史悠久的大教堂的各种声音。你也可以创造你自己的:只需带一个录音机到一个废弃的发电厂、工厂或精神病院,用手枪在空中开几枪。最好在凌晨 3 点做这件事。你会得到一个很好的声学空间的录音,你可以用卷积器,并在这个过程中有很多乐趣。

或者,如果您对吸引警察的注意力有所顾虑,您可以通过几行代码生成可配置的脉冲响应。下面列出的impulseResponse函数就是这样做的。它创建一个有两个通道的空缓冲区,并用随机噪声填充每个通道。一个简单的公式就能让噪音呈指数衰减,就像声音从房间的墙壁反射回来时自然衰减一样。您可以设定混响时间和衰减量,以模拟各种空间。定义脉冲响应的是指数衰减(不是白噪声),因此也是你的声学空间的表观大小。短暂的衰减造成声音发生在小空间的错觉,较长的衰减模拟更大的空间。impulseResponse功能也有一个reverse参数,如果true出现,就会产生一种怪异的反混响效果。

function impulseResponse(duration = 2, decay = 2, reverse = false) {

  //The length of the buffer
  //(The AudioContext's default sample rate is 44100)
  let length = actx.sampleRate * duration;

  //Create an audio buffer (an empty sound container) to store the reverb effect
  let impulse = actx.createBuffer(2, length, actx.sampleRate);

  //Use `getChannelData` to initialize empty arrays to store sound data for
  //the left and right channels
  let left = impulse.getChannelData(0),
      right = impulse.getChannelData(1);

  //Loop through each sample-frame and fill the channel
  //data with random noise
  for (let i = 0; i < length; i++){

    //Apply the reverse effect, if `reverse` is `true`
    let n;
    if (reverse) {
      n = length - i;
    } else {
      n = i;
    }

    //Fill the left and right channels with random white noise that
    //decays exponentially
    left[i] = (Math.random() * 2 - 1) * Math.pow(1 - n / length, decay);
    right[i] = (Math.random() * 2 - 1) * Math.pow(1 - n / length, decay);
  }

  //Return the `impulse`
  return impulse;
}

impulseResponse函数返回一个缓冲区,它是我们想要应用到声音中的混响效果的模型。但是我们实际上如何使用它呢?

首先,创建一个卷积器节点。这是专门的音频处理器,将普通声音与脉冲响应混合在一起,以创建最终的混响效果。

let convolverNode = actx.createConvolver();

然后将脉冲响应设置为卷积器自己的buffer

convolverNode.buffer = impulseResponse(2, 2, false);

最后,将卷积器节点连接到您的声音链。

soundNode.connect(convolverNode);
convolverNode.connect(destination);

当声音通过卷积器时,它会将脉冲响应混合到声音中,从而产生逼真的混响效果。

声音类添加混响功能

现在让我们更新我们的Sound类来添加一个可重用的混响特性,我们可以在任何声音上启用它。首先,在构造函数中创建卷积器节点,以及一些帮助我们控制效果的属性。

this.convolverNode = this.actx.createConvolver();
this.reverb = false;
this.reverbImpulse = null;

接下来,创建一个setReverb方法,让我们可以轻松地将混响应用到任何声音中。

setReverb(duration = 2, decay = 2, reverse = false) {
  this.reverbImpulse = impulseResponse(duration, decay, reverse);
  this.reverb = true;
}

然后,在play方法中,在音量和声相节点之间连接卷积器,并将脉冲响应应用于卷积器的缓冲器。如果reverb设置为false,效果将被旁路。这里是Sound类的play方法的第一部分,突出显示了所有新代码。

play() {
  this.startTime = this.actx.currentTime;
  this.soundNode = this.actx.createBufferSource();
  this.soundNode.buffer = this.buffer;

  //Connect all the nodes
  this.soundNode.connect(this.volumeNode);

  //If there's no reverb, bypass the convolverNode
  if (this.reverb === false) {
    this.volumeNode.connect(this.panNode);
  }

  //If there is reverb, connect the `convolverNode` and apply
  //the impulse response
  else {
    this.volumeNode.connect(this.convolverNode);
    this.convolverNode.connect(this.panNode);
    this.convolverNode.buffer = this.reverbImpulse;
  }

  this.panNode.connect(this.actx.destination);

  //... the rest of the `play` method is the same
}

现在,您可以使用setReverb方法将定制的混响应用到任何声音,语法如下:

let music = assets["sounds/music.wav"];
music.setReverb(2, 5, false);

如果您稍后需要关闭混响,请将声音的reverb属性设置为false:

music.reverb = false;

尝试不同的持续时间和延迟设置,你将能够产生各种各样的效果。如果是万圣节,就把reverse参数设为true

提示运行章节源文件中的specialEffects.html文件,体验所有这些新特性。请务必查看library/Sound模块中的完整的Sound类,以查看完整上下文中的所有代码。

合成声音

到目前为止,在这一章中,我们已经加载和控制的所有声音都是预先录制的音频文件。但是网络音频 API 也可以让你创造全新的声音,用一个多功能的振荡器节点。振荡器以你选择的任何音高产生音调。它也有一堆有用的属性,你可以设置来塑造这种基调。您可以将振荡器连接到 Web Audio API 的任何其他节点,如延迟节点或卷积器,为游戏创建几乎无限丰富的声音频谱。在本章的最后一节,我将首先向您介绍创建和使用振荡器的基础,然后我们将构建一个简单的SoundEffect类,您可以使用它作为构建各种不同游戏声音的基础。

用振荡器制作和播放声音非常容易。下面是你需要的最基本的代码:

//Create the audio context
let actx = new AudioContext();

//Create a new oscillator
let oscillator = actx.createOscillator();

//Connect it to the destination
oscillator.connect(actx.destination);

//Make it play
oscillator.start(actx.currentTime);

(使用stop方法停止振荡器。)

如果您运行这段代码,它会产生一个默认为 200 Hz 的音调(这是一个稍高的 g 音),您可以通过设置它的frequency.value属性来更改振荡器播放的音符。下面是如何让它播放中 A (440 Hz):

oscillator.frequency.value = 440;

振荡器还有一个detune属性 ,它是一个以分为单位的值,用来抵消频率。

您可以通过设置其type属性来更改振荡器音调所基于的基本波形模式,该属性是一个可以设置为"sine""triangle""square""sawtooth"的字符串。

oscillator.type = "sawtooth";

每种波形类型 都会产生越来越刺耳的音调。如果您想要一个真正平滑、类似钟声的音调,请使用"sine"。如果你仍然想要一个平滑的音调,但带有一点砂砾感,试试"triangle""square"开始发出有点刺耳的声音,其中"sawtooth"发出的声音最为刺耳。

注意这四种基本的波形类型可能就是你为游戏生成大多数声音所需要的全部。但是您可以借助createPeriodicWavesetPeriodicWave方法创建自己的定制波形。你可以将它们与称为傅立叶变换 的特殊数据阵列一起使用,来模拟各种各样的音调,比如不同的乐器。有关更多详细信息,请参见 Web Audio API 规范。

为了向您展示使用振荡器制作真正有用的东西是多么的简单,让我们把您的电脑变成一件乐器。

制作音乐

我们将创建一个迷你应用程序,让您使用键盘的数字键弹奏五个音符。我们将从一个叫做playNote 的可重用函数开始,它可以让你弹奏任何音符值。它会创建并播放一个音符,听起来就像你在摆弄过的电子键盘上按下任何一个键时所期待的一样。您可以设置音符的值(以赫兹为单位的频率)、波形类型及其decaydecay值决定音符从最大音量渐隐到静音的时长。playNote函数基本上只是我们刚才看到的代码的可重用包装器:它创建振荡器和音量节点,将它们连接到目的地,并使用振荡器的值来播放声音。(这段代码中唯一的新东西是用于淡出音符的技术——但我将在后面解释它是如何工作的。)

function playNote(frequency, decay = 1, type = "sine") {

  //Create an oscillator and a gain node, and connect them
  //together to the destination
  let oscillator = actx.createOscillator(),
      volume = actx.createGain();

  oscillator.connect(volume);
  volume.connect(actx.destination);

  //Set the oscillator's wave form pattern
  oscillator.type = type;

  //Set the note value
  oscillator.frequency.value = frequency;

  //Fade the sound out
  volume.gain.linearRampToValueAtTime(1, actx.currentTime);
  volume.gain.linearRampToValueAtTime(0, actx.currentTime + decay);

  //Make it play
  oscillator.start(actx.currentTime)
}

您可以使用playNote以任何频率弹奏音符,如下所示:

playNote(440, 2, "square");

淡出效果

第二个参数decay决定音符淡出时可以听到多长时间。淡出效果 是使用这两行代码创建的:

volume.gain.linearRampToValueAtTime(1, actx.currentTime);
volume.gain.linearRampToValueAtTime(0, actx.currentTime + decay);

linearRampToValueAtTime是一个非常有用的内置函数,允许您随时更改任何节点值。在本例中,它将音量的值从最大音量(1)更改为静音(0 ),时间从currentTime开始,到decay设置的值结束。你可以看到你需要使用linearRampToValueAtTime两次来创建完整的淡出效果。第一次使用设置开始音量和开始时间。第二个设置其结束音量和结束时间。Web Audio API 的引擎会自动为您插入所有中间值,并为您提供完全平滑的音量过渡。

以下是可用于随时更改任何节点值的基本格式:

nodeProperty.linearRampToValueAtTime(startValue, startTime);
nodeProperty.linearRampToValueAtTime(endValue, endTime);

它适用于任何节点值,包括频率,因此您可以使用linearRampToValueAtTime为游戏创建各种不同的效果。

linearRampToValueAtTime线性改变一个值:以均匀、渐进的方式。如果您希望该值呈指数变化,请使用exponentialRampToValueAtTime。指数变化逐渐开始,然后迅速下降。自然界中的许多声音在数值上有指数变化。

播放音符

剩下唯一要做的事情就是将playNote函数与某种事件挂钩。下面是捕获 1 到 5 数字键的键盘事件的代码。然后,当按下:D 时,它会以正确的音符频率调用playNote函数,例如,G、A 或 c。(如果你很好奇,你只需要这五个音符来演奏一首古印度古典 raga,名为 megh ,意思是。它们以任何组合放在一起听起来都不错。)

//Capture keyboard events for the number keys 1 to 5
let one = keyboard(49),
    two = keyboard(50),
    three = keyboard(51),
    four = keyboard(52),
    five = keyboard(53);

//Define the note values
let D = 293.66,
    E = 329.63,
    G = 392.00,
    A = 440.00,
    C = 523.25;

//D
one.press = () => {
  playNote(D, 1);
};

//E
two.press = () => {
  playNote(E, 1);
}

//G
three.press = () => {
  playNote(G, 1);
}

//A
four.press = () => {
  playNote(A, 1)
}

//C
five.press = () => {
  playNote(C, 1);
}

提示在网上快速搜索会出现许多图表,向你展示如何将赫兹频率值转换成真实的音符值。

几乎没有任何麻烦,你已经把你的电脑键盘变成了一种乐器!你可以轻松地在游戏中加入音乐效果。在一个平台游戏中,当一个角色跳到积木上时,制作积木或者一个简单的随机音乐生成器怎么样?玩家一边探索游戏世界一边作曲的游戏怎么样?你现在离进入一个全新的基于音乐的游戏只有几步之遥,所以去吧!

而这还差不多!振荡器真的没有什么复杂的——当你开始以富有想象力的方式将它们与我们在本章中使用的其他节点连接起来时,真正的乐趣就开始了。通过一点点的实验,你很快就会意识到你已经有了一个完整的音乐和音效合成器,它几乎有无限的潜力来创造你可能需要的任何游戏声音。

真的吗?是的,让我们来看看怎么做!

产生声音效果

想象一下:一个单一的、可重复使用的函数,在不到 150 行代码中,可以生成你可能需要的任何游戏音效或音符,而不必下载任何声音文件。多亏了 Web Audio API,这片乐土才成为可能,这也正是我们下一步要做的:一个通用的音效生成器,可以产生你在游戏中可能需要的几乎任何声音。

我们将要构建的音效函数被称为soundEffect。它有 13 个低级参数,你可以设置它们来创造大量有用的音调。在我们研究这个函数如何发挥其魔力的所有细节之前,让我们先来看看如何以一种实用的方式使用它。这里有一个使用它的模型,包括每个参数的功能描述。

soundEffect(
  frequencyValue,  //The sound's frequency pitch in Hertz
  attack,          //The time, in seconds, to fade the sound in
  decay,           //The time, in seconds, to fade the sound out
  type,            //waveform type: "sine", "triangle", "square", or "sawtooth"
  volumeValue,     //The sound's maximum volume
  panValue,        //The speaker pan. left: -1, middle: 0, right: 1
  wait,            //The time, in seconds, to wait before playing the sound
  pitchBendAmount, //A frequency amount, in Hz, to bend the sound's pitch down
  reverse,         //If `reverse` is true the pitch will bend up
  randomValue,     //A range, in Hz., within which to randomize the pitch
  dissonance,      //A value in Hz. Creates 2 additional dissonant frequencies
  echo,            //An array: [delayTime, feedbackTime, filterValue]
  reverb           //An array: [duration, decayRate, reverse?]
);

使用这个soundEffect函数的策略是修改所有这些参数,并为游戏创建你自己的自定义音效库。你可以把它想象成一个巨大的共鸣板,上面有 13 个彩色闪烁的转盘,你可以尽情玩耍。想象你是一个疯狂的科学家,而 13 是你的幸运数字!

要了解如何设置这些参数来创建您想要的声音,让我们尝试使用soundEffect来产生四种多功能的游戏声音:激光射击声音、跳跃声音、爆炸声音和音乐主题。(运行本章源文件中的soundEffects.html文件,获得该代码的工作示例,如图图 9-3 所示。)

图 9-3 。从纯代码中生成自定义声音效果

射击声

以下是如何使用soundEffect功能创建典型激光拍摄声音的示例:

function shootSound() {
  soundEffect(
    1046.5,           //frequency
    0,                //attack
    0.3,              //decay
    "sawtooth",       //waveform
    1,                //Volume
    -0.8,             //pan
    0,                //wait before playing
    1200,             //pitch bend amount
    false,            //reverse bend
    0,                //random frequency range
    25,               //dissonance
    [0.2, 0.2, 2000], //echo array: [delay, feedback, filter]
    undefined         //reverb array: [duration, decay, reverse?]
  );
}

*“锯齿”*波形设置赋予声音一种刺骨的刺耳感。pitchBendAmount是 1200,这意味着声音的频率从头到尾下降了 1200 Hz。这听起来就像你看过的所有科幻电影中的每一束激光。dissonance值为 25 意味着在主频上下 25 Hz 的声音中增加了两个额外的泛音。这些额外的泛音增加了音调的复杂性。

因为soundEffect函数被包装在一个自定义的shootSound函数中,所以您可以随时在您的应用程序代码中播放该效果,如下所示:

shootSound();

它会立即播放。

跳跃的声音

让我们看另一个例子。这里有一个 jumpSound 函数产生一个典型的平台游戏——角色跳跃的声音。

function jumpSound() {
  soundEffect(
    523.25,       //frequency
    0.05,         //attack
    0.2,          //decay
    "sine",       //waveform
    3,            //volume
    0.8,          //pan
    0,            //wait before playing
    600,          //pitch bend amount
    true,         //reverse
    100,          //random pitch range
    0,            //dissonance
    undefined,    //echo array: [delay, feedback, filter]
    undefined     //reverb array: [duration, decay, reverse?]
  );
}

jumpSoundattack值为 0.05,这意味着声音会快速淡入。它太快了,你真的听不到,但它巧妙地柔化了声音的开始。reverse值为true,表示音高向上弯曲而不是向下。(这是有道理的,因为跳字是向上跳的。)的randomValue是 100。这意味着音高将在目标频率周围 100 Hz 的范围内随机变化,因此声音的音高每次都会略有不同。这增加了声音的趣味性,让游戏世界充满活力。

爆炸的声音

只需调整相同的参数,您就可以创建完全不同的explosionSound效果:

function explosionSound() {
  soundEffect(
    16,          //frequency
    0,           //attack
    1,           //decay
    "sawtooth",  //waveform
    1,           //volume
    0,           //pan
    0,           //wait before playing
    0,           //pitch bend amount
    false,       //reverse
    0,           //random pitch range
    50,          //dissonance
    undefined,   //echo array: [delay, feedback, filter]
    undefined    //reverb array: [duration, decay, reverse?]
  );
}

这会产生低频隆隆声。爆音的起点是将frequency值设置得极低:16 Hz。它还有一个粗糙的"sawtooth"波形。但是让它真正起作用的是 50 的dissonance值。这增加了两个泛音,高于和低于目标频率 50 Hz,它们相互干扰并干扰主声音。

音乐主题

但不仅仅是为了音效!您可以使用soundEffect功能创建音符,并以设定的间隔播放它们。这里有一个名为bonusSound的功能,它以升调顺序播放三个音符(D、A 和高音 D)。这是典型的音乐主题,当游戏角色获得一些奖励点数时,你可能会听到,比如捡星星或硬币。(听到这个声音,你可能会闪回 1985 年!)

function bonusSound() {

  //D
  soundEffect(587.33, 0, 0.2, "square", 1, 0, 0);

  //A
  soundEffect(880, 0, 0.2, "square", 1, 0, 0.1);

  //High D
  soundEffect(1174.66, 0, 0.3, "square", 1, 0, 0.2);
}

让它工作的关键是最后一个参数:值wait(在刚刚显示的代码中突出显示)。第一个声音的wait值为 0,这意味着声音将立即播放。第二个声音的wait值是 0.1,这意味着它将在延迟 100 毫秒后播放。最后一个声音的wait值为 0.2,会让它在 200 毫秒内播放。这意味着所有三个音符按顺序播放,它们之间有 100 毫秒的间隔。

只需多做一点工作,您就可以使用wait参数来构建一个简单的音乐音序器,并构建您自己的音乐音效迷你库来演奏音符。

完整的soundEffect功能

这里是完整的soundEffect函数,带有解释其工作原理的注释。正如你将看到的,这是你在这一章中学到的所有技术的混合。(你会在library/sound.js文件中找到这个soundEffect函数。)

function soundEffect(
  frequencyValue,
  attack = 0,
  decay = 1,
  type = "sine",
  volumeValue = 1,
  panValue = 0,
  wait = 0,
  pitchBendAmount = 0,
  reverse = false,
  randomValue = 0,
  dissonance = 0,
  echo = undefined,
  reverb = undefined
) {

  //Create oscillator, gain and pan nodes, and connect them
  //together to the destination
  let oscillator = actx.createOscillator(),
      volume = actx.createGain(),
      pan = actx.createStereoPanner();

  oscillator.connect(volume);
  volume.connect(pan);
  pan.connect(actx.destination);

  //Set the supplied values
  volume.gain.value = volumeValue;
  pan.pan.value = panValue;
  oscillator.type = type;

  //Optionally randomize the pitch. If the `randomValue` is greater
  //than zero, a random pitch is selected that's within the range
  //specified by `frequencyValue`. The random pitch will be either
  //above or below the target frequency.
  let frequency;
  let randomInt = (min, max) => {
   return Math.floor(Math.random() * (max - min+ 1)) + min;
  }
  if (randomValue > 0) {
    frequency = randomInt(
      frequencyValue - randomValue / 2,
      frequencyValue + randomValue / 2
    );
  } else {
    frequency = frequencyValue;
  }
  oscillator.frequency.value = frequency;

  //Apply effects
  if (attack > 0) fadeIn(volume);
  if (decay > 0) fadeOut(volume);
  if (pitchBendAmount > 0) pitchBend(oscillator);
  if (echo) addEcho(volume);
  if (reverb) addReverb(volume);
  if (dissonance > 0) addDissonance();

  //Play the sound
  play(oscillator);

  //The helper functions:

  //Reverb
  function addReverb(volumeNode) {
    let convolver = actx.createConvolver();
    convolver.buffer = impulseResponse(reverb[0], reverb[1], reverb[2]);
    volumeNode.connect(convolver);
    convolver.connect(pan);
  }

  //Echo
  function addEcho(volumeNode) {

    //Create the nodes
    let feedback = actx.createGain(),
        delay = actx.createDelay(),
        filter = actx.createBiquadFilter();

    //Set their values (delay time, feedback time, and filter frequency)
    delay.delayTime.value = echo[0];
    feedback.gain.value = echo[1];
    if (echo[2]) filter.frequency.value = echo[2];

    //Create the delay feedback loop, with
    //optional filtering
    delay.connect(feedback);
    if (echo[2]) {
      feedback.connect(filter);
      filter.connect(delay);
    } else {
      feedback.connect(delay);
    }

    //Connect the delay loop to the oscillator's volume
    //node, and then to the destination
    volumeNode.connect(delay);

    //Connect the delay loop to the main sound chain's
    //pan node, so that the echo effect is directed to
    //the correct speaker
    delay.connect(pan);
  }

  //Fade in (the sound's "attack")
  function fadeIn(volumeNode) {

    //Set the volume to 0 so that you can fade in from silence
    volumeNode.gain.value = 0;

    volumeNode.gain.linearRampToValueAtTime(
      0, actx.currentTime + wait
    );
    volumeNode.gain.linearRampToValueAtTime(
      volumeValue, actx.currentTime + wait + attack
    );
  }

  //Fade out (the sound’s "decay")
  function fadeOut(volumeNode) {
    volumeNode.gain.linearRampToValueAtTime(
      volumeValue, actx.currentTime + attack + wait
    );
    volumeNode.gain.linearRampToValueAtTime(
      0, actx.currentTime + wait + attack + decay
    );
  }

  //Pitch bend.
  //Uses `linearRampToValueAtTime` to bend the sound's frequency up or down
  function pitchBend(oscillatorNode) {

    //Get the frequency of the current oscillator
    let frequency = oscillatorNode.frequency.value;

    //If `reverse` is true, make the sound drop in pitch.
    //(Useful for shooting sounds)
    if (!reverse) {
      oscillatorNode.frequency.linearRampToValueAtTime(
        frequency,
        actx.currentTime + wait
      );
      oscillatorNode.frequency.linearRampToValueAtTime(
        frequency - pitchBendAmount,
        actx.currentTime + wait + attack + decay
      );
    }

    //If `reverse` is false, make the note rise in pitch.
    //(Useful for jumping sounds)
    else {
      oscillatorNode.frequency.linearRampToValueAtTime(
        frequency,
        actx.currentTime + wait
      );
      oscillatorNode.frequency.linearRampToValueAtTime(
        frequency + pitchBendAmount,
        actx.currentTime + wait + attack + decay
      );
    }
  }

  //Dissonance
  function addDissonance() {

    //Create two more oscillators and gain nodes
    let d1 = actx.createOscillator(),
        d2 = actx.createOscillator(),
        d1Volume = actx.createGain(),
        d2Volume = actx.createGain();

    //Set the volume to the `volumeValue`
    d1Volume.gain.value = volumeValue;
    d2Volume.gain.value = volumeValue;

    //Connect the oscillators to the gain and destination nodes
    d1.connect(d1Volume);
    d1Volume.connect(actx.destination);
    d2.connect(d2Volume);
    d2Volume.connect(actx.destination);

    //Set the waveform to "sawtooth" for a harsh effect
    d1.type = "sawtooth";
    d2.type = "sawtooth";

    //Make the two oscillators play at frequencies above and
    //below the main sound's frequency. Use whatever value was
    //supplied by the `dissonance` argument
    d1.frequency.value = frequency + dissonance;
    d2.frequency.value = frequency - dissonance;

    //Apply effects to the gain and oscillator
    //nodes to match the effects on the main sound
    if (attack > 0) {
      fadeIn(d1Volume);
      fadeIn(d2Volume);
    }
    if (decay > 0) {
      fadeOut(d1Volume);
      fadeOut(d2Volume);
    }
    if (pitchBendAmount > 0) {
      pitchBend(d1);
      pitchBend(d2);
    }
    if (echo) {
      addEcho(d1Volume);
      addEcho(d2Volume);
    }
    if (reverb) {
      addReverb(d1Volume);
      addReverb(d2Volume);
    }

    //Play the sounds
    play(d1);
    play(d2);
  }

  //The `play` function that starts the oscillators
  function play(oscillatorNode) {
    oscillatorNode.start(actx.currentTime + wait);
  }
}

总结

您现在有了一套强大的新工具来为游戏添加声音。您已经学习了使用 Web Audio API 加载和播放声音的所有基础知识,并且有了一个有用的Sound类,您可以将它用于游戏中的所有音乐和声音效果。您还学习了如何使用assets对象加载和管理声音,以及如何使用通用的soundEffect函数从头开始生成声音。

但是你如何在游戏中使用声音呢?你将在第十一章中学习如何做。但是在你这么做之前,让我们看看你需要知道的一个更重要的工具来完成你的游戏设计工具包:补间。`

十、补间

补间是一种动画技术,你可以用它来让精灵以非常特殊的方式改变它的外观或位置。您可以使用补间使精灵沿着固定的路径或曲线移动,或者使精灵淡入、淡出、脉动或摆动。使用补间效果将会给你的游戏增加一个全新的互动维度和参与度,让它们以新的和令人兴奋的方式变得生动起来。

补间与你在本书中学习的其他移动精灵的方法有什么不同?在第五章中,你学习了如何使用速度和物理属性让精灵移动。这对于制作像弹跳球这样需要在每一帧对不断变化的游戏环境做出反应的东西来说是非常棒的。但有时你只是想告诉你的小精灵们“去那里,然后回来,永远重复这样的话。”补间就像给你的精灵一个可预测的、固定的、不变的运动脚本,它不受游戏物理特性的影响。就像一列在轨道上行驶的火车;它总是沿着相同的路线,在相同的时间停在每个车站。补间对于处理游戏动画中一些比较繁琐的方面特别有用,比如制作用户界面动画。它非常适合制作标题和按钮滑动或淡入淡出,也适合创建游戏场景之间的过渡。一般来说,当你想要实现一个快速运动特效的时候,你可以使用基于物理的运动来制作你的主精灵动画。

单词 tween 来自“在中间”动画师用这个词来描述动画对象在开始点和结束点之间的位置。如果您知道点 A 和点 B,并且您知道对象在这两点之间移动需要多长时间,您可以使用补间来计算所有这些中间点的位置。

提示这听起来耳熟吗?是的,它是!在第五章中,你学习了插值的概念——补间和插值是一回事。当你和你的编程朋友聊天时使用“插值”,和你的动画师朋友聊天时使用“补间”。

在这一章中,你将深入了解如何从头开始实现游戏的补间技术,包括:

  • 用于补间任何 sprite 属性(或任何其他值)的低级公式和过程。
  • 缓和:随着时间的推移,逐渐使精灵加速或减速到特定的目的地。
  • 运动路径:使精灵沿直线或曲线路径移动。

您还将学习如何构建一些有用的可重用组件,让您可以轻松地将缓动效果应用到游戏中的任何精灵。

缓动和插值

缓动是一种补间效果,可创建从一种状态或位置到另一种状态或位置的平滑过渡。假设您有一个精灵,您想在 60 帧(一秒钟)的时间内从画布的左侧到右侧制作动画。它应该慢慢开始,逐渐加速,然后减速到停止。图 10-1 说明了你想要达到的效果。

图 10-1 。使用缓动来使精灵的位置变得平滑

如果你只知道精灵的startValue (32),它的endValue (400),和动画应该采取的totalTime (60 帧),你怎么能算出中间的位置呢?

诀窍是将精灵移动所花费的时间转换成一个 0 到 1 之间的数字。这被称为归一化时间。你可以用这个简单的公式算出来:

normalizedTime = currentTime / totalTime;

normalizedTime是一个神奇的数字。您可以使用它来创建一整套缓动功能,以产生各种不同的效果。你所需要做的就是获取normalizedTime值,将其放入一个专门的缓动函数中,并将结果应用回精灵的位置。在前面的页面中,您将了解到许多这样的缓解功能——但是让我们快速地用一个实际的例子来说明。

应用缓动功能

你需要知道的最有用的缓和函数叫做平滑步骤。如果你想让一个精灵以自然的方式加速和减速,smoothstep正在等待你的命令。就是这个:

smoothstep = x => x * x * (3 - 2 * x);

不要让它吓到你!这只是一个普通的函数,它接受一个参数x,对其应用一些数学运算,然后返回结果。你将要学习的所有缓动函数都将遵循相同的格式。

smoothstep函数中的数学是做什么的?它只是描绘了一条曲线。图 10-2 显示了这条曲线的样子。

图 10-2 。smoothstep功能描述了一条令人愉快的曲线

如果您将该曲线应用到normalizedTime,您可以控制时间的流动以匹配曲线的形状。下面是如何做到这一点:

curvedTime = smoothstep(normalizedTime);

有了这个声明,时间将开始变慢,在中途加速,然后在接近结束时又变慢。就像水呈现出倒入其中的容器的形状一样,时间呈现出你使用的任何缓和函数的形状。

当你有了曲线时间值,你可以用它来插值精灵的 x 位置,计算如下:

sprite.x = (endValue * curvedTime) + (startValue * (1 - curvedTime));

这个公式使用curvedTime来计算精灵在当前帧的位置。它将规范化(0 到 1)值扩展回 sprite 可以使用的实数。如果你不断循环更新curvedTime,精灵会加速和减速来匹配你应用的曲线。

提示这个基本的技巧会让你插值任意两个值;不仅仅是针对精灵属性!

行动缓和

我给你们看的这些代码都发生在一个循环中。它可以是任何类型的循环(比如for循环),但是因为我们正在制作游戏,所以我们将使用游戏循环。因此,让我们来看看你的代码需要什么样的从画布的左侧到右侧补间猫精灵,以产生如图 10-1 所示的效果。

首先,在游戏的setup函数中,创建你需要的变量:

totalFrames = 60;
frameCounter = 0;
startValue = cat.x;
endValue = 400;
smoothstep = x => x * x * (3 - 2 * x);

totalFrames值是动画的整个持续时间。frameCounter将用于计算经过的帧数,这样当动画到达totalFrames时你可以停止动画。startValueendValue定义了动画的起点和终点。

gameLoop计算帧数并运行我们在第一部分中看到的代码:

function gameLoop() {
  requestAnimationFrame(gameLoop);

  //Run the animation while `frameCounter` is less than `totalFrames`
  if (frameCounter < totalFrames) {

    //Find the normalized time value
    let normalizedTime = frameCounter / totalFrames;

    //Apply the easing function
    let curvedTime = smoothstep(normalizedTime);

    //Interpolate the sprite's x position based on the curved time
    cat.x = (endValue * curvedTime) + (startValue * (1 - curvedTime));

    //Add 1 to the frame counter
    frameCounter += 1;
  }

  //Render the canvas
  render(canvas);
}

仅此而已!您可以使用相同的技术来补间任何 sprite 属性— alphawidthheight等等。如果你补间猫的scaleXscaleY属性会发生什么?我们来看看。

首先,设置补间的开始和结束值。我们想让猫的体型从 1(正常大小)扩大到 2(两倍大小。)

startValue = 1;
endValue = 2;

然后用这两行新代码替换插值代码:

cat.scaleX = (endValue * curvedTime) + (startValue * (1 - curvedTime));
cat.scaleY = (endValue * curvedTime) + (startValue * (1 - curvedTime));

图 10-3 显示了发生的情况。这只猫在一秒钟内平稳地膨胀到两倍大。看起来像魔术,其实只是简单的数学!

图 10-3 。通过补间其比例来平滑地将精灵膨胀到两倍大小

经典缓动功能

您可以完全改变缓动效果的风格,只需添加不同的缓动功能。我们在第一个例子中使用了smoothstep,但是还有更多公式可供选择。让我们来做一个最棒的旅行,看看制作游戏最有用的一些放松功能。

线性的

巡演的第一站是最简单的公式:线性缓和。它所做的只是完全不变地返回normalizedTime

let linear = x => x;

它什么也不做!它只是返回你输入的相同的值,没有改变。就好像你根本没用缓动功能一样;结果只是一条直线(图 10-4 )。

图 10-4 。线性放松只是一条直线

如果您使用线性缓动来补间精灵的位置,精灵将开始全速移动,然后突然停止。雪碧没有逐渐加速或减速。如果这听起来并不有趣,那是因为它并不有趣!我在这里包括了线性缓动,因为这是学习理解这些函数如何工作的第一步,但是我的建议是不要在真实的游戏中使用它。没有什么比线性宽松更大声地呼喊“学生游戏”了!

但幸运的是,线性放松有一些更有趣的兄弟姐妹:加速减速

加速度

通过将normalizedTime值(x)乘以自身,您可以将枯燥的线性缓动变为激动人心的加速:

let acceleration = x => x * x;

这是一个缓慢开始然后逐渐加速的缓和效果,如图图 10-5 中的图形所示。

图 10-5 。逐渐加速

当你把一个值乘以它自己,它被称为一个平方值。JavaScript 有一种方便的方法来帮助您使用Math.pow函数计算平方值(pow代表“的幂”)。Math.pow有两个参数:初始值,以及该值自身相乘的次数(指数)。

Math.pow(initialValue, exponent);

这意味着你也可以这样写acceleration函数:

let acceleration = x => Math.pow(x, 2);

如果你把相同的值再乘以一次,你会得到一个的立方值。下面是accelerationCubed函数:

let accelerationCubed = x => Math.pow(x, 3);

效果类似普通加速,但更极端,如图图 10-6 。

图 10-6 。立方加速度是一个更夸张的效果

减速

减速与加速相反:开始很快,然后逐渐减速直至停止。该公式正好与加速度公式相反:

let deceleration = x => 1 - Math.pow(1 - x, 2);

正如加速一样,减速也有一个立方版本,它夸大了效果:

let decelerationCubed = x => 1 - Math.pow(1 - x, 3);

图 10-7 是普通减速和立方减速的对比图。

图 10-7 。减速开始时很快,然后逐渐减速直至停止

平滑步骤

整场秀的明星是smoothstep公式。这是一个非常令人愉快、看起来自然的过渡,适用于任何类型的补间。除了你已经看到的标准公式,smoothstep也有平方和立方版本,它们将效果增强到额外的程度。这是所有三个smoothstep功能,你可以在图 10-8 中看到。

let smoothstep = x => x * x * (3 - 2 * x);
let smoothstepSquared = x => Math.pow((x * x * (3 - 2 * x)), 2);
let smoothstepCubed = x => Math.pow((x * x * (3 - 2 * x)), 3);

图 10-8 。Smoothstep 可产生均衡且自然的效果

如有疑问,请使用 smoothstepping!它可以将任何游戏的外观从“学生”变成“专业”

正弦

正弦曲线给你一个稍微圆一点的减速效果。对于一个温和的加速度,使用反正弦。

let sine = x => Math.sin(x * Math.PI / 2);
let inverseSine = x => 1 - Math.sin((1 - x) * Math.PI / 2);

图 10-9 显示了这些曲线的样子。

图 10-9 。使用正弦曲线进行平缓的加速和减速

这两个公式还有平方和立方版本,每一个都成比例地放大了曲线:

let sineSquared = x => Math.pow(Math.sin(x * Math.PI / 2), 2);
let sineCubed = x => Math.pow(Math.sin(x * Math.PI / 2), 2);
let inverseSineSquared = x => 1 - Math.pow(Math.sin((1 - x) * Math.PI / 2), 2);
let inverseSineCubed = x => 1 - Math.pow(Math.sin((1 - x) * Math.PI / 2), 3);

所有这些公式实际上只使用了正弦曲线的一半。如果你使用完整的曲线,你会得到一个和 smoothstep 几乎一样的形状。

let sineComplete = x => 0.5 - Math.cos(-x * Math.PI) * 0.5;

然而,这种方法比 smoothstep 公式的计算量大得多,所以通常不需要使用它。

齿条

到目前为止,我们看到的所有公式都只是在两点之间的一个值:0 和 1。但是有时引入两个超出这个范围的点是有用的。这使您可以创建一个补间,在值稳定之前添加一点反弹或抖动。你可以借助一条叫做样条的数学曲线来做到这一点。您可以将样条视为一条沿您定义的点弯曲的线。

有许多公式可以用来生成样条曲线,但是对于游戏来说,一个特别有效的公式是 Catmull-Rom 样条曲线。公式如下:

let spline = (t, a, b, c, d) => {
  return 0.5 * (
    (2 * b) +
    (-a + c) * t +
    (2 * a - 5 * b + 4 * c - d) * t * t +
    (-a + 3 * b - 3 * c + d) * t * t * t
  );
}

你和我都不需要知道为什么这个公式有效——我们只需要发一个大大的“谢谢!”敬 Catmull 和 Rom 为我们解决了这个问题。你真正需要知道的是,这个公式会产生四个你可以控制的点。自变量tnormalizedTimeabcd是样条的四个点。

下面是如何在我们当前的补间设置中使用样条线:

let curvedTime = spline(normalizedTime, 10, 0, 1, -10);

最后四个参数代表样条的四个点。中间的两个 0 和 1 表示基本补间范围:

10, 0, 1, -10

一般来说,不要将 0 和 1 更改为任何其他值,因为我们的补间系统使用的归一化时间值也在 0 和 1 之间。

您应该更改的数字是第一点和最后一点,即 10 和–10。

10, 0, 1, -10

那些是控制点。它们决定补间偏离 0 到 1 范围的程度。第一个数字 10 是补间开始时的散度,最后一个数字–10 是结束时的散度。给它们更高的值会使效果更戏剧化,给它们更低的值会使效果不那么戏剧化。

图 10-10 显示了该样条曲线绘制后的样子。

图 10-10 。使用样条线在 0 到 1 范围之外补间精灵

你可以在这张图上看到,曲线从 0 开始,然后移动到几乎-0.1。然后它向上弯曲到大约 1.1,然后稳定在 1。

当您补间一个精灵时,这有什么影响?图 10-11 展示了当你使用样条曲线来补间猫的 x 位置时会发生什么。这是一种弹性反弹效应。精灵向左摆动,向右反弹,稍微超出终点,然后到达目的地。这只发生在 x 轴上,所以猫沿着直线来回移动。

图 10-11 。对精灵的位置应用样条线以获得弹性反弹效果

你可以通过改变样条控制点的值来创建一系列不同的效果:第一个点值和最后一个点值。例如,如果将第一个点更改为 0,并将最后一个点保持在–10,则弹性反弹只会在猫运动结束时发生。

let curvedTime = spline(normalizedTime, 0, 0, 1, -10);

在你的日常游戏设计中,你很少需要在大多数补间中使用样条线。但对于某些特殊效果来说是必不可少的。在这一章的后面,你将学习如何使用样条线来产生戏剧性的果冻抖动效果。

加权平均值

如果你的目的值在每一帧不断变化,考虑使用weightedAverage功能。其效果与你在第六章中学到的缓解公式相同。

let weightedAverage = (p, d, w) => ((p * (w - 1)) + d) / w;

参数p是精灵属性值,d是目标值,w是添加到效果中的权重的数量。权重决定了放松的快慢。权重值在 5 到 50 之间是一个很好的起点;然后你可以调整这个数字来微调放松的感觉。

与本章中的其他补间函数不同,weightedAverage不要求您计算归一化时间或对其应用任何曲线函数。把它放在你游戏循环的任何地方。

function gameLoop() {
  requestAnimationFrame(gameLoop);
  cat.x = weightedAverage(cat.x, endValue, 30);
  render(canvas);
}

是的,这只是简单宽松的另一种计算方式。

沿曲线运动

到目前为止,我们看到的曲线都有助于修改精灵的属性如何随时间变化。但是你也可以使用曲线来修改精灵在空间中移动的方式。贝塞尔曲线非常适合这样做。这是经典的三次贝塞尔公式:

function cubicBezier(t, a, b, c, d) {
    var t2 = t * t;
    var t3 = t2 * t;
    return a
      + (-a * 3 + t * (3 * a - a * t)) * t
      + (3 * b + t * (-6 * b + b * 3 * t)) * t
      + (c * 3 - c * 3 * t) * t2 + d * t3;
}

这只是一种你可以设置的有四个点的样条曲线。自变量tnormalizedTimeabcd是样条的四个点。

你可以把贝塞尔曲线想象成一条在起点和终点ad之间延伸的直线。点bc是决定线弯曲程度的控制点。你可以把 b 和 c 想象成强力磁铁,它们拉着线使之弯曲。贝塞尔曲线的形状取决于 b 点和 c 点的位置。图 10-12 显示了bc控制点如何扭曲在ad之间运行的直线。

图 10-12 。贝塞尔曲线

有了我们设置好的补间系统,你可以很容易地让精灵沿着这条曲线移动。只需将cubicBezier函数的返回值应用到精灵的 x/y 位置。另外,您还可以选择对其应用任何缓动功能。以下是如何:

let curvedTime = smoothstep(normalizedTime);
cat.x = cubicBezier(curvedTime, startX, bX, cX, endX);
cat.y = cubicBezier(curvedTime, startY, bY, cY, endY);

如果您在我们在本章开始时编写的补间引擎中运行这段代码,猫将从起点到终点平滑地形成弧线。

这是达到这种效果的完整的gameLoop,图 10-13 说明了结果。

function gameLoop() {
  requestAnimationFrame(gameLoop);

  //Run the animation while `frameCounter` is less than `totalFrames`
  if (frameCounter < totalFrames) {

    //Find the normalized time value
    let normalizedTime = frameCounter / totalFrames;

    //Optionally apply an easing formula
    let curvedTime = smoothstep(normalizedTime);

    //Make the sprite follow a Bezier curve
    cat.x = cubicBezier(curvedTime, 25, 100, 175, 225);
    cat.y = cubicBezier(curvedTime, 250, 50, 0, 250);

    //Add 1 to the frame counter
    frameCounter += 1;
  }

  //Render the canvas
  render(canvas);
}

图 10-13 。让精灵跟随曲线

如果您不想应用任何缓和,只需向cubicBezier函数提供原始的normalizedTime而不是curvedTime值作为第一个参数。

构建补间组件

你现在知道了补间技术是如何工作的,但是你如何在游戏中使用它呢?仅仅是在两点之间移动一个 sprite 就需要很多代码,如果您想要移动数百个 sprite 呢?您需要一个可重用的系统来创建和管理补间,所以这就是我们接下来要创建的。

您可以将补间视为介于粒子效果和关键帧动画之间的游戏组件。像粒子一样,它们需要被游戏循环更新,你可以在任何时候让几十个补间变得活跃或不活跃。像关键帧动画一样,补间动画也有持续时间,所以它们可以播放、暂停,也可能随时间反转或循环。在本节中,我们将构建一组用于构建补间的低级工具,以便您可以轻松地为游戏构建自己的自定义补间效果库。

注意你会在本书源文件的library/tween.js文件中找到这一节的所有工作代码。

tweens阵列

就像粒子和交互按钮一样,你的游戏需要一个数组来存储所有活跃的补间动画。简单点说,就叫tweens

export let tweens = [];

然后,您的游戏循环将需要遍历数组中的补间,并对每个补间调用一个update方法:

if (tweens.length > 0) {
  for(let i = tweens.length - 1; i >= 0; i--) {
    let tween = tweens[i];
    if (tween) tween.update();
  }
}

这是我们在第八章中用于粒子引擎的同一系统。而且,就像粒子一样,代码以相反的顺序循环补间,以便我们可以随时从数组中轻松地删除补间,而不会弄乱循环索引计数器。

您很快就会看到这些补间对象是什么,以及update方法是做什么的。

轻松图书馆

在本章的第一部分,我向你展示了如何使用 16 个缓动功能,比如smoothsteplinearspline。为了使它们易于使用,我们将把它们添加为一个名为ease的对象的属性。这将允许我们使用类似这样的代码来访问这些函数:

ease.smoothstep(normalizedTime);

或者,因为函数是ease对象的一个属性,我们可以选择通过它的字符串名调用函数:

ease"smoothstep";

这是一个巧妙的技巧,正如您将看到的,这将使我们不必在以后编写大量重复的代码。

存储缓和函数的ease对象只是一个普通的旧对象文字,将函数作为属性。这里有一个ease对象的简化版本,展示了前两个函数是如何作为属性添加的。

let ease = {
  linear(x) {return x;},
  smoothstep(x) {return x * x * (3 - 2 * x);},
  //... the rest of the easing functions follow the same pattern...
};

对于剩余的 14 个函数,ease对象中的其余代码遵循完全相同的模型。我们现在有了一个方便的易于应用的放松函数库。

创建补间对象

下一步是构建一个叫做tweenProperty的灵活的底层函数,我们可以用它来补间任何 sprite 的任何属性。我称之为“低级”,因为我们将使用它作为构建块来创建更易于使用的、专门的补间效果。

在我向您展示代码之前,先告诉您如何使用tweenProperty函数:

tweenProperty(
  sprite,         //The sprite
  "x",            //The property you want to tween (a string)
  100,            //The start value
  200,            //the end value
  60,             //The tween duration, in frames
  ["smoothstep"], //An array that defines the easing type (a string)
  true,           //Yoyo? True or false
  1000            //The delay, in milliseconds, before the tween yoyos
);

请注意,缓动类型在数组中作为字符串列出:

["smoothstep"]

除样条曲线外,所有缓动类型都将使用相同的格式。如果要使用样条曲线,请在数组中提供两个额外的值-起始幅值和结束幅值:

["spline" -10, 10]

这两个数字指的是可用于修改弹性的样条线控制点。您将在前面的代码中看到它们是如何使用的。

tweenProperty函数中的第七个参数是一个名为yoyo的布尔值。如果yoyotrue,补间动画将连续循环反转其动画,就像溜溜球一样。最后一个参数是一个以毫秒为单位的数字,它决定了在溜溜球重复之前动画应该暂停多长时间。

tweenProperty函数返回一个tween对象。

let tween = tweenProperty(/*...arguments...*/);

tween对象有playpause方法,您可以使用它们来控制补间,还有一个名为playing的布尔属性,它告诉您补间当前是否正在播放:

tween.play();
tween.pause();
tween.playing

您可以选择使用补间完成时应该运行的任何代码来定义tween对象的onComplete方法:

tween.onComplete = () => {
  //This code will run when the tween finishes
};

(如果你的补间是溜溜球,在每个溜溜球片段的结尾都会调用onComplete。)

tween对象还有一个update函数,它包含了应该在游戏循环内部运行的代码。当tweenProperty函数创建补间时,它将补间对象推入全局tweens数组。在我们之前看过的代码中,通过循环遍历tweens数组并对每个补间调用update方法,使补间对象具有动画效果。

完整的tweenProperty功能

下面是创建和返回补间对象的完整的tweenProperty函数。你会看到大部分代码是我们用来控制精灵关键帧动画的addStatePlayer函数和我们用来制作粒子的particleEffect函数的混合体。注释解释了大部分细节,但是还有一些新特性,我将在代码清单之后更深入地解释。

export function tweenProperty(
  sprite,                  //The sprite object
  property,                //The property to tween (a string)
  startValue,              //Tween start value
  endValue,                //Tween end value
  totalFrames,             //Duration in frames
  type = ["smoothstep"],   //The easing type
  yoyo = false,            //Yoyo?
  delayBeforeRepeat = 0    //Delay in milliseconds before repeating
) {

  //Create the tween object
  let o = {};

  //If the tween is a spline, set the
  //start and end magnitude values
  if(type[0] === "spline" ){
    o.startMagnitude = type[1];
    o.endMagnitude = type[2];
  }

  //Use `o.start` to make a new tween using the current
  //end point values
  o.start = (startValue, endValue) => {

    //Clone the start and end values so that any possible references to sprite
    //properties are converted to ordinary numbers
    o.startValue = JSON.parse(JSON.stringify(startValue));
    o.endValue = JSON.parse(JSON.stringify(endValue));
    o.playing = true;
    o.totalFrames = totalFrames;
    o.frameCounter = 0;

    //Add the tween to the global `tweens` array. The `tweens` array is
    //updated on each frame
    tweens.push(o);
  };

  //Call `o.start` to start the tween
  o.start(startValue, endValue);

  //The `update` method will be called on each frame by the game loop.
  //This is what makes the tween move
  o.update = () => {

    let time, curvedTime;

    if (o.playing) {

      //If the elapsed frames are less than the total frames,
      //use the tweening formulas to move the sprite
      if (o.frameCounter < o.totalFrames) {

        //Find the normalized value
        let normalizedTime = o.frameCounter / o.totalFrames;

        //Select the correct easing function from the
        //`ease` object’s library of easing functions

        //If it's not a spline, use one of the ordinary easing functions
        if (type[0] !== "spline") {
          curvedTime = easetype;
        }

        //If it's a spline, use the `spline` function and apply the
        //two additional `type` array values as the spline's start and
        //end points
        else {
          curvedTime = ease.spline(normalizedTime, o.startMagnitude, 0, 1, o.endMagnitude);
        }

        //Interpolate the sprite's property based on the curve
        sprite[property] = (o.endValue * curvedTime) + (o.startValue * (1 - curvedTime));

        o.frameCounter += 1;
      }

      //When the tween has finished playing, run the end tasks
      else {
       o.end();
      }
    }
  };

  //The `end` method will be called when the tween is finished
  o.end = () => {

    //Set `playing` to `false`
    o.playing = false;

    //Call the tween's `onComplete` method, if it's been assigned
    //by the user in the main program
    if (o.onComplete) o.onComplete();

    //Remove the tween from the `tweens` array
    tweens.splice(tweens.indexOf(o), 1);

    //If the tween's `yoyo` property is `true`, create a new tween
    //using the same values, but use the current tween's `startValue`
    //as the next tween's `endValue`
    if (yoyo) {
      wait(delayBeforeRepeat).then(() => {
        o.start(o.endValue, o.startValue);
      });
    }
  };

  //Play and pause methods
  o.play = () => o.playing = true;
  o.pause = () => o.playing = false;

  //Return the tween object
  return o;
}

tweenProperty做的一件重要的事情是将startValueendValue转换成字符串,然后再转换回数字,然后将它们分配给补间对象:

o.startValue = JSON.parse(JSON.stringify(startValue));
o.endValue = JSON.parse(JSON.stringify(endValue));

这确保了startValueendValue是纯数字,而不是指向精灵属性的引用。在补间动画制作过程中,Sprite 属性值可能会发生变化,如果发生这种情况,可能会破坏补间动画。例如,假设您用cat.x初始化tweenProperty函数的初始值(第三个参数),如下面的代码所示:

let tween = tweenProperty(cat, "x", cat.x ... )

cat.x不是数字!只是一个指向猫身上x值的引用。如果在补间动画制作过程中cat.x发生变化(这是必然的),补间对象将读取猫的当前 x 位置,而不是它的开始位置。为了使补间正常工作,您只需要开始位置值

使用JSON.parseJSON.stringify方法是帮助解决这个问题的常用方法。JSON.stringify将任何值转换成字符串(这个过程叫做序列化)。然后JSON.parse将其转换回数字(这个过程叫做反序列化)。这个转换过程会删除所有引用,所以你只剩下一个纯数字。这是防止您意外使用补间开始值和结束值的保险策略。

注意制作一个不包含指向原始对象上的值的引用指针的对象的精确副本被称为克隆。当前版本的 JavaScript (ES6)没有克隆对象的专用功能,尽管将来的版本可能会有。

现在我们已经写了tweenProperty函数,我们如何使用它呢?这个函数的目的不是在你的游戏代码中直接使用它。相反,它是一个低级工具,你可以用它来为你的精灵构建更高级的、有用的补间函数。让我们看看下一步该怎么做。

阿尔法补间

现在我们有了一个在精灵上补间单个属性的便捷方法,有三个高级函数很容易让我们马上完成:fadeInfadeOutpulse

fadeIn

fadeIn函数允许你淡入一个 sprite,方法是将它的alpha属性补间为 1:

export function fadeIn(sprite, frames = 60) {
  return tweenProperty(
    sprite, "alpha", sprite.alpha, 1, frames, ["sine"]
  );
}

在你的游戏代码中这样使用它:

let fadeInTween = fadeIn(anySprite);

fadeOut

相反的效果,fadeOut,将精灵的 alpha 补间为 0:

export function fadeOut(sprite, frames = 60) {
  return tweenProperty(
    sprite, "alpha", sprite.alpha, 0, frames, ["sine"]
  );
}

请这样使用:

let fadeOutTween = fadeOut(anySprite);

这两种效果非常适合场景转换或使精灵出现或消失。

pulse

pulse功能使精灵在高低 alpha 值之间不断振荡。这是一个很好的效果,可以用来吸引人们对精灵的注意。

export function pulse(sprite, frames = 60, minAlpha = 0) {
  return tweenProperty(
    sprite, "alpha", sprite.alpha, minAlpha, frames, ["smoothstep"], true
  );
}

第三个参数minAlpha是在补间回到原始值之前使用的最低 alpha 值。所以如果你只是想让精灵补间到 alpha 为 0.3,用下面的语句初始化pulse函数:

let pulseTween = pulse(anySprite, 120, 0.3);

如果将frames参数设置为较低的值,可以创建闪烁效果。

所有这些效果都在一个属性之间,即精灵的alpha。但是如果您想要创建一个需要多个属性补间的更复杂的效果呢?

补间多个属性

我们需要再构建一个底层组件!我们将构建一个名为makeTween的新函数,它将允许您通过组合任意多的单属性补间来创建复杂的效果。以下是您可以使用它的方式:

let complexTween = makeTween([
  [/* A property you want to tween */],
  [/* Another property you want to tween */],
  [/* Yet another property you want to tween */]
]);

makeTween接受单个参数,该参数是包含要补间的属性子数组的数组。您放在子数组中的信息与您需要提供给我们之前创建的tweenProperty函数的信息是相同的。

下面是如何使用makeTween创建一个复杂的高级函数slide,它将把一个精灵的 x/y 位置补间到画布上的任何其他 x/y 位置。

export function slide(
  sprite,
  endX,
  endY,
  frames = 60,
  type = ["smoothstep"],
  yoyo = false,
  delayBeforeRepeat = 0
) {
  return makeTween([

    //The x axis tween
    [sprite, "x", sprite.x, endX, frames, type, yoyo, delayBeforeRepeat],

    //The y axis tween
    [sprite, "y", sprite.y, endY, frames, type, yoyo, delayBeforeRepeat]
  ]);
}

可以看到数组中的数据与初始化tweenProperty函数所需的参数完全相同。你可以这样在游戏代码中使用滑动功能:

let catSlide = slide(cat, 400, 32, 60, ["smoothstep"], true, 0);

这将使cat精灵从其当前位置平滑地来回滑动到 400/30 的 x/y 位置,超过 60 帧。

所有补间都有一个用户可定义的onComplete方法,该方法将在补间完成时运行。下面是当补间完成时,如何使用catSlide补间上的onComplete向控制台写入消息:

catSlide.onComplete = () => console.log("Cat slide finished!");

制作补间功能

makeTween函数将接受任意数量的补间数组作为参数,因此您可以使用它来构建一些真正复杂的效果。它本质上只是一个包装器,使用tweenProperty来创建每个补间,并将对每个补间对象的引用保存在它自己的内部数组中。它还为您提供了控制数组中所有补间动画的高级playpause方法,并允许您分配一个onComplete方法,该方法将在数组中的所有补间动画完成后运行。

下面是完成所有这些的完整的makeTween函数。

function makeTween(tweensToAdd) {

  //Create an object to manage the tweens
  let o = {};

  //Create an internal `tweens` array to store the new tweens
  o.tweens = [];

  //Make a new tween for each array
  tweensToAdd.forEach(tweenPropertyArguments => {

     //Use the tween property arguments to make a new tween
     let newTween = tweenProperty(...tweenPropertyArguments);

     //Push the new tween into this object's internal `tweens` array
     o.tweens.push(newTween);
  });

  //Add a counter to keep track of the
  //number of tweens that have completed their actions
  let completionCounter = 0;

  //`opleted` will be called each time one of the tweens finishes
  o.completed = () => {

    //Add 1 to the `completionCounter`
    completionCounter += 1;

    //If all tweens have finished, call the user-defined `onComplete`
    //method, if it's been assigned. Reset the `completionCounter`
    if (completionCounter === o.tweens.length) {
      if (o.onComplete) o.onComplete();
      completionCounter = 0;
    }
  };

  //Add `onComplete` methods to all tweens
  o.tweens.forEach(tween => {
    tween.onComplete = () => o.completed();
  });

  //Add pause and play methods to control all the tweens
  o.pause = () => {
    o.tweens.forEach(tween => {
      tween.playing = false;
    });
  };
  o.play = () => {
    o.tweens.forEach(tween => {
      tween.playing = true;
    });
  };

  //Return the tween object
  return o;
}

因为makeTween管理多个补间动画,它需要知道所有补间动画何时完成任务。代码使用一个名为completionCounter的计数器变量来跟踪这一点,并将其初始化为 0:

let completionCounter = 0;

创建补间动画后,makeTween循环遍历其数组中的所有补间动画,并向它们添加onComplete方法:

o.tweens.forEach(tween => {
  tween.onComplete = () => o.completed();
});

当它们完成时,补间动画将调用一个名为completed的方法。completed方法给completionCounter加 1。如果completionCounter的值与内部tweens数组的长度相匹配,那么你就知道所有的补间都完成了。然后代码运行一个可选的、用户定义的onComplete方法,如果它存在的话。

o.completed = () => {

  //Add 1 to the `completionCounter`
  completionCounter += 1;

  //If all tweens have finished, call the user-defined `onComplete`
  //method, if it's been assigned in the main program.
  //Then reset the `completionCounter`
  if (completionCounter === o.tweens.length) {
    if (o.onComplete) o.onComplete();
    completionCounter = 0;
  }
};

removeTween功能

既然我们有了制作多个补间动画的方法,我们还需要一个移除它们的方法。我们需要添加的最后一点是一个通用的removeTween函数:

export function removeTween(tweenObject) {

  //Remove the tween if `tweenObject` doesn't have any nested
  //tween objects
  if(!tweenObject.tweens) {
    tweenObject.pause();
    tweens.splice(tweens.indexOf(tweenObject), 1);

  //Otherwise, remove the nested tween objects
  } else {
    tweenObject.pause();
    tweenObject.tweens.forEach(element => {
      tweens.splice(tweens.indexOf(element), 1);
    });
  }
}

使用removeTween从游戏中删除任何补间,语法如下:

removeTween(tween);

最后,我们现在可以开始制作一些有趣的东西了!

轻松轻松!

我们现在已经有了创建一些简单易用的高级补间函数所需的所有工具,这些函数适用于任何精灵。下面是对各种游戏最有用的一些功能的快速总结。如果您需要更专业的东西,只需使用这些函数作为创建您自己的模型。

slide

如果你需要让精灵在任意两个 x/y 点之间移动,使用slide功能。

let slideTween = slide(
  anySprite,              //The sprite
  400,                    //Destination x
  32,                     //Destination y
  60,                     //Duration in frames
  ["smoothstep"],         //Easing type
  true,                   //yoyo?
  0                       //Delay, in milliseconds, before repeating
);

你在前一节看到了slide函数的代码,而图 10-14 说明了它的功能。

图 10-14 。使用滑块让精灵平滑地移动到任何一点

breathe

通过在溜溜球循环中来回补间scaleXscaleY属性,你可以让精灵看起来像在呼吸(图 10-15 )。

图 10-15 。对 scaleX 和 scaleY 属性进行补间以制作一个呼吸精灵

下面是做这件事的breathe函数。

export function breathe(
  sprite, endScaleX, endScaleY,
  frames, yoyo = true, delayBeforeRepeat = 0
) {
  return makeTween([

    //Create the scaleX tween
    [
      sprite, "scaleX", sprite.scaleX, endScaleX,
      frames, ["smoothstepSquared"], yoyo, delayBeforeRepeat
    ],

    //Create the scaleY tween
    [
      sprite, "scaleY", sprite.scaleY, endScaleY,
      frames, ["smoothstepSquared"], yoyo, delayBeforeRepeat
    ]
  ]);
}

请注意,breathe使用了smoothstepSquared函数来获得更明显的效果:

在游戏代码中使用它让精灵呼吸,如下例所示:

let breathingTween = breathe(anySprite, 1.2, 1.2, 60);

scale

breathe功能在一个连续的溜溜球补间中放大和缩小精灵。但是如果您希望缩放效果只发生一次,请使用scale函数:

export function scale(sprite, endScaleX, endScaleY, frames = 60) {
  return makeTween([

    //Create the scaleX tween
    [
      sprite, "scaleX", sprite.scaleX, endScaleX,
      frames, ["smoothstep"], false
    ],

    //Create the scaleY tween
    [
      sprite, "scaleY", sprite.scaleY, endScaleY,
      frames, ["smoothstep"], false
    ]
  ]);
}

它与breathe函数几乎完全相同,除了yoyo参数被设置为false。你可以使用scale平滑地放大或缩小精灵,方法如下:

let scaleUpTween = scale(anySprite, 2, 2);
let scaleDownTween = scale(anySprite, 0.2, 0.2);

strobe

通过快速旋转标尺并使用样条线,您可以创建一个迷幻效果strobe

export function strobe(
  sprite, scaleFactor = 1.3, startMagnitude = 10, endMagnitude = 20,
  frames = 10, yoyo = true, delayBeforeRepeat = 0
) {
  return makeTween([

    //Create the scaleX tween
    [
      sprite, "scaleX", sprite.scaleX, scaleFactor, frames,
      ["spline", startMagnitude, endMagnitude],
      yoyo, delayBeforeRepeat
    ],

    //Create the scaleY tween
    [
      sprite, "scaleY", sprite.scaleY, scaleFactor, frames,
      ["spline", startMagnitude, endMagnitude],
      yoyo, delayBeforeRepeat
    ]
  ]);
}

您可以在这段代码中看到“样条线”缓动类型是如何设置的,以及它的起始和结束幅度值:

["spline", startMagnitude, endMagnitude]

以下是制作精灵频闪灯的方法:

let strobeTween = strobe(anySprite, 1.3, 10, 20, 10);

这是一种闪烁的缩放效果,如果你让它持续太久,可能会让你头疼。(你会在本章的源文件中找到一个strobe函数的工作示例,以及这些效果的其余部分。)

T2wobble

最后但同样重要的是:wobble函数。想象一大盘你一生中见过的最不稳定的果冻布丁。然后,用手指戳它。这就是wobble函数的作用。它的工作原理是借助一条样条线在 xy 轴上反向缩放精灵。精灵开始时非常不稳定,然后随着每次重复逐渐变得不那么不稳定,直到它恢复正常。图 10-16 说明了这个效果。

图 10-16 。让雪碧像果冻一样晃动

是这些补间函数中最复杂的,因为它在幕后做了更多的工作。它为 xy 缩放补间添加了一个onComplete方法,这样每次重复时都会有一点点friction添加到抖动中。这就是它逐渐慢下来的原因。摩擦值在 0.96(不太不稳定)和 0.99(更不稳定)之间是一个很好的尝试范围。当补间的结束值低于 1 时,效果结束,补间被移除。

export function wobble(
  sprite,
  scaleFactorX = 1.2,
  scaleFactorY = 1.2,
  frames = 10,
  xStartMagnitude = 10,
  xEndMagnitude = 10,
  yStartMagnitude = -10,
  yEndMagnitude = -10,
  friction = 0.98,
  yoyo = true,
  delayBeforeRepeat = 0
) {

  let o = makeTween([

    //Create the scaleX tween
    [
      sprite, "scaleX", sprite.scaleX, scaleFactorX, frames,
      ["spline", xStartMagnitude, xEndMagnitude],
      yoyo, delayBeforeRepeat
    ],

    //Create the scaleY tween
    [
      sprite, "scaleY", sprite.scaleY, scaleFactorY, frames,
      ["spline", yStartMagnitude, yEndMagnitude],
      yoyo, delayBeforeRepeat
    ]
  ]);

  //Add some friction to the `endValue` at the end of each tween
  o.tweens.forEach(tween => {
    tween.onComplete = () => {

      //Add friction if the `endValue` is greater than 1
      if (tween.endValue > 1) {
        tween.endValue *= friction;

        //Set the `endValue` to 1 when the effect is finished and
        //remove the tween from the global `tweens` array
        if (tween.endValue <= 1) {
          tween.endValue = 1;
          removeTween(tween);
        }
      }
    };
  });

  return o;
}

以下是如何让精灵在游戏中摇摆的方法:

let wobbleTween = wobble(anySprite, 1.2, 1.2);

更改 xy 比例因子(第二个和第三个参数)以获得更生动的效果。

我是在玩不同的补间值时偶然发现频闪和抖动效果的。你也可以这样做!使用makeTween合成多个补间动画,以意想不到的方式改变不同的 sprite 属性——您可能会对自己的成果感到惊讶!

使用航路点跟随运动路径

在上一节中,你学习了如何使用slide函数使一个精灵平滑地在其位置之间移动。但是如果你想让一个精灵沿着一条连接路径的路线走呢?你可以将一系列的slide函数连接在一起,让一个精灵在画布上行走。

为了完成这项工作,你需要连接一个由 x/y 点组成的阵列;每个点被称为一个航点。每次精灵到达一个航路点时,调用slide函数并将精灵移动到下一个点。例如,假设你想让一个精灵沿着矩形路径前进,如图图 10-17 所示。

图 10-17 。使用路径点使精灵跟随路径

您可以定义一组 2D 路点来描述路径,如下所示:

 [
  [32, 32],     //First x/y point
  [32, 128],    //Next x/y point
  [300, 128],   //Next x/y point
  [300, 32],    //Next x/y point
  [32, 32]      //Last x/y point
],

因为这是一个封闭的路径,所以最后一个点与第一个点相同,但您也可以保持路径开放。

要做到这一点,您需要构建一个函数来读取这些路点,并使精灵在每个相邻点之间移动。当每个点之间的移动完成后,你需要让精灵在下两个点之间移动,直到它到达最后一个点。

walkPath

你可以使用一个名为walkPath的新函数来帮助你做到这一点。它的代码和我理论上描述的完全一样。在我们详细了解walkPath的工作原理之前,我们先来了解一下它的使用方法。下面是你需要用来让猫精灵沿着图 10-17 中的矩形路径前进的代码。

let catPath = walkPath(
  cat,                   //The sprite

  //An array of x/y waypoints to connect in sequence
  [
    [32, 32],            //First x/y point
    [32, 128],           //Next x/y point
    [300, 128],          //Next x/y point
    [300, 32],           //Next x/y point
    [32, 32]             //Last x/y point
  ],

  300,                   //Total duration in frames
  ["smoothstep"],        //Easing type
  true,                  //Should the path loop?
  true,                  //Should the path yoyo?
  1000                   //Delay in milliseconds between segments
);

您可以看到第二个参数是一个 2D 数组,它列出了路径的路点。如果loop(第五个参数)是true,sprite 将在到达路径末尾时从路径的起点重新开始。如果yoyo(第六个参数)是true,精灵将在到达终点时逆向行走。(如果你设置yoyo为真,设置loopfalse,精灵将从路径的起点到终点返回,不重复。)最后一个参数是 sprite 在路径的每一部分之间应该等待的延迟(以毫秒为单位)。

下面是完成这项工作的完整的walkPath函数。它使用makeTween在 2D 数组中的每个航路点之间创建一个补间。补间完成后,会在前一个数组的最后一个点和新数组的下一个点之间创建一个新的补间。当到达最后一个点时,路径可选地循环和溜溜球。

export function walkPath(
  sprite,                   //The sprite
  originalPathArray,        //A 2D array of waypoints
  totalFrames = 300,        //The duration, in frames
  type = ["smoothstep"],    //The easing type
  loop = false,             //Should the animation loop?
  yoyo = false,             //Should the direction reverse?
  delayBetweenSections = 0  //Delay, in milliseconds, between sections
) {

  //Clone the path array so that any possible references to sprite
  //properties are converted into ordinary numbers
  let pathArray = JSON.parse(JSON.stringify(originalPathArray));

  //Figure out the duration, in frames, of each path section by
  //dividing the `totalFrames` by the length of the `pathArray`
  let frames = totalFrames / pathArray.length;

  //Set the current point to 0, which will be the first waypoint
  let currentPoint = 0;

  //Make the first path using the internal `makePath` function (below)
  let tween = makePath(currentPoint);

  //The `makePath` function creates a single tween between two points and
  //then schedules the next path to be made after it

  function makePath(currentPoint) {

    //Use the `makeTween` function to tween the sprite's x and y position
    let tween = makeTween([

      //Create the x axis tween between the first x value in the
      //current point and the x value in the following point
      [
        sprite,
        "x",
        pathArray[currentPoint][0],
        pathArray[currentPoint + 1][0],
        frames,
        type
      ],

      //Create the y axis tween in the same way
      [
        sprite,
        "y",
        pathArray[currentPoint][1],
        pathArray[currentPoint + 1][1],
        frames,
        type
      ]
    ]);

    //When the tween is complete, advance the `currentPoint` by 1.
    //Add an optional delay between path segments, and then make the
    //next connecting path
    tween.onComplete = () => {

      //Advance to the next point
      currentPoint += 1;

      //If the sprite hasn't reached the end of the
      //path, tween the sprite to the next point
      if (currentPoint < pathArray.length - 1) {
        wait(delayBetweenSections).then(() => {
          tween = makePath(currentPoint);
        });
      }

      //If we've reached the end of the path, optionally
      //loop and yoyo it
      else {

        //Reverse the path if `loop` is `true`
        if (loop) {

          //Reverse the array if `yoyo` is `true`. Use JavaScript’s built-in
          //array `reverse` method to do this
          if (yoyo) pathArray.reverse();

          //Optionally wait before restarting
          wait(delayBetweenSections).then(() => {

            //Reset the `currentPoint` to 0 so that we can
            //restart at the first point
            currentPoint = 0;

            //Set the sprite to the first point
            sprite.x = pathArray[0][0];
            sprite.y = pathArray[0][1];

            //Make the first new path
            tween = makePath(currentPoint);

            //... and so it continues!
          });
        }
      }
    };

    //Return the path tween to the main function
    return tween;
  }

  //Pass the tween back to the main program
  return tween;
}

通过调整makePath功能的参数,您可以实现多种多样的运动路径效果,这将使您很好地适应各种游戏。但是makePath只移动由直线段组成的路径。如果你想让一个精灵沿着一条弯曲的路径走呢?

walkCurve

你可以用一组贝塞尔曲线来描述一个精灵的路径,而不是用一组 x/y 的路点。图 10-18 显示了一个精灵沿着两条贝塞尔曲线组成的路径。第一条曲线使 sprite 向画布底部弯曲,第二条曲线使它向其起点弯曲。

图 10-18 。使用贝塞尔曲线使精灵遵循弯曲的运动路径

这是两条贝塞尔曲线的数组,描述了图 10-18 所示的运动路径:

[
  //Curve 1
  [[hedgehog.x, hedgehog.y],[75, 500],[200, 500],[300, 300]],

  //Curve 2
  [[300, 300],[250, 100],[100, 100],[hedgehog.x, hedgehog.y]]
]

下一步是创建一个名为walkCurve的函数,让精灵沿着贝塞尔曲线描述的路径前进。walkCurve功能与walkPath功能非常相似——唯一真正的区别是航路点数据被曲线数据取代。下面是你如何使用walkCurve函数让一个精灵跟随图 10-18 中的路径:

let hedgehogPath = walkCurve(
  hedgehog,              //The sprite

  //An array of Bezier curve points that
  //you want to connect in sequence
  [
    [[hedgehog.x, hedgehog.y],[75, 500],[200, 500],[300, 300]],
    [[300, 300],[250, 100],[100, 100],[hedgehog.x, hedgehog.y]]
  ],

  300,                   //Total duration, in frames
  ["smoothstep"],        //Easing type
  true,                  //Should the path loop?
  true,                  //Should the path yoyo?
  1000                   //Delay in milliseconds between segments
);

下面是完整的walkCurve函数,带有描述其工作原理的注释。

export function walkCurve(
  sprite,                  //The sprite
  pathArray,               //2D array of Bezier curves
  totalFrames = 300,       //The duration, in frames
  type = ["smoothstep"],   //The easing type
  loop = false,            //Should the animation loop?
  yoyo = false,            //Should the direction reverse?
  delayBeforeContinue = 0  //Delay, in milliseconds, between sections
) {

  //Divide the `totalFrames` into sections for each part of the path
  let frames = totalFrames / pathArray.length;

  //Set the current curve to 0, which will be the first one
  let currentCurve = 0;

  //Make the first path
  let tween = makePath(currentCurve);

  function makePath(currentCurve) {

    //Use the custom `followCurve` function (described earlier
    //in the chapter) to make a sprite follow a curve
    let tween = followCurve(
      sprite,
      pathArray[currentCurve],
      frames,
      type
    );

    //When the tween is complete, advance the `currentCurve` by one.
    //Add an optional delay between path segments, and then create the
    //next path
    tween.onComplete = () => {
      currentCurve += 1;
      if (currentCurve < pathArray.length) {
        wait(delayBeforeContinue).then(() => {
          tween = makePath(currentCurve);
        });
      }

      //If we've reached the end of the path, optionally
      //loop and reverse it
      else {
        if (loop) {
          if (yoyo) {

            //Reverse the order of the curves in the `pathArray`
            pathArray.reverse();

            //Reverse the order of the points in each curve
            pathArray.forEach(curveArray => curveArray.reverse());
          }

          //After an optional delay, reset the sprite to the
          //beginning of the path and create the next new path
          wait(delayBeforeContinue).then(() => {
            currentCurve = 0;
            sprite.x = pathArray[0][0];
            sprite.y = pathArray[0][1];
            tween = makePath(currentCurve);
          });
        }
      }
    };

    //Return the path tween to the main function
    return tween;
  }

  //Pass the tween back to the main program
  return tween;
}

就这样,我们结束了!补间,解决了!

摘要

你现在有了一套有用的工具,可以为各种游戏制作各种动画补间效果。您已经学习了所有经典的缓动功能是如何工作的,以及如何使用它们来制作精灵动画。您还构建了一个通用且可定制的补间引擎,可以用于任何游戏项目。结合您在前面章节中学习的脚本运动和关键帧动画技术,您现在有一个令人眼花缭乱的运动效果调色板可供选择。

本章最重要的是你知道如何使用高级补间函数,如fadeInfadeOutpulseslidestrobebreathefollowCurvewalkPathwalkCurve。不要让那些补间函数如何工作的技术细节困扰你。如果您想尝试制作自己的自定义补间动画,您可以在以后更仔细地研究它们。

但是如何在游戏中使用这些补间函数呢?在下一章你会发现。

本文标签: 高级游戏JavaScript