C++游戏开发之旅 24

问题概述

不用问了,问个毛,没声音,解决加声音。

方案:

image-20260302211357590

核心思想:

  • 构建引擎层音频模块(AudioPlayer)
  • 创建音频组件(AudioComponent)
  • 实现数据驱动的音效配置
  • 为游戏各处添加音效和音乐
音频系统架构:
┌──────────────────┐
│   SDL_mixer      │ ← 底层音频库
│   (C风格API)      │
└────────┬─────────┘
         │ 封装
         ↓
┌──────────────────┐
│  AudioPlayer     │ ← 引擎层
│  (统一接口)       │   播放控制
└────────┬─────────┘
         │
    ┌────┴────┐
    │         │
    ↓         ↓
┌────────┐┌────────┐
│Audio   ││Context │ ← 使用者
│Comp    ││直接调用│
└────────┘└────────┘

第一部分:引擎层音频系统

首先,我们需要一个位于引擎层的核心模块(AudioPlayer),专门负责与底层音频库(SDL_mixer)打交道。

1.音频播放器:AudioPlayer

AudioPlayer职责

  • 音效管理

    • 播放音效(Chunk)
    • 多通道混音
  • 音乐管理

    • 播放背景音乐(Music)
    • 循环、淡入淡出
    • 暂停、继续、停止
  • 音量控制

    • 全局音效控制
    • 背景音乐控制

功能实现

  • 音效(Sound / Chunk) 短促的音效;可同时播放多个
  • 音乐(Music) 背景音乐;只能播放一个
  • 音量控制 调节音量;0.0 ~ 1.0 范围

src/engine/audio/audio_player.h

#pragma once 
#include <string>

namespace engine::resource {
class ResourceManager;
}

struct Mix_Chunk;
struct Mix_Music;

namespace engine::audio {
/**
 * @brief 控制音频播放的类
 * 提供播放音效和音乐的功能, 由 ResourceManager 管理资源
 */
class AudioPlayer final {
private:
    resource::ResourceManager* resource_manager_;
    std::string current_music_;

public:
    explicit AudioPlayer(resource::ResourceManager* resource_manager);
    ~AudioPlayer() = default;

    // 禁止拷贝和移动
    AudioPlayer(const AudioPlayer&) = delete;
    AudioPlayer& operator=(const AudioPlayer&) = delete;
    AudioPlayer(AudioPlayer&&) = delete;
    AudioPlayer& operator=(AudioPlayer&&) = delete;

    // 播放控制方法
    int playSound(const std::string& sound_path, int channel = -1);
    bool playMusic(const std::string& music_path, int loops = -1, int fade_in_ms = 0);
    void stopMusic(int fade_out_ms = 0);
    void pauseMusic();
    void resumeMusic();

    // 音量控制方法
    void setSoundVolume(float volume, int channel = -1);
    void setMusicVolume(float volume);
    float getMusicVolume();
    float getSoundVolume(int channel = -1);
};

}

单一职责原则

explicit AudioPlayer(engine::resource::ResourceManager* resource_manager);

AudioPlayer的构造函数接收一个 ResourceManager 指针,我们需要进行音乐播放就可以去资源管理器中找文件。

职责分离

ResourceManager 
 - 加载音频文件
 - 缓存音频资源
 - 管理资源生命周期
	↓
AduioPlayer
 - 播放音频
 - 控制音量
 - 管理播放状态

接口方法说明

方法 参数 说明
playSound sound_path, channel 播放音效,返回播放通道
playMusic music_path, loops, fade_in_ms 播放音乐,支持淡入
stopMusic fade_out_ms 停止音乐,支持淡出
pauseMusic 暂停音乐
resumeMusic 继续音乐
setMusicVolume volume (0.0~1.0) 设置音乐音量
setSoundVolume volume, channel 设置音效音量

2.AudioPlayer实现

src/engine/audio/audio_player.cpp

#include "audio_player.h"
#include <SDL3_mixer/SDL_mixer.h>
#include "../resource/resource_manager.h"
#include <spdlog/spdlog.h>
#include <glm/common.hpp>

