强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

Pixi.js 游戏开发教程 / Pixi.js 声音集成(Howler.js)

20. 声音集成(Howler.js)

概述

Pixi.js 不包含音频系统。Howler.js 是最流行的 Web 音频库,支持 Web Audio API 和 HTML5 Audio 回退,跨浏览器兼容性极佳。本章讲解如何将 Howler.js 与 Pixi.js 游戏集成。

Howler.js 安装与配置

npm install howler
npm install @types/howler  # TypeScript 类型
import { Howl, Howler } from 'howler';

// 基本用法
const sound = new Howl({
    src: ['/file/sound.mp3'],
    volume: 0.8,
    loop: false,
    preload: true,
});

// 播放
const id = sound.play();

// 全局设置
Howler.autoUnlock = true;   // 自动解锁音频上下文
Howler.html5PoolSize = 10;  // HTML5 Audio 对象池大小

⚠️ 注意:浏览器要求用户交互(点击、按键)后才能播放音频。Howler.js 会自动处理音频上下文的解锁,但建议在首次用户交互后再播放声音。

Howl 对象详解

播放/暂停/停止

const bgm = new Howl({
    src: ['/file/bgm.mp3', '/file/bgm.ogg'], // 多格式回退
    volume: 0.5,
    loop: true,
    preload: true,
});

// 播放(返回声音实例 ID)
const id = bgm.play();

// 暂停(保留播放位置)
bgm.pause(id);

// 从暂停位置恢复
bgm.play(id);

// 停止(重置到开头)
bgm.stop(id);

// 判断状态
bgm.playing(id);  // 是否正在播放
bgm.state();       // 'unloaded' | 'loading' | 'loaded'

音量控制

// 设置全局音量(影响所有声音)
Howler.volume(0.8);

// 设置单个声音的音量
bgm.volume(0.3);

// 设置某个实例的音量
const sfxId = sfx.play();
sfx.volume(0.5, sfxId);

// 获取当前音量
const currentVol = bgm.volume(); // 返回当前音量值

播放速率

// 调整播放速率(变调效果)
sfx.rate(1.5, sfxId);  // 1.5 倍速
sfx.rate(0.5, sfxId);  // 0.5 倍速(慢动作)
sfx.rate(1.0, sfxId);  // 正常速度

事件回调

const sound = new Howl({
    src: ['/file/sound.mp3'],
    onload: () => console.log('加载完成'),
    onloaderror: (id, err) => console.error('加载失败:', err),
    onplay: (id) => console.log('开始播放'),
    onend: (id) => console.log('播放结束'),
    onpause: (id) => console.log('暂停'),
    onstop: (id) => console.log('停止'),
    onfade: (id) => console.log('淡入淡出完成'),
});

// 也可以通过链式方法绑定
sound.on('end', (id) => {
    console.log('播放结束,ID:', id);
});

音频精灵(Sprite)

音频精灵将多个短音效打包在一个音频文件中,通过时间区间播放:

// 音频精灵定义
const sfx = new Howl({
    src: ['/file/sfx-sprite.mp3', '/file/sfx-sprite.ogg'],
    sprite: {
        shoot:    [0, 300],      // 开始时间 0ms,持续 300ms
        explosion: [500, 800],   // 开始时间 500ms,持续 800ms
        coin:     [1500, 200],   // 开始时间 1500ms,持续 200ms
        jump:     [1800, 400],
        hit:      [2400, 150],
        powerup:  [2700, 600],
        click:    [3500, 100],
    },
});

// 播放精灵中的片段
sfx.play('shoot');
sfx.play('explosion');
sfx.play('coin');

// 可以同时播放同一个片段的多个实例
sfx.play('shoot'); // 第一个实例
sfx.play('shoot'); // 第二个实例(不会互相覆盖)

💡 提示:音频精灵非常适合打包 UI 音效。20 个小音效打包成一个文件只需一次 HTTP 请求。推荐使用 Audacity 制作音频精灵。

制作音频精灵

# 使用 audiosprite 工具(npm 包)
npm install -g audiosprite

# 合并多个音频文件
audiosprite --output sfx-sprite \
    --format howler \
    --export mp3,ogg \
    shoot.wav explosion.wav coin.wav jump.wav

3D 空间音频

Howler.js 支持 3D 空间音效,基于听者位置产生方向感:

const fireSound = new Howl({
    src: ['/file/fire.mp3'],
    loop: true,
});

// 设置声音源的 3D 位置(相对于听者)
const id = fireSound.play();
fireSound.pos(x, y, z, id); // x, y, z 为 3D 坐标

// 设置听者位置(通常是玩家/相机位置)
Howler.pos(listenerX, listenerY, listenerZ);

// 设置朝向(可选)
Howler.orientation(0, 0, -1, 0, 1, 0); // 前方向, 上方向

// 更新声音位置(每帧调用)
app.ticker.add(() => {
    // 听者跟随玩家
    Howler.pos(player.x, player.y, 0);

    // 篝火声音位置
    fireSound.pos(campfire.x, campfire.y, 0, fireId);
});

