diff --git a/.gitignore b/.gitignore index 6d2d5d5..94fad6d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,9 @@ IMPLEMENTATION_STATUS.md .cache en.subject.pdf saves/ +ft_minecraft +docs/ + +# Audio assets (>42MB total) — use scripts/download_assets.sh +assets/music/ +assets/sounds/ diff --git a/CMakeLists.txt b/CMakeLists.txt index fcbf6d1..d70bad8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -55,6 +55,31 @@ if(FT_MINECRAFT_FETCH_DEPS) INTERFACE ${fastnoiselite_SOURCE_DIR}/Cpp ) + + # ── miniaudio (single-header audio engine) ───────────────────────────────── + FetchContent_Declare( + miniaudio + GIT_REPOSITORY https://github.com/mackron/miniaudio.git + GIT_TAG 0.11.22 + GIT_SHALLOW TRUE + ) + FetchContent_GetProperties(miniaudio) + if(NOT miniaudio_POPULATED) + FetchContent_Populate(miniaudio) + endif() + + # ── stb (provides stb_vorbis.c for OGG decoding) ─────────────────────────── + FetchContent_Declare( + stb + GIT_REPOSITORY https://github.com/nothings/stb.git + GIT_TAG master + GIT_SHALLOW TRUE + ) + FetchContent_GetProperties(stb) + if(NOT stb_POPULATED) + FetchContent_Populate(stb) + endif() + else() find_package(glfw3 REQUIRED) find_package(glm CONFIG REQUIRED) @@ -88,6 +113,11 @@ add_executable( srcs/network/client.cpp srcs/mob/mob_manager.cpp srcs/mob/arrow_manager.cpp + # ── Audio system ────────────────────────────────────────────────────────── + srcs/audio/audio_impl.cpp + srcs/audio/audio_manager.cpp + srcs/audio/biome_audio_system.cpp + srcs/audio/sound_emitter.cpp ) target_include_directories( @@ -96,6 +126,8 @@ target_include_directories( ${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_CURRENT_SOURCE_DIR}/srcs ${glfw_SOURCE_DIR}/deps + ${miniaudio_SOURCE_DIR} + ${stb_SOURCE_DIR} ) target_compile_definitions(ft_minecraft PRIVATE GLFW_INCLUDE_NONE) @@ -108,6 +140,8 @@ target_link_libraries( glm::glm ft_minecraft_fastnoise Threads::Threads + ${CMAKE_DL_LIBS} # miniaudio uses dlopen on Linux for audio backends + m # math library (miniaudio, stb_vorbis) ) set_target_properties( diff --git a/scripts/download_assets.sh b/scripts/download_assets.sh new file mode 100755 index 0000000..da45de1 --- /dev/null +++ b/scripts/download_assets.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# download_assets.sh +# +# 使い方: +# bash scripts/download_assets.sh +# +# URL の拡張子は自動判定(ogg / mp3 / wav / flac すべてOK)。 +# "" のままにすれば無音 WAV で自動補完。 + +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +# ============================================================================= +# ここに URL を書く(不要なら "" のままでOK) +# ============================================================================= + +# ── BGM ────────────────────────────────────────────────────────────────────── +# CC-BY 3.0: RPG Exploration Level 1 (HitCtrl) +URL_MUSIC_PLAINS="https://opengameart.org/sites/default/files/RPG_Exploration_Level_1.ogg" +# CC0: Desert theme / caravan (Loyalty Freak Music) +URL_MUSIC_DESERT="https://opengameart.org/sites/default/files/caravan.ogg.ogg" +# CC0: Ice Shine Bells (hc) +URL_MUSIC_TUNDRA="https://opengameart.org/sites/default/files/Ice-Shine-Bells_0.ogg" +# CC0: Cave Theme (Brandon Morris) +URL_MUSIC_ROCKY="https://opengameart.org/sites/default/files/cave%20themeb4.ogg" +# CC-BY 3.0: Hazy Darkness Looping (Eric Matyas) +URL_MUSIC_SWAMP="https://opengameart.org/sites/default/files/hazy_darkness_looping_0.ogg" +# CC-BY-SA 3.0: DarkWinds orchestral (Zefz) +URL_MUSIC_MOUNTAIN="https://opengameart.org/sites/default/files/DarkWinds_0.OGG" +# CC0: Birds and Wind - Ambient (Spring Spring) +URL_MUSIC_CANYON="https://opengameart.org/sites/default/files/Birds%20and%20Wind%20-%20Ambient_1.ogg" +# CC0: Flowerbed Fields (Zane Little Music) +URL_MUSIC_SPRING="https://opengameart.org/sites/default/files/flowerbed_fields.ogg" +# CC-BY 3.0: The Last Leaf of the Fall (HitCtrl) +URL_MUSIC_AUTUMN="https://opengameart.org/sites/default/files/the_last_leaf_of_the_fall.ogg" + +# ── アンビエント ────────────────────────────────────────────────────────────── +URL_AMBIENT_PLAINS="" +URL_AMBIENT_DESERT="" +URL_AMBIENT_TUNDRA="" +URL_AMBIENT_ROCKY="" +URL_AMBIENT_SWAMP="" +URL_AMBIENT_MOUNTAIN="" +URL_AMBIENT_CANYON="" +URL_AMBIENT_SPRING="" +URL_AMBIENT_AUTUMN="" + +# ── SE(効果音) ────────────────────────────────────────────────────────────── +# CC0: grass_1 (Delta12 Studio) +URL_SE_FOOTSTEP_GRASS="https://opengameart.org/sites/default/files/grass_1.ogg" +# CC0: snd_footsteps1 (Spring Spring) +URL_SE_FOOTSTEP_STONE="https://opengameart.org/sites/default/files/snd_footsteps1.wav" +# CC0: sand_footsteps (various) +URL_SE_FOOTSTEP_SAND="https://opengameart.org/sites/default/files/sand_footsteps_0.mp3" +# CC0: tap_stone で代用 (雪は直リンクなし) +URL_SE_FOOTSTEP_SNOW="https://opengameart.org/sites/default/files/tap_stone.wav" +# CC0: grass_1 で代用 (木材は直リンクなし) +URL_SE_FOOTSTEP_WOOD="https://opengameart.org/sites/default/files/grass_1.ogg" +# CC0: punch_1 (Delta12 Studio) +URL_SE_ATTACK="https://opengameart.org/sites/default/files/punch_1.ogg" +# CC0: hurt_1 (Delta12 Studio) +URL_SE_HURT="https://opengameart.org/sites/default/files/hurt_1.ogg" +# CC0: swim (Skippy Fish) +URL_SE_SWIM="https://opengameart.org/sites/default/files/swim.ogg" +# CC0: small_rock_impact (Spring Spring) +URL_SE_BLOCK_BREAK="https://opengameart.org/sites/default/files/small_rock_impact.wav" +# CC0: tap_stone (Spring Spring) +URL_SE_BLOCK_PLACE="https://opengameart.org/sites/default/files/tap_stone.wav" +# CC0: zombie groan (saturn91.dev) +URL_SE_MOB_GROAN="https://opengameart.org/sites/default/files/01_zombey.mp3" +# CC0: zombie_pain (various) +URL_SE_MOB_HURT="https://opengameart.org/sites/default/files/zombie_pain.ogg" +# CC0: explosion1 (various) +URL_SE_MOB_EXPLODE="https://opengameart.org/sites/default/files/explosion1_0.ogg" + +# ============================================================================= +# 以下は変更不要 +# ============================================================================= + +mkdir -p \ + "$REPO_ROOT/assets/music" \ + "$REPO_ROOT/assets/sounds/ambient" \ + "$REPO_ROOT/assets/sounds" + +# URL の末尾から拡張子を取り出す(クエリ文字列を除去してから) +url_ext() { + local url="$1" + # クエリ文字列・フラグメントを除去してから拡張子を取得 + local path="${url%%\?*}" + path="${path%%\#*}" + echo "${path##*.}" +} + +# stem: 拡張子なしのファイルパス(例: assets/music/plains_bgm) +# URL の拡張子でファイルを保存。stem.* がすでに存在すればスキップ。 +fetch() { + local url="$1" stem="$2" + [[ -z "$url" ]] && return 0 + + # 本物のファイルがすでにある(マーカーなし)ならスキップ + if [[ ! -f "${stem}.placeholder" ]]; then + for f in "${stem}".ogg "${stem}".mp3 "${stem}".wav "${stem}".flac; do + [[ -f "$f" ]] && return 0 + done + fi + + local ext + ext="$(url_ext "$url" | tr '[:upper:]' '[:lower:]')" + local dst="${stem}.${ext}" + echo " downloading $(basename "$dst") ..." + if command -v curl &>/dev/null; then + curl -fsSL -o "$dst" "$url" + else + wget -q -O "$dst" "$url" + fi + + # ダウンロード成功 → プレースホルダーを削除(実ダウンロードが .wav の場合は消さない) + rm -f "${stem}.placeholder" + if [[ "$ext" != "wav" ]]; then rm -f "${stem}.wav"; fi +} + +# BGM +fetch "$URL_MUSIC_PLAINS" "$REPO_ROOT/assets/music/plains_bgm" +fetch "$URL_MUSIC_DESERT" "$REPO_ROOT/assets/music/desert_bgm" +fetch "$URL_MUSIC_TUNDRA" "$REPO_ROOT/assets/music/tundra_bgm" +fetch "$URL_MUSIC_ROCKY" "$REPO_ROOT/assets/music/rocky_bgm" +fetch "$URL_MUSIC_SWAMP" "$REPO_ROOT/assets/music/swamp_bgm" +fetch "$URL_MUSIC_MOUNTAIN" "$REPO_ROOT/assets/music/mountain_bgm" +fetch "$URL_MUSIC_CANYON" "$REPO_ROOT/assets/music/canyon_bgm" +fetch "$URL_MUSIC_SPRING" "$REPO_ROOT/assets/music/spring_bgm" +fetch "$URL_MUSIC_AUTUMN" "$REPO_ROOT/assets/music/autumn_bgm" + +# Ambient +fetch "$URL_AMBIENT_PLAINS" "$REPO_ROOT/assets/sounds/ambient/plains" +fetch "$URL_AMBIENT_DESERT" "$REPO_ROOT/assets/sounds/ambient/desert" +fetch "$URL_AMBIENT_TUNDRA" "$REPO_ROOT/assets/sounds/ambient/tundra" +fetch "$URL_AMBIENT_ROCKY" "$REPO_ROOT/assets/sounds/ambient/rocky" +fetch "$URL_AMBIENT_SWAMP" "$REPO_ROOT/assets/sounds/ambient/swamp" +fetch "$URL_AMBIENT_MOUNTAIN" "$REPO_ROOT/assets/sounds/ambient/mountain" +fetch "$URL_AMBIENT_CANYON" "$REPO_ROOT/assets/sounds/ambient/canyon" +fetch "$URL_AMBIENT_SPRING" "$REPO_ROOT/assets/sounds/ambient/spring" +fetch "$URL_AMBIENT_AUTUMN" "$REPO_ROOT/assets/sounds/ambient/autumn" + +# SE +fetch "$URL_SE_FOOTSTEP_GRASS" "$REPO_ROOT/assets/sounds/footstep_grass" +fetch "$URL_SE_FOOTSTEP_STONE" "$REPO_ROOT/assets/sounds/footstep_stone" +fetch "$URL_SE_FOOTSTEP_SAND" "$REPO_ROOT/assets/sounds/footstep_sand" +fetch "$URL_SE_FOOTSTEP_SNOW" "$REPO_ROOT/assets/sounds/footstep_snow" +fetch "$URL_SE_FOOTSTEP_WOOD" "$REPO_ROOT/assets/sounds/footstep_wood" +fetch "$URL_SE_ATTACK" "$REPO_ROOT/assets/sounds/attack" +fetch "$URL_SE_HURT" "$REPO_ROOT/assets/sounds/hurt" +fetch "$URL_SE_SWIM" "$REPO_ROOT/assets/sounds/swim" +fetch "$URL_SE_BLOCK_BREAK" "$REPO_ROOT/assets/sounds/block_break" +fetch "$URL_SE_BLOCK_PLACE" "$REPO_ROOT/assets/sounds/block_place" +fetch "$URL_SE_MOB_GROAN" "$REPO_ROOT/assets/sounds/mob_groan" +fetch "$URL_SE_MOB_HURT" "$REPO_ROOT/assets/sounds/mob_hurt" +fetch "$URL_SE_MOB_EXPLODE" "$REPO_ROOT/assets/sounds/mob_explode" + +# 未ダウンロードのファイルは無音 WAV で補完 +echo "" +echo "Generating placeholders for missing files..." +ASSETS="$REPO_ROOT/assets" bash "$REPO_ROOT/scripts/gen_placeholder_assets.sh" + +echo "" +echo "Done." diff --git a/scripts/gen_placeholder_assets.sh b/scripts/gen_placeholder_assets.sh new file mode 100755 index 0000000..dfc41f2 --- /dev/null +++ b/scripts/gen_placeholder_assets.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# gen_placeholder_assets.sh +# +# 開発用: 無音の WAV プレースホルダーを生成する。 +# 生成したファイルには stem.placeholder マーカーを置く。 +# マーカーがあるファイルは download_assets.sh が URL 設定時に上書きする。 + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ASSETS="${ASSETS:-$REPO_ROOT/assets}" + +python3 - "$ASSETS" <<'PYEOF' +import struct, os, sys + +assets = sys.argv[1] + +def make_wav(stem, duration_s, sample_rate=44100, channels=1): + os.makedirs(os.path.dirname(stem), exist_ok=True) + + # 本物のファイルがあればスキップ(マーカーなし=本物) + for ext in ('.ogg', '.mp3', '.wav', '.flac'): + candidate = stem + ext + if os.path.exists(candidate) and not os.path.exists(stem + '.placeholder'): + return + + wav_path = stem + '.wav' + num_samples = int(sample_rate * duration_s * channels) + pcm = bytes(num_samples * 2) + with open(wav_path, 'wb') as f: + data_size = len(pcm) + f.write(b'RIFF') + f.write(struct.pack(' + +#if defined(__clang__) +# pragma clang diagnostic pop +#elif defined(__GNUC__) +# pragma GCC diagnostic pop +#endif diff --git a/srcs/audio/audio_manager.cpp b/srcs/audio/audio_manager.cpp new file mode 100644 index 0000000..da3421a --- /dev/null +++ b/srcs/audio/audio_manager.cpp @@ -0,0 +1,418 @@ +#include "audio/audio_manager.hpp" +#include +#include +#include +#include +#include + +// ── 拡張子なしの SE パス(stems) ──────────────────────────────────────────── +static const char* kSeStems[] = { + "sounds/footstep_grass", // FootstepGrass + "sounds/footstep_stone", // FootstepStone + "sounds/footstep_sand", // FootstepSand + "sounds/footstep_snow", // FootstepSnow + "sounds/footstep_wood", // FootstepWood + "sounds/attack", // Attack + "sounds/hurt", // Hurt + "sounds/swim", // Swim + "sounds/block_break", // BlockBreak + "sounds/block_place", // BlockPlace + "sounds/mob_groan", // MobGroan + "sounds/mob_hurt", // MobHurt + "sounds/mob_explode", // MobExplode +}; +static_assert(sizeof(kSeStems) / sizeof(kSeStems[0]) == + static_cast(SoundEvent::COUNT), + "kSeStems length mismatch"); + +// ogg / mp3 / wav / flac のどれかが存在するパスを返す。 +// 見つからなければ stem + ".ogg" を返す(AudioManager が警告を出す)。 +static std::string resolveAudioPath(const std::string& stem) { + static const char* kExts[] = {".ogg", ".mp3", ".wav", ".flac"}; + for (const char* ext : kExts) { + std::string candidate = stem + ext; + if (FILE* f = std::fopen(candidate.c_str(), "rb")) { + std::fclose(f); + return candidate; + } + } + return stem + ".ogg"; +} + +// ── BGM slot (one of two ping-pong slots) ───────────────────────────────────── +struct BgmSlot { + ma_sound sound = {}; + bool initialized = false; + bool playing = false; + std::string path; +}; + +// ── SE pool slot (3D one-shot sounds) ──────────────────────────────────────── +struct SeSlot { + ma_sound sound = {}; + bool initialized = false; +}; + +static constexpr int kSePoolSize = 16; + +// ── pImpl ───────────────────────────────────────────────────────────────────── +struct AudioManager::Impl { + ma_engine engine = {}; + ma_sound_group bgm_group = {}; + ma_sound_group ambient_group = {}; + ma_sound_group se_group = {}; + bool engine_ok = false; + bool bgm_grp_ok = false; + bool amb_grp_ok = false; + bool se_grp_ok = false; + + // Two BGM slots for crossfade (ping-pong) + BgmSlot bgm[2]; + int active_slot = -1; // slot currently playing (fading in or at full vol) + int fading_slot = -1; // slot currently fading out (-1 = none) + float fade_timer = 0.0f; // seconds until fading_slot is stopped + + // Ambient + ma_sound ambient_snd = {}; + bool ambient_ok = false; + bool ambient_fading_out = false; + float ambient_fade_timer = 0.0f; + std::string ambient_path; + + // 3D SE pool + std::array se_pool = {}; + + // Cached listener position (for distance culling in playSe3D) + float listener_x = 0.0f, listener_y = 0.0f, listener_z = 0.0f; + + std::string assets_root; + float master_vol = 1.0f; + float bgm_vol = 0.8f; + float se_vol = 1.0f; + float ambient_vol = 0.6f; +}; + +// ───────────────────────────────────────────────────────────────────────────── + +AudioManager::AudioManager() : impl_(std::make_unique()) {} +AudioManager::~AudioManager() { shutdown(); } + +bool AudioManager::init(const std::string& assets_root) { + Impl& d = *impl_; + d.assets_root = assets_root; + + ma_engine_config cfg = ma_engine_config_init(); + cfg.listenerCount = 1; + if (ma_engine_init(&cfg, &d.engine) != MA_SUCCESS) { + fprintf(stderr, "[Audio] ma_engine_init failed\n"); + return false; + } + d.engine_ok = true; + + auto init_group = [&](ma_sound_group& g, bool& ok, const char* name) -> bool { + if (ma_sound_group_init(&d.engine, 0, nullptr, &g) != MA_SUCCESS) { + fprintf(stderr, "[Audio] %s group init failed\n", name); + return false; + } + ok = true; + return true; + }; + if (!init_group(d.bgm_group, d.bgm_grp_ok, "BGM")) return false; + if (!init_group(d.ambient_group, d.amb_grp_ok, "Ambient")) return false; + if (!init_group(d.se_group, d.se_grp_ok, "SE")) return false; + + ma_engine_set_volume(&d.engine, d.master_vol); + ma_sound_group_set_volume(&d.bgm_group, d.bgm_vol); + ma_sound_group_set_volume(&d.ambient_group, d.ambient_vol); + ma_sound_group_set_volume(&d.se_group, d.se_vol); + + // Default listener orientation: looking -Z (OpenGL convention) + ma_engine_listener_set_direction(&d.engine, 0, 0.0f, 0.0f, -1.0f); + ma_engine_listener_set_world_up(&d.engine, 0, 0.0f, 1.0f, 0.0f); + + fprintf(stderr, "[Audio] init OK root='%s'\n", assets_root.c_str()); + return true; +} + +void AudioManager::shutdown() { + Impl& d = *impl_; + if (!d.engine_ok) return; + + for (auto& se : d.se_pool) + if (se.initialized) { ma_sound_uninit(&se.sound); se.initialized = false; } + + if (d.ambient_ok) { + ma_sound_stop(&d.ambient_snd); + ma_sound_uninit(&d.ambient_snd); + d.ambient_ok = false; + } + for (auto& slot : d.bgm) + if (slot.initialized) { + ma_sound_stop(&slot.sound); + ma_sound_uninit(&slot.sound); + slot.initialized = false; + } + + if (d.se_grp_ok) { ma_sound_group_uninit(&d.se_group); d.se_grp_ok = false; } + if (d.amb_grp_ok) { ma_sound_group_uninit(&d.ambient_group); d.amb_grp_ok = false; } + if (d.bgm_grp_ok) { ma_sound_group_uninit(&d.bgm_group); d.bgm_grp_ok = false; } + + ma_engine_uninit(&d.engine); + d.engine_ok = false; + fprintf(stderr, "[Audio] shutdown OK\n"); +} + +void AudioManager::update(float dt) { + Impl& d = *impl_; + if (!d.engine_ok) return; + + // Stop the fading-out BGM slot after crossfade completes + if (d.fading_slot >= 0 && d.fade_timer > 0.0f) { + d.fade_timer -= dt; + if (d.fade_timer <= 0.0f) { + d.fade_timer = 0.0f; + BgmSlot& s = d.bgm[d.fading_slot]; + if (s.initialized) { + ma_sound_stop(&s.sound); + ma_sound_uninit(&s.sound); + s.initialized = false; + s.playing = false; + } + d.fading_slot = -1; + } + } + + // Stop the fading-out ambient after fade completes + if (d.ambient_fading_out && d.ambient_fade_timer > 0.0f) { + d.ambient_fade_timer -= dt; + if (d.ambient_fade_timer <= 0.0f) { + d.ambient_fade_timer = 0.0f; + d.ambient_fading_out = false; + if (d.ambient_ok) { + ma_sound_stop(&d.ambient_snd); + ma_sound_uninit(&d.ambient_snd); + d.ambient_ok = false; + } + } + } + + // Reclaim SE pool slots that have finished playing + for (auto& se : d.se_pool) + if (se.initialized && ma_sound_at_end(&se.sound)) { + ma_sound_uninit(&se.sound); + se.initialized = false; + } +} + +// ── Volume ──────────────────────────────────────────────────────────────────── + +void AudioManager::setMasterVolume(float v) { + impl_->master_vol = std::clamp(v, 0.0f, 1.0f); + if (impl_->engine_ok) ma_engine_set_volume(&impl_->engine, impl_->master_vol); +} +void AudioManager::setBgmVolume(float v) { + impl_->bgm_vol = std::clamp(v, 0.0f, 1.0f); + if (impl_->bgm_grp_ok) + ma_sound_group_set_volume(&impl_->bgm_group, impl_->bgm_vol); +} +void AudioManager::setSeVolume(float v) { + impl_->se_vol = std::clamp(v, 0.0f, 1.0f); + if (impl_->se_grp_ok) + ma_sound_group_set_volume(&impl_->se_group, impl_->se_vol); +} +void AudioManager::setAmbientVolume(float v) { + impl_->ambient_vol = std::clamp(v, 0.0f, 1.0f); + if (impl_->amb_grp_ok) + ma_sound_group_set_volume(&impl_->ambient_group, impl_->ambient_vol); +} + +float AudioManager::getMasterVolume() const { return impl_->master_vol; } +float AudioManager::getBgmVolume() const { return impl_->bgm_vol; } +float AudioManager::getSeVolume() const { return impl_->se_vol; } +float AudioManager::getAmbientVolume() const { return impl_->ambient_vol; } + +// ── Listener ────────────────────────────────────────────────────────────────── + +void AudioManager::setListenerPosition(float x, float y, float z) { + impl_->listener_x = x; impl_->listener_y = y; impl_->listener_z = z; + if (impl_->engine_ok) + ma_engine_listener_set_position(&impl_->engine, 0, x, y, z); +} +void AudioManager::setListenerDirection(float fx, float fy, float fz) { + if (impl_->engine_ok) + ma_engine_listener_set_direction(&impl_->engine, 0, fx, fy, fz); +} +void AudioManager::setListenerUp(float ux, float uy, float uz) { + if (impl_->engine_ok) + ma_engine_listener_set_world_up(&impl_->engine, 0, ux, uy, uz); +} + +// ── BGM crossfade ───────────────────────────────────────────────────────────── + +void AudioManager::playBgm(const std::string& path, float fade_duration) { + Impl& d = *impl_; + if (!d.engine_ok) return; + + // Skip if already playing this exact track + if (d.active_slot >= 0 && + d.bgm[d.active_slot].playing && + d.bgm[d.active_slot].path == path) + return; + + // Choose the slot that is NOT currently active + int next = (d.active_slot == 0) ? 1 : 0; + + // If next slot is still alive from a previous transition, evict it + if (d.bgm[next].initialized) { + if (next == d.fading_slot) d.fading_slot = -1; + ma_sound_stop(&d.bgm[next].sound); + ma_sound_uninit(&d.bgm[next].sound); + d.bgm[next].initialized = false; + d.bgm[next].playing = false; + } + + std::string full = resolveAudioPath(d.assets_root + "/" + path); + ma_uint32 flags = MA_SOUND_FLAG_STREAM | MA_SOUND_FLAG_NO_SPATIALIZATION; + if (ma_sound_init_from_file(&d.engine, full.c_str(), flags, + &d.bgm_group, nullptr, + &d.bgm[next].sound) != MA_SUCCESS) { + fprintf(stderr, "[Audio] BGM load failed: %s\n", full.c_str()); + return; + } + d.bgm[next].initialized = true; + d.bgm[next].playing = true; + d.bgm[next].path = path; + ma_sound_set_looping(&d.bgm[next].sound, MA_TRUE); + + // Fade new track in from silence + auto fade_ms = static_cast(fade_duration * 1000.0f); + ma_sound_set_fade_in_milliseconds(&d.bgm[next].sound, 0.0f, 1.0f, fade_ms); + ma_sound_start(&d.bgm[next].sound); + + // Fade old track out; retire any pre-existing fading slot immediately + if (d.active_slot >= 0 && d.bgm[d.active_slot].initialized) { + if (d.fading_slot >= 0 && d.fading_slot != d.active_slot && + d.bgm[d.fading_slot].initialized) { + ma_sound_stop(&d.bgm[d.fading_slot].sound); + ma_sound_uninit(&d.bgm[d.fading_slot].sound); + d.bgm[d.fading_slot].initialized = false; + d.bgm[d.fading_slot].playing = false; + } + ma_sound_set_fade_in_milliseconds( + &d.bgm[d.active_slot].sound, 1.0f, 0.0f, fade_ms); + d.fading_slot = d.active_slot; + d.fade_timer = fade_duration; + } + + d.active_slot = next; +} + +void AudioManager::stopBgm(float fade_out) { + Impl& d = *impl_; + if (!d.engine_ok || d.active_slot < 0) return; + + BgmSlot& s = d.bgm[d.active_slot]; + if (s.initialized && s.playing) { + auto fade_ms = static_cast(fade_out * 1000.0f); + ma_sound_set_fade_in_milliseconds(&s.sound, 1.0f, 0.0f, fade_ms); + d.fading_slot = d.active_slot; + d.fade_timer = fade_out; + } + d.active_slot = -1; +} + +// ── Ambient ─────────────────────────────────────────────────────────────────── + +void AudioManager::playAmbient(const std::string& path, float fade_in) { + Impl& d = *impl_; + if (!d.engine_ok) return; + if (d.ambient_ok && d.ambient_path == path) return; // already playing + + // Stop current ambient immediately (crossfade handled by BiomeAudioSystem timing) + if (d.ambient_ok) { + ma_sound_stop(&d.ambient_snd); + ma_sound_uninit(&d.ambient_snd); + d.ambient_ok = false; + d.ambient_fading_out = false; + d.ambient_fade_timer = 0.0f; + } + + std::string full = resolveAudioPath(d.assets_root + "/" + path); + ma_uint32 flags = MA_SOUND_FLAG_STREAM | MA_SOUND_FLAG_NO_SPATIALIZATION; + if (ma_sound_init_from_file(&d.engine, full.c_str(), flags, + &d.ambient_group, nullptr, + &d.ambient_snd) != MA_SUCCESS) { + fprintf(stderr, "[Audio] Ambient load failed: %s\n", full.c_str()); + return; + } + d.ambient_ok = true; + d.ambient_path = path; + ma_sound_set_looping(&d.ambient_snd, MA_TRUE); + auto fade_ms = static_cast(fade_in * 1000.0f); + ma_sound_set_fade_in_milliseconds(&d.ambient_snd, 0.0f, 1.0f, fade_ms); + ma_sound_start(&d.ambient_snd); +} + +void AudioManager::stopAmbient(float fade_out) { + Impl& d = *impl_; + if (!d.engine_ok || !d.ambient_ok || d.ambient_fading_out) return; + + auto fade_ms = static_cast(fade_out * 1000.0f); + ma_sound_set_fade_in_milliseconds(&d.ambient_snd, 1.0f, 0.0f, fade_ms); + d.ambient_fading_out = true; + d.ambient_fade_timer = fade_out; + d.ambient_path.clear(); +} + +// ── SE ──────────────────────────────────────────────────────────────────────── + +void AudioManager::playSe(SoundEvent event) { + Impl& d = *impl_; + if (!d.engine_ok) return; + + int idx = static_cast(event); + if (idx < 0 || idx >= static_cast(SoundEvent::COUNT)) return; + + std::string full = resolveAudioPath(d.assets_root + "/" + kSeStems[idx]); + if (ma_engine_play_sound(&d.engine, full.c_str(), &d.se_group) != MA_SUCCESS) + fprintf(stderr, "[Audio] playSe failed: %s\n", full.c_str()); +} + +void AudioManager::playSe3D(SoundEvent event, float x, float y, float z, + float max_distance) { + Impl& d = *impl_; + if (!d.engine_ok) return; + + // Distance cull + float dx = x - d.listener_x, dy = y - d.listener_y, dz = z - d.listener_z; + if (dx*dx + dy*dy + dz*dz > max_distance * max_distance) return; + + // Find a free SE pool slot + SeSlot* slot = nullptr; + for (auto& s : d.se_pool) if (!s.initialized) { slot = &s; break; } + if (!slot) return; // pool full; drop the sound + + int idx = static_cast(event); + if (idx < 0 || idx >= static_cast(SoundEvent::COUNT)) return; + + std::string full = resolveAudioPath(d.assets_root + "/" + kSeStems[idx]); + // Load without streaming (SE are short) and with spatialization enabled + if (ma_sound_init_from_file(&d.engine, full.c_str(), 0, + &d.se_group, nullptr, + &slot->sound) != MA_SUCCESS) + return; + + slot->initialized = true; + ma_sound_set_spatialization_enabled(&slot->sound, MA_TRUE); + ma_sound_set_position(&slot->sound, x, y, z); + ma_sound_set_attenuation_model(&slot->sound, ma_attenuation_model_inverse); + ma_sound_set_rolloff(&slot->sound, 1.0f); + ma_sound_set_min_distance(&slot->sound, 1.0f); + ma_sound_set_max_distance(&slot->sound, max_distance); + ma_sound_set_looping(&slot->sound, MA_FALSE); + ma_sound_start(&slot->sound); +} + +void* AudioManager::getEngineHandle() const { + return impl_->engine_ok ? &impl_->engine : nullptr; +} diff --git a/srcs/audio/audio_manager.hpp b/srcs/audio/audio_manager.hpp new file mode 100644 index 0000000..3277e21 --- /dev/null +++ b/srcs/audio/audio_manager.hpp @@ -0,0 +1,93 @@ +#pragma once +#include +#include +#include + +// One-shot sound effect categories. +// Paths mapped in audio_manager.cpp :: kSePaths[]. +enum class SoundEvent : uint8_t { + FootstepGrass = 0, + FootstepStone, + FootstepSand, + FootstepSnow, + FootstepWood, + Attack, + Hurt, + Swim, + BlockBreak, + BlockPlace, + MobGroan, + MobHurt, + MobExplode, + COUNT +}; + +// Central audio manager. +// +// Threading model: +// All public methods must be called from the main thread. +// miniaudio's audio callback runs on a dedicated audio thread internally; +// miniaudio's own API is thread-safe, so we do not need extra locking here. +// +// Usage: +// AudioManager audio; +// audio.init("assets"); +// // each frame: +// audio.setListenerPosition(px, py, pz); +// audio.setListenerDirection(fx, fy, fz); +// audio.update(dt); +class AudioManager { +public: + AudioManager(); + ~AudioManager(); + + // assets_root: directory that contains "music/" and "sounds/" subdirs. + bool init(const std::string& assets_root = "assets"); + void shutdown(); + + // Call once per frame from the main thread. + void update(float dt); + + // ── Volume [0.0, 1.0] ─────────────────────────────────────────────────── + void setMasterVolume(float v); + void setBgmVolume(float v); + void setSeVolume(float v); + void setAmbientVolume(float v); + + float getMasterVolume() const; + float getBgmVolume() const; + float getSeVolume() const; + float getAmbientVolume() const; + + // ── Listener (player camera) ───────────────────────────────────────────── + void setListenerPosition(float x, float y, float z); + void setListenerDirection(float fx, float fy, float fz); + void setListenerUp(float ux, float uy, float uz); + + // ── BGM: looping track with crossfade on switch ────────────────────────── + // path: relative to assets_root, e.g. "music/forest_bgm.ogg" + // fade_duration: seconds for both fade-in of new and fade-out of old track. + void playBgm(const std::string& path, float fade_duration = 4.0f); + void stopBgm(float fade_out = 2.0f); + + // ── Ambient: independent looping sound layer ───────────────────────────── + void playAmbient(const std::string& path, float fade_in = 2.0f); + void stopAmbient(float fade_out = 2.0f); + + // ── SE: 2D one-shot ────────────────────────────────────────────────────── + void playSe(SoundEvent event); + + // ── SE: 3D positional one-shot ─────────────────────────────────────────── + // Skipped automatically when the source is farther than max_distance. + // Uses inverse-distance attenuation (OpenAL-style). + void playSe3D(SoundEvent event, float x, float y, float z, + float max_distance = 32.0f); + + // ── Internal access for SoundEmitter ──────────────────────────────────── + // Returns the raw ma_engine* as void*. Used only by SoundEmitter. + void* getEngineHandle() const; + +private: + struct Impl; + std::unique_ptr impl_; +}; diff --git a/srcs/audio/biome_audio_system.cpp b/srcs/audio/biome_audio_system.cpp new file mode 100644 index 0000000..113e66d --- /dev/null +++ b/srcs/audio/biome_audio_system.cpp @@ -0,0 +1,58 @@ +#include "audio/biome_audio_system.hpp" +#include "audio/audio_manager.hpp" +#include + +// Biome names must match TerrainGenerator::biomeName() — all uppercase. +void BiomeAudioSystem::init(AudioManager& mgr) { + mgr_ = &mgr; + + // 拡張子は AudioManager 側で自動解決(ogg/mp3/wav/flac に対応) + registerBiome("PLAINS", {"music/plains_bgm", "sounds/ambient/plains"}); + registerBiome("DESERT", {"music/desert_bgm", "sounds/ambient/desert"}); + registerBiome("TUNDRA", {"music/tundra_bgm", "sounds/ambient/tundra"}); + registerBiome("ROCKY", {"music/rocky_bgm", "sounds/ambient/rocky"}); + registerBiome("SWAMP", {"music/swamp_bgm", "sounds/ambient/swamp"}); + registerBiome("MOUNTAIN", {"music/mountain_bgm", "sounds/ambient/mountain"}); + registerBiome("CANYON", {"music/canyon_bgm", "sounds/ambient/canyon"}); + registerBiome("SPRING", {"music/spring_bgm", "sounds/ambient/spring"}); + registerBiome("AUTUMN", {"music/autumn_bgm", "sounds/ambient/autumn"}); +} + +void BiomeAudioSystem::registerBiome(const std::string& biome_name, + BiomeTrack track) { + tracks_[biome_name] = std::move(track); +} + +void BiomeAudioSystem::update(float dt, const char* current_biome) { + if (!mgr_ || !current_biome) return; + + // Debounce: prevent thrashing at biome borders + if (change_cooldown_ > 0.0f) { + change_cooldown_ -= dt; + return; + } + + std::string name(current_biome); + if (name == current_biome_) return; + + auto it = tracks_.find(name); + if (it == tracks_.end()) { + // Unknown biome; record it to avoid repeated warnings but don't crash + fprintf(stderr, "[BiomeAudio] No track for biome '%s'\n", name.c_str()); + current_biome_ = name; + return; + } + + fprintf(stderr, "[BiomeAudio] %s → %s\n", + current_biome_.empty() ? "(none)" : current_biome_.c_str(), + name.c_str()); + + const BiomeTrack& track = it->second; + if (!track.bgm_path.empty()) + mgr_->playBgm(track.bgm_path, fade_duration_); + if (!track.ambient_path.empty()) + mgr_->playAmbient(track.ambient_path, 2.0f); + + current_biome_ = name; + change_cooldown_ = kChangeCooldown; +} diff --git a/srcs/audio/biome_audio_system.hpp b/srcs/audio/biome_audio_system.hpp new file mode 100644 index 0000000..c03c0db --- /dev/null +++ b/srcs/audio/biome_audio_system.hpp @@ -0,0 +1,43 @@ +#pragma once +#include +#include + +class AudioManager; + +// BGM + ambient pair registered for one biome. +struct BiomeTrack { + std::string bgm_path; // e.g. "music/plains_bgm.ogg" + std::string ambient_path; // e.g. "sounds/ambient/plains.ogg" +}; + +// Maps biome names → BiomeTrack and drives BGM/ambient switching. +// +// Call init() once at startup, then update() every frame with the +// biome name returned by World::getBiomeNameAt(). +// +// Crossfade duration is forwarded to AudioManager::playBgm / playAmbient. +// A 2-second debounce prevents rapid switching at biome borders. +class BiomeAudioSystem { +public: + static constexpr float kDefaultFadeDuration = 4.0f; // seconds + static constexpr float kChangeCooldown = 2.0f; // seconds + + // Registers all built-in biome tracks and wires up the AudioManager. + void init(AudioManager& mgr); + + // Register (or overwrite) a biome track by name. + void registerBiome(const std::string& biome_name, BiomeTrack track); + + // Call every frame. current_biome is the string from getBiomeNameAt(). + void update(float dt, const char* current_biome); + + void setFadeDuration(float s) { fade_duration_ = s; } + float fadeDuration() const { return fade_duration_; } + +private: + AudioManager* mgr_ = nullptr; + std::unordered_map tracks_; + std::string current_biome_; + float fade_duration_ = kDefaultFadeDuration; + float change_cooldown_ = 0.0f; +}; diff --git a/srcs/audio/sound_emitter.cpp b/srcs/audio/sound_emitter.cpp new file mode 100644 index 0000000..565882d --- /dev/null +++ b/srcs/audio/sound_emitter.cpp @@ -0,0 +1,90 @@ +#include "audio/sound_emitter.hpp" +#include "audio/audio_manager.hpp" +#include +#include +#include +#include + +struct SoundEmitter::Impl { + ma_sound sound = {}; + bool loaded = false; + bool playing = false; +}; + +SoundEmitter::SoundEmitter() : impl_(std::make_unique()) {} +SoundEmitter::~SoundEmitter() { unload(); } + +bool SoundEmitter::load(AudioManager& mgr, + const std::string& path, + float min_distance, + float max_distance, + AttenuationModel model) { + unload(); + + auto* eng = static_cast(mgr.getEngineHandle()); + if (!eng) return false; + + // Construct full path the same way AudioManager does + // (AudioManager doesn't expose assets_root, so the caller is expected to + // pass the full path, or a path relative to the cwd which is the same + // as assets_root + "/" + relative_path.) + if (ma_sound_init_from_file(eng, path.c_str(), 0, + nullptr, nullptr, + &impl_->sound) != MA_SUCCESS) { + fprintf(stderr, "[SoundEmitter] load failed: %s\n", path.c_str()); + return false; + } + impl_->loaded = true; + + ma_sound_set_spatialization_enabled(&impl_->sound, MA_TRUE); + + switch (model) { + case AttenuationModel::Linear: + ma_sound_set_attenuation_model(&impl_->sound, + ma_attenuation_model_linear); + break; + case AttenuationModel::InverseSquare: + ma_sound_set_attenuation_model(&impl_->sound, + ma_attenuation_model_inverse); + break; + } + ma_sound_set_rolloff(&impl_->sound, 1.0f); + ma_sound_set_min_distance(&impl_->sound, min_distance); + ma_sound_set_max_distance(&impl_->sound, max_distance); + return true; +} + +void SoundEmitter::unload() { + if (!impl_->loaded) return; + ma_sound_stop(&impl_->sound); + ma_sound_uninit(&impl_->sound); + impl_->loaded = false; + impl_->playing = false; +} + +void SoundEmitter::setPosition(float x, float y, float z) { + if (impl_->loaded) + ma_sound_set_position(&impl_->sound, x, y, z); +} + +void SoundEmitter::setVolume(float v) { + if (impl_->loaded) + ma_sound_set_volume(&impl_->sound, std::clamp(v, 0.0f, 1.0f)); +} + +void SoundEmitter::play(bool loop) { + if (!impl_->loaded) return; + ma_sound_set_looping(&impl_->sound, loop ? MA_TRUE : MA_FALSE); + ma_sound_start(&impl_->sound); + impl_->playing = true; +} + +void SoundEmitter::stop() { + if (!impl_->loaded) return; + ma_sound_stop(&impl_->sound); + impl_->playing = false; +} + +bool SoundEmitter::isPlaying() const { + return impl_->loaded && ma_sound_is_playing(&impl_->sound); +} diff --git a/srcs/audio/sound_emitter.hpp b/srcs/audio/sound_emitter.hpp new file mode 100644 index 0000000..6053829 --- /dev/null +++ b/srcs/audio/sound_emitter.hpp @@ -0,0 +1,46 @@ +#pragma once +#include +#include + +class AudioManager; + +// A persistent 3D sound source attached to a world object or mob. +// +// Example: +// SoundEmitter emitter; +// emitter.load(audio_mgr, "sounds/ambient/fire_crackle.ogg", +// /*min_dist=*/1.0f, /*max_dist=*/16.0f); +// emitter.setPosition(x, y, z); +// emitter.play(/*loop=*/true); +// // each frame: +// emitter.setPosition(mob.x, mob.y, mob.z); +// // on death: +// emitter.stop(); +class SoundEmitter { +public: + enum class AttenuationModel { Linear, InverseSquare }; + + SoundEmitter(); + ~SoundEmitter(); + + // Load a sound file from AudioManager's assets_root. + // Returns false if the file could not be loaded (non-fatal; other methods + // become no-ops). + bool load(AudioManager& mgr, + const std::string& path, + float min_distance = 1.0f, + float max_distance = 32.0f, + AttenuationModel model = AttenuationModel::InverseSquare); + + void unload(); + + void setPosition(float x, float y, float z); + void setVolume(float v); // [0, 1], independent of SE group volume + void play(bool loop = true); + void stop(); + bool isPlaying() const; + +private: + struct Impl; + std::unique_ptr impl_; +}; diff --git a/srcs/core/engine.cpp b/srcs/core/engine.cpp index becca1b..f12e559 100644 --- a/srcs/core/engine.cpp +++ b/srcs/core/engine.cpp @@ -34,6 +34,8 @@ #include "network/client.hpp" #include "mob/mob_manager.hpp" #include "mob/arrow_manager.hpp" +#include "audio/audio_manager.hpp" +#include "audio/biome_audio_system.hpp" #include #include @@ -213,6 +215,13 @@ struct Engine::Impl { float net_pos_timer = 0.0f; float mob_sync_timer = 0.0f; // ホスト用: mob送信インターバル + // Audio + AudioManager audio_mgr; + BiomeAudioSystem biome_audio; + float footstep_timer = 0.0f; + float swim_timer = 0.0f; + float groan_timer = 0.0f; + // Mobs MobManager mob_mgr; ArrowManager arrow_mgr; @@ -332,6 +341,13 @@ bool Engine::init(uint32_t seed, int width, int height) { // ── チャンクマネージャーの初期化 ─────────────────────────────────────────── impl_->chunk_mgr = new ChunkManager(impl_->world, impl_->renderer); + // ── オーディオシステムの初期化 ───────────────────────────────────────────── + if (!impl_->audio_mgr.init("assets")) { + fprintf(stderr, "[Engine] Audio init failed — continuing without audio\n"); + } else { + impl_->biome_audio.init(impl_->audio_mgr); + } + running_ = true; fprintf(stderr, "[Engine] init OK seed=%u %dx%d\n", seed, width_, height_); return true; @@ -454,6 +470,39 @@ void Engine::run() { bool moving = inp.isHeld(GLFW_KEY_W) || inp.isHeld(GLFW_KEY_S) || inp.isHeld(GLFW_KEY_A) || inp.isHeld(GLFW_KEY_D); if (moving) impl_->local_walk_phase += dt * 8.0f; + + // ── 歩行音・水泳音 ────────────────────────────────────────── + impl_->footstep_timer -= dt; + if (impl_->footstep_timer <= 0.0f && moving) { + if (impl_->player.isInWater()) { + impl_->audio_mgr.playSe(SoundEvent::Swim); + impl_->footstep_timer = 0.55f; + } else if (impl_->player.isOnGround()) { + glm::vec3 p = impl_->player.camera().position(); + BlockType below = impl_->world.getWorldBlock( + (int)std::floor(p.x), + (int)std::floor(p.y - 1.2f), + (int)std::floor(p.z)); + SoundEvent fs = SoundEvent::FootstepGrass; + switch (below) { + case BlockType::Stone: + case BlockType::GoldOre: + case BlockType::DiamondOre: + fs = SoundEvent::FootstepStone; break; + case BlockType::Sand: + case BlockType::Cactus: + fs = SoundEvent::FootstepSand; break; + case BlockType::Snow: + fs = SoundEvent::FootstepSnow; break; + case BlockType::Wood: + fs = SoundEvent::FootstepWood; break; + default: + fs = SoundEvent::FootstepGrass; break; + } + impl_->audio_mgr.playSe(fs); + impl_->footstep_timer = 0.40f; + } + } } } if (impl_->player.shouldClose()) break; @@ -461,6 +510,7 @@ void Engine::run() { auto applyPlayerDamage = [&](float dmg, const char* source) { if (dmg <= 0.0f || impl_->player_dead) return; impl_->player_health -= dmg; + impl_->audio_mgr.playSe(SoundEvent::Hurt); fprintf(stderr, "[Game] Player took %.1f damage (%s).\n", dmg, source); if (impl_->player_health <= 0.0f) { @@ -637,13 +687,20 @@ void Engine::run() { BlockType::Air); rebuildModified(hit.bx, hit.bz, *impl_->chunk_mgr); inventoryAdd(inv, broken); + impl_->audio_mgr.playSe(SoundEvent::BlockBreak); if (impl_->multiplayer) impl_->net_client.sendBlockChange( hit.bx, hit.by, hit.bz, static_cast(BlockType::Air)); } else { - impl_->mob_mgr.playerMeleeAttack( + int hit_idx = impl_->mob_mgr.playerMeleeAttack( pos.x, pos.y, pos.z, front.x, front.z); + impl_->audio_mgr.playSe(SoundEvent::Attack); + if (hit_idx >= 0) { + const Zombie& hz = impl_->mob_mgr.zombies()[hit_idx]; + impl_->audio_mgr.playSe3D(SoundEvent::MobHurt, + hz.x, hz.y + 0.9f, hz.z, 32.0f); + } } } @@ -689,6 +746,7 @@ void Engine::run() { impl_->world.setWorldBlock(hit.nx, hit.ny, hit.nz, to_place); rebuildModified(hit.nx, hit.nz, *impl_->chunk_mgr); + impl_->audio_mgr.playSe(SoundEvent::BlockPlace); if (impl_->multiplayer) impl_->net_client.sendBlockChange( hit.nx, hit.ny, hit.nz, @@ -713,6 +771,16 @@ void Engine::run() { // ── マルチプレイ: ネットワーク送受信 ──────────────────────────────────── glm::vec3 ppos = impl_->player.camera().position(); + + // ── オーディオ更新 ────────────────────────────────────────────────── + { + glm::vec3 front = impl_->player.camera().front(); + impl_->audio_mgr.setListenerPosition(ppos.x, ppos.y, ppos.z); + impl_->audio_mgr.setListenerDirection(front.x, front.y, front.z); + const char* biome = impl_->world.getBiomeNameAt(ppos.x, ppos.z); + impl_->biome_audio.update(dt, biome); + impl_->audio_mgr.update(dt); + } if (impl_->attack_sync_timer > 0.0f) impl_->attack_sync_timer -= dt; if (impl_->death_sync_timer > 0.0f) @@ -811,12 +879,31 @@ void Engine::run() { if (is_mob_host) { auto explosions = impl_->mob_mgr.consumeExplosions(); for (const MobExplosion& ex : explosions) { + impl_->audio_mgr.playSe3D(SoundEvent::MobExplode, + ex.x, ex.y, ex.z, 64.0f); applyMobExplosion( ex, impl_->world, *impl_->chunk_mgr, impl_->multiplayer ? &impl_->net_client : nullptr); } } applyPlayerDamage(dmg, "mob"); + + // Mob groan: one nearby Chase/Attack zombie emits a sound periodically + impl_->groan_timer -= dt; + if (impl_->groan_timer <= 0.0f) { + impl_->groan_timer = 3.5f; + for (const Zombie& z : impl_->mob_mgr.zombies()) { + if (!z.alive()) continue; + if (z.state != Zombie::State::Chase && + z.state != Zombie::State::Attack) continue; + float dx = z.x - ppos.x, dz = z.z - ppos.z; + if (dx*dx + dz*dz < 40.0f * 40.0f) { + impl_->audio_mgr.playSe3D(SoundEvent::MobGroan, + z.x, z.y + 0.9f, z.z, 40.0f); + break; + } + } + } } // ── 矢の物理更新(命中判定はホストのみ実行) ───────────────────────── diff --git a/srcs/player/player.hpp b/srcs/player/player.hpp index 8db2db9..db357e0 100644 --- a/srcs/player/player.hpp +++ b/srcs/player/player.hpp @@ -22,8 +22,9 @@ class Player { InputHandler& input() { return input_; } ChunkPos chunkPos() const; - bool isFlyMode() const { return fly_mode_; } - bool isInWater() const { return in_water_; } + bool isFlyMode() const { return fly_mode_; } + bool isInWater() const { return in_water_; } + bool isOnGround() const { return on_ground_; } void respawnAtInitial(); bool shouldClose() const { return input_.shouldClose(); }