namespace engine::audio {

    AudioPlayer::AudioPlayer(resource::ResourceManager *resource_manager)
    : resource_manager_(resource_manager)
    {
        if(!resource_manager_){
            spdlog::error("AudioPlayer 构造失败, ResourceManager is null");
        }
    }
    

    int AudioPlayer::playSound(const std::string &sound_path, int channel)
    {
        // 通过 ResourceManager 获取资源
        Mix_Chunk *chunk = resource_manager_->getSound(sound_path);
        if(!chunk){
            spdlog::error("AudioPlayer 无法获取音效 {} 播放", sound_path);
            return -1;
        }
        // 播放音效 which channel was used to play the sound, or -1 if sound could not be played
        int played_channel = Mix_PlayChannel(channel, chunk, 0);
        if(played_channel == -1){
            spdlog::error("AudioPlayer 无法播放音效 {}:{}", sound_path, SDL_GetError());
        }
        return played_channel;
    }

    bool AudioPlayer::playMusic(const std::string &music_path, int loops, int fade_in_ms)
    {
        // 如果当前音乐已经在播放,则不重复播放
        if(music_path == current_music_) return true;
        current_music_ = music_path;
        Mix_Music *music = resource_manager_->getMusic(music_path);
        if(!music){
            spdlog::error("AudioPlayer 无法获取音乐 {} 播放", music_path);
            return false;
        }
        // 先停止之前的音乐
        Mix_HaltMusic(); // Halt playing of the music stream.

        bool result = false;
        if(fade_in_ms > 0){
            result = Mix_FadeInMusic(music, loops, fade_in_ms); // 淡入播放音乐
        } else {
            result = Mix_PlayMusic(music, loops); // 播放音乐 0 means "play once and stop"
        }
        if(!result){
            spdlog::error("AudioPlayer 无法播放音乐 {}:{}", music_path, SDL_GetError());
        }
        return result;
    }

    void AudioPlayer::stopMusic(int fade_out_ms)
    {
        if(fade_out_ms > 0){
            Mix_FadeOutMusic(fade_out_ms); // 淡出停止音乐
        } else {
            Mix_HaltMusic(); // 停止播放音乐
        }
        spdlog::info("AudioPlayer 停止播放音乐");
    }

    void AudioPlayer::pauseMusic()
    {
        Mix_PauseMusic(); // Pause the music stream.
        spdlog::info("AudioPlayer 暂停播放音乐");
    }

    void AudioPlayer::resumeMusic()
    {
        Mix_ResumeMusic(); // Resume the music stream.
        spdlog::info("AudioPlayer 恢复播放音乐");
    }

    void AudioPlayer::setSoundVolume(float volume, int channel)
    {   // 将浮点音量(0-1)转换为SDL_mixer的音量(0-128)
        int sdl_volume = static_cast<int>(glm::max(0.0f, glm::min(1.0f, volume)) * MIX_MAX_VOLUME);
        Mix_Volume(channel, sdl_volume); // 为一个通道设置音量
        // If the specified volume is -1, this returns the current volume. 
        // If channel is -1, this returns the average of all channels
        spdlog::trace("AudioPlayer: 设置通道 {} 的音量为 {:.2f}。", channel, volume);
    }

    void AudioPlayer::setMusicVolume(float volume)
    {
        int sdl_volume = static_cast<int>(glm::max(0.0f, glm::min(1.0f, volume)) * MIX_MAX_VOLUME);
        Mix_VolumeMusic(sdl_volume); // 设置音乐音量
        spdlog::trace("AudioPlayer: 设置音乐音量为 {:.2f}。", volume);
    }

    float AudioPlayer::getMusicVolume()
    {
        int volume = Mix_VolumeMusic(-1);
        return static_cast<float>(volume) / MIX_MAX_VOLUME;
    }

    float AudioPlayer::getSoundVolume(int channel)
    {
        int volume = Mix_Volume(channel, -1); // 获取指定通道的音量
        return static_cast<float>(volume) / MIX_MAX_VOLUME;
    }
}