衰减模型

// 设置距离衰减模型
Howler.panningModel('HRTF'); // 'HRTF'(更真实)或 'equalpower'(更简单)

// 设置最大距离
fireSound.pannerAttr({
    panningModel: 'HRTF',
    refDistance: 100,     // 参考距离
    maxDistance: 1000,    // 最大距离
    rolloffFactor: 1,    // 衰减速率
    distanceModel: 'inverse', // 'linear' | 'inverse' | 'exponential'
}, id);

音频格式选择

格式压缩浏览器支持推荐用途
MP3有损全部背景音乐
OGG有损Chrome/Firefox备选格式
WebM有损现代浏览器高压缩率
WAV无损全部短音效

💡 提示:建议同时提供 MP3 和 OGG 两种格式。MP3 保证 Safari 兼容,OGG 在 Chrome/Firefox 中更小。

const bgm = new Howl({
    src: ['/file/bgm.mp3', '/file/bgm.ogg'],
    // Howler 自动选择浏览器支持的最佳格式
});

音频加载策略

class AudioLoader {
    constructor() {
        this.sounds = new Map();
    }

    // 预加载关键音效
    async preload(manifest) {
        const promises = [];

        for (const [name, config] of Object.entries(manifest)) {
            const sound = new Howl({
                src: config.src,
                preload: true,
                sprite: config.sprite,
                volume: config.volume ?? 1,
                loop: config.loop ?? false,
            });

            this.sounds.set(name, sound);

            promises.push(new Promise((resolve, reject) => {
                sound.once('load', resolve);
                sound.once('loaderror', reject);
            }));
        }

        return Promise.all(promises);
    }

    get(name) {
        return this.sounds.get(name);
    }

    play(name, sprite) {
        const sound = this.sounds.get(name);
        if (sound) return sound.play(sprite);
    }
}

// 使用
const audio = new AudioLoader();
await audio.preload({
    bgm:       { src: ['/file/bgm.mp3', '/file/bgm.ogg'], loop: true, volume: 0.4 },
    shoot:     { src: ['/file/sfx.mp3'], sprite: { shoot: [0, 300] } },
    explosion: { src: ['/file/sfx.mp3'], sprite: { explosion: [500, 800] } },
});

背景音乐循环

class BGMManager {
    constructor() {
        this.current = null;
        this.currentName = '';
        this.targetVolume = 0.5;
    }

    play(name, src, volume = 0.5) {
        if (this.currentName === name && this.current?.playing()) return;

        // 停止当前音乐
        if (this.current) {
            this.fadeOut(this.current, 500).then(() => {
                this.current.stop();
            });
        }

        // 播放新音乐
        this.currentName = name;
        this.current = new Howl({
            src,
            loop: true,
            volume: 0,
        });

        this.current.play();
        this.fadeIn(this.current, 1000, volume);
    }

    fadeIn(sound, duration, targetVolume) {
        sound.fade(0, targetVolume, duration);
    }

    fadeOut(sound, duration) {
        return new Promise((resolve) => {
            const currentVol = sound.volume();
            sound.fade(currentVol, 0, duration);
            sound.once('fade', resolve);
        });
    }

    pause() {
        if (this.current?.playing()) {
            this.current.pause();
        }
    }

    resume() {
        if (this.current && !this.current.playing()) {
            this.current.play();
        }
    }
}

// 使用
const bgmManager = new BGMManager();

// 进入战斗
bgmManager.play('battle', ['/file/battle.mp3', '/file/battle.ogg'], 0.6);

// 回到探索
bgmManager.play('explore', ['/file/explore.mp3', '/file/explore.ogg'], 0.4);

音效触发

射击音效

const shootSfx = new Howl({
    src: ['/file/shoot.mp3'],
    volume: 0.3,
    rate: 1.0,
});

function onShoot() {
    // 随机微调音高,避免听觉疲劳
    const rate = 0.9 + Math.random() * 0.2; // 0.9-1.1
    shootSfx.rate(rate);
    shootSfx.play();
}

爆炸音效

const explosionSfx = new Howl({
    src: ['/file/explosion.mp3'],
    volume: 0.6,
});

function onExplosion(x, y) {
    const id = explosionSfx.play();

    // 3D 空间音效
    if (Howler.usingWebAudio) {
        explosionSfx.pos(x, y, 0, id);

        // 根据距离衰减音量
        const dx = x - player.x;
        const dy = y - player.y;
        const dist = Math.sqrt(dx * dx + dy * dy);
        const maxDist = 800;
        const volume = Math.max(0, 1 - dist / maxDist);
        explosionSfx.volume(volume * 0.6, id);
    }
}

UI 点击音效

const uiClickSfx = new Howl({
    src: ['/file/click.mp3'],
    volume: 0.4,
});

// 全局按钮音效
function addClickSound(button) {
    button.on('pointerdown', () => {
        uiClickSfx.play();
    });
}

音频淡入淡出