核心功能解析

  • playSound --- 从ResourceManager获取音效getSound(sound_path) -> 检查资源是否有效if(!chunk) -> 调用 SDL_mixer 播放 Mix_PlayChannel(channel,chunk,0) -> 返回播放通道号
  • playMusic --- 检查和当前播放的音乐是否相同 if(music_path == current_music_ -> 从ResourceManager获取音乐 getMusic(music_path) -> 停止之前的音乐 Mix_HaltMusic() -> 根据参数选择播放形式 fade_in_ms > 0 是否淡入

设置音量

因为SDL_mixer中的音量是0128的整型,但是我们在设置的时候一般都是用浮点数进行设置的0.01.0,会有以下转换方式

int sdl_volume = static_cast<int>(glm::max(0.0f, glm::min(1.0f, volume)) * MIX_MAX_VOLUME);

C++封装的优势

AudioPlayer 将 SDL_mixer 那些C风格的、需要处理各种指针和返回码的函数,封装成了简洁、易用的C++接口

就用播放Sound音效举例:我们通过封装手段,将Mix_Chunk这样的音效片段存储在了ResourceManager下的AduioManager中,里面有个unordered_map,通过智能指针,以及自定义删除器,安全的处理缓存的音频资源的存放和资源释放;而我们需要进行播放音频的时候,只要通过资源管理器ResourceManager去获取相应的资源就行了

C风格 vs C++封装:
┌─────────────────────┐
│ SDL_mixer(C风格): │
│ Mix_Chunk* chunk =  │
│   Mix_LoadWAV(path);│
│ if (!chunk) { ... } │
│ Mix_PlayChannel(    │
│   -1, chunk, 0);    │
│ 需要手动管理资源    │
└─────────────────────┘

┌─────────────────────┐
│ AudioPlayer(C++): │
│ audio_player.       │
│   playSound(path);  │
│                     │
│ 简洁、安全、易用    │
└─────────────────────┘

第二部分:引擎集成与组件化

renderphysicsEngine一样,audioPlayer也是引擎的核心服务之一,我们需要在GameApp中创建实例,并将其添加到上下文Context中,以便游戏各处能方便的访问。

3.集成到引擎核心

首先,我们需要在GameApp类中创建AudioPlayer,然后初始化,需要注意顺序,这个initAudioPlayer()需要在initResourceManager()之后initContext()之前;完成后为Context添加AudioPlayer的引用。

引擎核心服务总览

引擎核心服务:
┌──────────────────────┐
│      GameApp         │
├──────────────────────┤
│ • ResourceManager    │ ← 资源管理
│ • Renderer           │ ← 渲染
│ • PhysicsEngine      │ ← 物理
│ • AudioPlayer        │ ← 音频(新增)✨
└────────┬─────────────┘
         │
         ↓
┌──────────────────────┐
│      Context         │
│  (全局访问接口)      │
└──────────────────────┘

现在,我们有了一个全局可访问的音频播放器,为了让每个GameObject都能管理自己的声音,我们需要一个AudioComponent

4.音频组件:AudioComponent

src/engine/component/audio_component.h

#pragma once
#include "component.h"
#include <unordered_map>
#include <string>

namespace engine::audio {
class AudioPlayer;
}

namespace engine::render {
class Camera;
}

namespace engine::component {
class TransformComponent;

class AudioComponent final : public Component {
    friend class engine::object::GameObject;
private:
    engine::audio::AudioPlayer* audio_player_; // 音频播放器的非拥有指针
    engine::render::Camera* camera_; // 相机非拥有指针,用于音频空间定位
    engine::component::TransformComponent* transform_component_ = nullptr; // 缓存指针

    ///< @brief 音效id 到路径的映射表
    std::unordered_map<std::string, std::string> sound_id_to_path_; 

public:
    AudioComponent(engine::audio::AudioPlayer* audio_player, engine::render::Camera* camera);
    ~AudioComponent() override = default;

    // 禁止拷贝和移动
    AudioComponent(const AudioComponent&) = delete;
    AudioComponent& operator=(const AudioComponent&) = delete;
    AudioComponent(AudioComponent&&) = delete;
    AudioComponent& operator=(AudioComponent&&) = delete;

    /**
     * @brief 播放音效。
     * @param sound_path 音效文件的id (或路径)。
     * @param channel 要播放的特定通道,或 -1 表示第一个可用通道。
     * @param use_spatial 是否使用空间定位。
     */
    void playSound(const std::string& sound_id, int channel = -1, bool use_spatial = false);

    /**
     * @brief 添加音效到映射表。
     * @param sound_id 音效的标识符(针对本组件唯一即可)。
     * @param sound_path 音效文件的路径。
     */
    void addSound(const std::string& sound_id, const std::string& sound_path);

private:
    // 核心循环方法
    void init() override;
    void update(float, engine::core::Context&) override {};
};

}

逻辑ID与资源路径解耦

std::unordered_map<std::string, std::string> sound_id_to_path_;

AudioComponent引入了一个设计—逻辑ID与资源路径解耦

逻辑ID映射:
┌──────────────────────┐
│ sound_id_to_path_    │
├──────────────────────┤
│ "jump" → "assets/    │
│   audio/jump.mp3"    │
│                      │
│ "hurt" → "assets/    │
│   audio/hurt.mp3"    │
│                      │
│ "dead" → "assets/    │
│   audio/dead.mp3"    │
└──────────────────────┘

这样可以用逻辑ID代替完整的路径,如果要修改声音,只需要修改映射表。

使用对比:
┌─────────────────────┐
│ 硬编码路径(❌):   │
│ playSound("assets/  │
│   audio/jump.mp3"); │
│ • 路径硬编码        │
│ • 难以维护          │
└─────────────────────┘

┌─────────────────────┐
│ 逻辑ID(✅):       │
│ playSound("jump");  │
│ • 简洁清晰          │
│ • 易于维护          │
│ • 支持配置          │
└─────────────────────┘

空间音频预留

void playSound(const std::string& sound_id, int channel = -1, bool use_spatial = false);

playSound函数还包含了一个use_spatial 参数,虽然SDL_mixer对3D空间音效的支持有限,但已经预留了这个接口,我们可以通过物理离相机中心足够近时才播放声音。

物体在相机视野内:
[C]--------[P]
相机		  玩家
距离 < 阈值
播放音效
---------------------
物体在相机视野外:
[C]---------------[P]
相机		  		玩家
距离 > 阈值
不播放

src/engine/component/audio_component.cpp

#include "audio_component.h"

#include "spdlog/spdlog.h"
#include "../object/game_object.h"
#include "../component/transform_component.h"
#include "../audio/audio_player.h"
#include "../render/camera.h"

namespace engine::component {
    AudioComponent::AudioComponent(engine::audio::AudioPlayer *audio_player, engine::render::Camera *camera)
    : audio_player_(audio_player), camera_(camera)
    {
        if(!audio_player_ || !camera_) {
            spdlog::error("AudioComponent 初始化失败,缺少 AudioPlayer 或 Camera");
        }
    }

    void AudioComponent::init()
    {
        if (!owner_) {
        spdlog::error("AudioComponent 没有所有者 GameObject!");
        return;
        }
        transform_ = owner_->getComponent<TransformComponent>();
        if (!transform_) {
            spdlog::warn("AudioComponent 所在的 GameObject 上没有 TransformComponent!无法进行空间定位");
        }
    }

    void AudioComponent::playSound(const std::string &sound_id, int channel, bool use_spatial)
    {
        // 通过 sound_id 获取 sound_path, 如果找不到则使用 sound_id 作为 sound_path
        auto sound_path = sound_id_to_path_.find(sound_id) != sound_id_to_path_.end() ? sound_id_to_path_[sound_id] : sound_id;
        
        if(use_spatial && transform_) {
            auto camera_center = camera_->getPosition() + camera_->getViewportSize() / 2.0f;
            auto obj_pos = transform_->getPosition();
            auto distance = glm::distance(camera_center, obj_pos);
            if(distance < 150.0f){
                audio_player_->playSound(sound_path, channel);
            }
        } else { // 如果不使用空间定位,则直接播放声音
            audio_player_->playSound(sound_path, channel);
        }
    }

    void AudioComponent::addSound(const std::string &sound_id, const std::string &sound_path)
    {
        auto it = sound_id_to_path_.find(sound_id);
        if(it != sound_id_to_path_.end()){
            spdlog::warn("AudioComponent 已经存在 sound_id: {}这种音效, 覆盖之前的路径", sound_id);
        }
        sound_id_to_path_[sound_id] = sound_path;
        spdlog::debug("AudioComponent 添加 sound_id: {}, sound_path: {}", sound_id, sound_path);
    }
    
}

第三部分:数据驱动的音效配置

为了实现音效的数据驱动,我们仿造之前动画的设置方式。

5.在Tiled中配置音效

定义一个新的自定义属性"sound",是一个JSON格式的字符串

// 玩家 player
{
  "jump":"assets/audio/cartoon-jump-6462.mp3",
  "hurt":"assets/audio/monster.mp3",
  "dead":"assets/audio/dead-8bit-41400.mp3"
}
// 青蛙 frog
{
  "cry":"assets/audio/frog_quak-81741.mp3"
}

level_loader中定义一个新的工具函数,用于添加音效的映射。我们需要在进行对象层处理函数部分类似于动画的处理方式补充上添加音效。

// 添加音效映射
void addSoundMapping(const nlohmann::json& sound_json, engine::component::AudioComponent* audio_comp);

void LevelLoader::addSoundMapping(const nlohmann::json &sound_json, engine::component::AudioComponent *audio_comp)
{
    if(!sound_json.is_object() || !audio_comp){
        spdlog::error("json对象异常或是音频组件为空");
        return;
    }
    for(const auto& item: sound_json.items()) {
        auto sound_id = item.key();
        auto sound_path = item.value();
        if(!sound_path.is_string()){
            spdlog::error("音频{}的值不是字符串", sound_id);
            continue;
        }
        // 添加音频映射
        audio_comp->addSound(sound_id, sound_path.get<std::string>());
    }
}

// 自定义属性那一块需要添加音效
// 获取音效
auto sound = getTileProperty<std::string>(tile_json,"sound");
if(sound){
    nlohmann::json sound_json;
    try {
        sound_json = nlohmann::json::parse(sound.value());
    } catch (const nlohmann::json::parse_error& e) {
        spdlog::error("解析音效json出错: {}", e.what());
        continue;
    }
    // 添加音频组件 
    auto audio_comp = obj->addComponent<component::AudioComponent>(
        &scene.getContext().getAudioPlayer(), 
        &scene.getContext().getCamera());
    // 添加映射表
    addSoundMapping(sound_json, audio_comp);
}

这样我们就实现了在Tiled编辑器中通过json数据以实现音频效果的数据驱动了。

第四部分:在游戏中发生

6.背景音乐与全局音量

GameScene::init中,我们可以启动背景音乐并设置一个适合的音量。

// in src/game/scene/game_scene.cpp
void GameScene::init() {
    // ...
    // 设置音量
    context_.getAudioPlayer().setMusicVolume(0.2f);  // 设置背景音乐音量为20%
    context_.getAudioPlayer().setSoundVolume(0.5f);  // 设置音效音量为50%
    // 播放背景音乐 (循环,淡入1秒)
    context_.getAudioPlayer().playMusic("assets/audio/hurry_up_and_run.ogg", -1, 1000);

    Scene::init();
    spdlog::trace("GameScene 初始化完成。");
}

7.玩家状态音效

我们为玩家添加了AudioComponent组件,这使得我们播放音效十分容易,只需要缓存AudioComponent,然后在进入状态的时候enter()函数中调用。

8.敌人AI音效

同样地,我们让 AIComponent 也能获取 AudioComponent。然后在 JumpBehavior 中,当敌人落地时,播放它的叫声。

void JumpBehavior::update(float dt, AIComponent &ai_component)
{
    auto* pc = ai_component.getPhysicsComponent();
    auto* tc = ai_component.getTransformComponent();
    auto* sc = ai_component.getSpriteComponent();
    auto* ac = ai_component.getAnimationComponent();
    if(!pc || !tc || !sc || !ac){
        spdlog::error("缺少必要组件,无法执行巡逻行为");
        return;
    }

    auto current_x = tc->getPosition().x;
    auto is_on_ground = pc->hasCollidedBelow();

    if(is_on_ground){
        // 蛤蟆在地上的时候,开始叫 注意顺序,得一开始就判断
        if(jump_timer_ < 0.001f){
            if(ai_component.getAudioComponent()){
                ai_component.getAudioComponent()->playSound("cry", -1, true);
            }
        }

        // 跳跃计时器
        jump_timer_ += dt;
        pc->velocity_ = {0,0};


        // 跳跃逻辑
        if(jump_timer_ > jump_interval_){
            jump_timer_ = 0;

            // 检查转向逻辑
            if(!jumping_right_ && current_x < patrol_min_x_ || pc->hasCollidedLeft()){
                jumping_right_ = true;
            } else if(jumping_right_ && current_x > patrol_max_x_ || pc->hasCollidedRight()){
                jumping_right_ = false;
            }
            auto jump_vel_x = jumping_right_ ? jump_vel_.x : -jump_vel_.x;
            pc->velocity_ = {jump_vel_x,jump_vel_.y};
            ac->playAnimation("jump");
        } else {
            ac->playAnimation("idle");
        }
    } else { // 在空中
        if(pc->velocity_.y < 0){
            ac->playAnimation("jump");
        } else{
            ac->playAnimation("fall");
        }
    }
    // 方向设置
    sc->setFlipped(jumping_right_);
}

这里我们使用了空间音频,也就是说当这个蛤蟆与相机屏幕视口中心位置的距离小于一定的像素才播放音效。

9.场景时间音效

有些音效不属于任何特定对象,而是由场景中的事件触发,比如吃到道具、踩死敌人。这类音效可以直接通过 Context 来播放。

// in src/game/scene/game_scene.cpp
// 玩家踩到敌人
void GameScene::PlayerVSEnemyCollision(...) {
    // ...
    if (is_stomp) {
        // ...
        player->getComponent<engine::component::PhysicsComponent>()->velocity_.y = -300.0f;
        context_.getAudioPlayer().playSound("assets/audio/punch2a.mp3");
    }
    // ...
}

// 玩家吃到道具
void GameScene::PlayerVSItemCollision(...) {
    // ...
    item->setNeedRemove(true);
    createEffect(item_aabb.position + item_aabb.size / 2.0f, item->getTag());
    context_.getAudioPlayer().playSound("assets/audio/poka01.mp3");
}

这里我们也可以看到,音频播放可以有不同的方式,如果游戏对象是有AudioComponent组件的,那么这个组件中可以设置相应的逻辑id与播放路径的映射表,我们可以通过输入逻辑id来实现播放,此外,也可以通过Context上下文输入完整路径直接播放。

编译运行

场景中终于有了音乐和音效,我艹,太好了!

章节总结

完整的音频系统架构

音频系统层次结构:
┌──────────────────────────┐
│      SDL_mixer           │ ← 底层库
│   (C风格音频API)         │
└────────┬─────────────────┘
         │ 封装
         ↓
┌──────────────────────────┐
│    ResourceManager       │ ← 资源层
│   (加载和缓存音频)       │
└────────┬─────────────────┘
         │ 提供资源
         ↓
┌──────────────────────────┐
│     AudioPlayer          │ ← 引擎层
│   (统一播放接口)         │
└────────┬─────────────────┘
         │
    ┌────┴────┬─────────┐
    │         │         │
    ↓         ↓         ↓
┌────────┐┌────────┐┌────────┐
│Audio   ││Context ││Level   │ ← 使用层
│Comp    ││直接调用││Loader  │
└────────┘└────────┘└────────┘
posted @ 2026-03-05 21:36  wenyiGamecpp  阅读(45)  评论(0)    收藏  举报