// 淡入
const sound = new Howl({ src: ['/file/bgm.mp3'], volume: 0 });
sound.play();
sound.fade(0, 0.8, 2000); // 2 秒从 0 淡入到 0.8

// 淡出
sound.fade(0.8, 0, 1000); // 1 秒从 0.8 淡出到 0
sound.once('fade', () => {
    sound.stop();
});

// 场景切换时的音频过渡
async function sceneTransition() {
    const currentBgm = bgmManager.current;
    if (currentBgm) {
        currentBgm.fade(currentBgm.volume(), 0, 500);
    }
    // ... 切换场景 ...
    newBgm.play();
    newBgm.fade(0, 0.5, 1000);
}

音频管理器封装

class AudioManager {
    private sounds: Map<string, Howl> = new Map();
    private bgmManager: BGMManager;
    private sfxVolume = 1.0;
    private bgmVolume = 0.5;
    private masterVolume = 1.0;
    private muted = false;

    constructor() {
        this.bgmManager = new BGMManager();
    }

    // 注册音效
    registerSfx(name: string, src: string[], sprite?: Record<string, [number, number]>) {
        this.sounds.set(name, new Howl({
            src,
            sprite,
            volume: this.sfxVolume,
        }));
    }

    // 播放音效
    playSfx(name: string, sprite?: string): number | null {
        if (this.muted) return null;
        const sound = this.sounds.get(name);
        if (sound) {
            const id = sound.play(sprite);
            sound.volume(this.sfxVolume * this.masterVolume, id);
            return id;
        }
        return null;
    }

    // 播放背景音乐
    playBgm(name: string, src: string[]) {
        if (this.muted) return;
        this.bgmManager.play(name, src, this.bgmVolume * this.masterVolume);
    }

    // 设置主音量
    setMasterVolume(v: number) {
        this.masterVolume = Math.max(0, Math.min(1, v));
        Howler.volume(this.masterVolume);
    }

    // 设置音效音量
    setSfxVolume(v: number) {
        this.sfxVolume = Math.max(0, Math.min(1, v));
    }

    // 设置背景音乐音量
    setBgmVolume(v: number) {
        this.bgmVolume = Math.max(0, Math.min(1, v));
        if (this.bgmManager.current) {
            this.bgmManager.current.volume(this.bgmVolume * this.masterVolume);
        }
    }

    // 静音切换
    toggleMute() {
        this.muted = !this.muted;
        Howler.mute(this.muted);
    }

    // 暂停所有
    pauseAll() {
        Howler.volume(0);
    }

    // 恢复所有
    resumeAll() {
        Howler.volume(this.masterVolume);
    }
}

// 全局实例
const audioManager = new AudioManager();

// 注册所有音效
audioManager.registerSfx('shoot', ['/file/shoot.mp3']);
audioManager.registerSfx('explosion', ['/file/explosion.mp3']);
audioManager.registerSfx('coin', ['/file/coin.mp3']);
audioManager.registerSfx('jump', ['/file/jump.mp3']);

// 使用
player.on('shoot', () => audioManager.playSfx('shoot'));
player.on('jump', () => audioManager.playSfx('jump'));
enemy.on('death', () => audioManager.playSfx('explosion'));

// 切换背景音乐
audioManager.playBgm('explore', ['/file/explore.mp3', '/file/explore.ogg']);

设置界面集成

// 音量滑块
function createVolumeSlider(label, initialValue, onChange) {
    const container = new Container();

    const text = new Text({
        text: label,
        style: { fontSize: 14, fill: '#ffffff' },
    });
    container.addChild(text);

    const sliderBg = new Graphics();
    sliderBg.rect(0, 0, 150, 8);
    sliderBg.fill(0x555555);
    sliderBg.x = 100;
    sliderBg.y = 4;
    container.addChild(sliderBg);

    const sliderFill = new Graphics();
    sliderFill.rect(0, 0, 150 * initialValue, 8);
    sliderFill.fill(0x00aaff);
    sliderFill.x = 100;
    sliderFill.y = 4;
    container.addChild(sliderFill);

    sliderBg.eventMode = 'static';
    sliderBg.on('pointerdown', (e) => {
        const local = sliderBg.toLocal(e.global);
        const ratio = Math.max(0, Math.min(1, local.x / 150));
        sliderFill.clear();
        sliderFill.rect(0, 0, 150 * ratio, 8);
        sliderFill.fill(0x00aaff);
        onChange(ratio);
    });

    return container;
}

// 设置菜单中
const masterSlider = createVolumeSlider('主音量', 1.0, (v) => audioManager.setMasterVolume(v));
const bgmSlider = createVolumeSlider('背景音乐', 0.5, (v) => audioManager.setBgmVolume(v));
const sfxSlider = createVolumeSlider('音效', 1.0, (v) => audioManager.setSfxVolume(v));

masterSlider.y = 200;
bgmSlider.y = 230;
sfxSlider.y = 260;
settingsPanel.addChild(masterSlider, bgmSlider, sfxSlider);

扩展阅读