From 630368c89f1821897411940cc773da980478e9fe Mon Sep 17 00:00:00 2001 From: foxtacles Date: Tue, 10 Mar 2026 17:36:50 -0700 Subject: [PATCH] Add emote sound effects (#14) * Add optional sound effects to emotes Replace the raw 2D string emote table with a struct-based EmoteEntry/EmotePhase table so each emote phase can declare an optional 3D spatialized sound effect. The sound plays via LegoCacheSoundManager when the emote phase starts, positioned at the character's ROI. No extra network sync needed since sounds trigger locally when TriggerEmote is called on each client. Adds "crash5" sound to the BNsDis01 (disassemble) emote phase. https://claude.ai/code/session_013XkwJJZ4RbNcBMFESuPTfJ * Extract animation/asset tables from protocol.h into animdata.h Move EmotePhase, EmoteEntry structs, animation table externs (walk/idle/emote/vehicle), and helpers (IsMultiPartEmote, IsLargeVehicle, DetectVehicleType) into a dedicated animdata.h/cpp pair. This keeps protocol.h focused on wire-format concerns (message structs, serialization, enums) while animdata.h owns game asset data. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude --- CMakeLists.txt | 1 + .../include/extensions/multiplayer/animdata.h | 49 ++++++++++++ .../multiplayer/characteranimator.h | 2 +- .../include/extensions/multiplayer/protocol.h | 29 ------- .../multiplayer/thirdpersoncamera.h | 1 - extensions/src/multiplayer/animdata.cpp | 78 +++++++++++++++++++ .../src/multiplayer/characteranimator.cpp | 18 ++++- extensions/src/multiplayer/protocol.cpp | 71 ----------------- 8 files changed, 144 insertions(+), 105 deletions(-) create mode 100644 extensions/include/extensions/multiplayer/animdata.h create mode 100644 extensions/src/multiplayer/animdata.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 5cd0dbf4..a4b62a77 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -532,6 +532,7 @@ if (ISLE_EXTENSIONS) extensions/src/siloader.cpp extensions/src/textureloader.cpp extensions/src/multiplayer.cpp + extensions/src/multiplayer/animdata.cpp extensions/src/multiplayer/animutils.cpp extensions/src/multiplayer/characteranimator.cpp extensions/src/multiplayer/charactercloner.cpp diff --git a/extensions/include/extensions/multiplayer/animdata.h b/extensions/include/extensions/multiplayer/animdata.h new file mode 100644 index 00000000..5dd82a2c --- /dev/null +++ b/extensions/include/extensions/multiplayer/animdata.h @@ -0,0 +1,49 @@ +#pragma once + +#include "extensions/multiplayer/protocol.h" + +#include + +class LegoPathActor; + +namespace Multiplayer +{ + +// Animation and vehicle tables (defined in animdata.cpp) +extern const char* const g_walkAnimNames[]; +extern const int g_walkAnimCount; + +extern const char* const g_idleAnimNames[]; +extern const int g_idleAnimCount; + +// Per-phase emote data: animation name and optional sound effect. +struct EmotePhase { + const char* anim; // Animation name (nullptr = unused phase) + const char* sound; // Sound key for LegoCacheSoundManager (nullptr = silent) +}; + +// Emote table entry: two phases (phase 1 = primary, phase 2 = recovery for multi-part emotes). +struct EmoteEntry { + EmotePhase phases[2]; +}; + +extern const EmoteEntry g_emoteEntries[]; +extern const int g_emoteAnimCount; + +// Returns true if the emote is a multi-part stateful emote (has a phase-2 animation). +inline bool IsMultiPartEmote(uint8_t p_emoteId) +{ + return p_emoteId < g_emoteAnimCount && g_emoteEntries[p_emoteId].phases[1].anim != nullptr; +} + +extern const char* const g_vehicleROINames[VEHICLE_COUNT]; +extern const char* const g_rideAnimNames[VEHICLE_COUNT]; +extern const char* const g_rideVehicleROINames[VEHICLE_COUNT]; + +// Returns true if the vehicle type has no ride animation (model swap instead) +bool IsLargeVehicle(int8_t p_vehicleType); + +// Detect the vehicle type of a given actor, or VEHICLE_NONE if not a vehicle +int8_t DetectVehicleType(LegoPathActor* p_actor); + +} // namespace Multiplayer diff --git a/extensions/include/extensions/multiplayer/characteranimator.h b/extensions/include/extensions/multiplayer/characteranimator.h index 13efc11b..1cfe2012 100644 --- a/extensions/include/extensions/multiplayer/characteranimator.h +++ b/extensions/include/extensions/multiplayer/characteranimator.h @@ -1,7 +1,7 @@ #pragma once +#include "extensions/multiplayer/animdata.h" #include "extensions/multiplayer/animutils.h" -#include "extensions/multiplayer/protocol.h" #include "mxgeometry/mxmatrix.h" #include "mxtypes.h" diff --git a/extensions/include/extensions/multiplayer/protocol.h b/extensions/include/extensions/multiplayer/protocol.h index c8638bf1..4038e779 100644 --- a/extensions/include/extensions/multiplayer/protocol.h +++ b/extensions/include/extensions/multiplayer/protocol.h @@ -5,8 +5,6 @@ #include #include -class LegoPathActor; - namespace Multiplayer { @@ -155,33 +153,6 @@ struct CustomizeMsg { #pragma pack(pop) -// Animation and vehicle tables (defined in protocol.cpp) -extern const char* const g_walkAnimNames[]; -extern const int g_walkAnimCount; - -extern const char* const g_idleAnimNames[]; -extern const int g_idleAnimCount; - -// Emote animation table: [emoteId][phase]. Phase 0 = primary, phase 1 = phase-2 (nullptr for one-shot). -extern const char* const g_emoteAnims[][2]; -extern const int g_emoteAnimCount; - -// Returns true if the emote is a multi-part stateful emote (has a phase-2 animation). -inline bool IsMultiPartEmote(uint8_t p_emoteId) -{ - return p_emoteId < g_emoteAnimCount && g_emoteAnims[p_emoteId][1] != nullptr; -} - -extern const char* const g_vehicleROINames[VEHICLE_COUNT]; -extern const char* const g_rideAnimNames[VEHICLE_COUNT]; -extern const char* const g_rideVehicleROINames[VEHICLE_COUNT]; - -// Returns true if the vehicle type has no ride animation (model swap instead) -bool IsLargeVehicle(int8_t p_vehicleType); - -// Detect the vehicle type of a given actor, or VEHICLE_NONE if not a vehicle -int8_t DetectVehicleType(LegoPathActor* p_actor); - // Validate actorId is a playable character (1-5, not brickster) inline bool IsValidActorId(uint8_t p_actorId) { diff --git a/extensions/include/extensions/multiplayer/thirdpersoncamera.h b/extensions/include/extensions/multiplayer/thirdpersoncamera.h index 1d136865..795cb034 100644 --- a/extensions/include/extensions/multiplayer/thirdpersoncamera.h +++ b/extensions/include/extensions/multiplayer/thirdpersoncamera.h @@ -2,7 +2,6 @@ #include "extensions/multiplayer/characteranimator.h" #include "extensions/multiplayer/customizestate.h" -#include "extensions/multiplayer/protocol.h" #include "mxgeometry/mxgeometry3d.h" #include "mxtypes.h" diff --git a/extensions/src/multiplayer/animdata.cpp b/extensions/src/multiplayer/animdata.cpp new file mode 100644 index 00000000..af2e37e6 --- /dev/null +++ b/extensions/src/multiplayer/animdata.cpp @@ -0,0 +1,78 @@ +#include "extensions/multiplayer/animdata.h" + +#include "legopathactor.h" + +namespace Multiplayer +{ + +const char* const g_walkAnimNames[] = { + "CNs001xx", // 0: Normal (default) + "CNs002xx", // 1: Joyful + "CNs003xx", // 2: Gloomy + "CNs005xx", // 3: Leaning + "CNs006xx", // 4: Scared + "CNs007xx", // 5: Hyper +}; +const int g_walkAnimCount = sizeof(g_walkAnimNames) / sizeof(g_walkAnimNames[0]); + +const char* const g_idleAnimNames[] = { + "CNs008xx", // 0: Sway (default) + "CNs009xx", // 1: Groove + "CNs010xx", // 2: Excited +}; +const int g_idleAnimCount = sizeof(g_idleAnimNames) / sizeof(g_idleAnimNames[0]); + +// Emote table. Each entry has two phases: {anim, sound}. +// Phase 2 anim is nullptr for one-shot emotes; non-null makes it a multi-part stateful emote. +const EmoteEntry g_emoteEntries[] = { + {{{"CNs011xx", nullptr}, {nullptr, nullptr}}}, // 0: Wave (one-shot) + {{{"CNs012xx", nullptr}, {nullptr, nullptr}}}, // 1: Hat Tip (one-shot) + {{{"BNsDis01", "crash5"}, {"BNsAss01", nullptr}}}, // 2: Disassemble / Reassemble (multi-part) +}; +const int g_emoteAnimCount = sizeof(g_emoteEntries) / sizeof(g_emoteEntries[0]); + +// Vehicle model names (LOD names). The helicopter is a compound ROI ("copter") +// with no standalone LOD; use its body part instead. +const char* const g_vehicleROINames[VEHICLE_COUNT] = + {"chtrbody", "jsuser", "dunebugy", "bike", "board", "moto", "towtk", "ambul"}; + +// Ride animation names for small vehicles (NULL = large vehicle, no ride anim) +const char* const g_rideAnimNames[VEHICLE_COUNT] = {NULL, NULL, NULL, "CNs001Bd", "CNs001sk", "CNs011Ni", NULL, NULL}; + +// Vehicle variant ROI names used in ride animations +const char* const g_rideVehicleROINames[VEHICLE_COUNT] = {NULL, NULL, NULL, "bikebd", "board", "motoni", NULL, NULL}; + +bool IsLargeVehicle(int8_t p_vehicleType) +{ + return p_vehicleType != VEHICLE_NONE && p_vehicleType < VEHICLE_COUNT && g_rideAnimNames[p_vehicleType] == NULL; +} + +int8_t DetectVehicleType(LegoPathActor* p_actor) +{ + static const struct { + const char* className; + int8_t vehicleType; + } vehicleMap[] = { + {"Helicopter", VEHICLE_HELICOPTER}, + {"Jetski", VEHICLE_JETSKI}, + {"DuneBuggy", VEHICLE_DUNEBUGGY}, + {"Bike", VEHICLE_BIKE}, + {"SkateBoard", VEHICLE_SKATEBOARD}, + {"Motorcycle", VEHICLE_MOTOCYCLE}, + {"TowTrack", VEHICLE_TOWTRACK}, + {"Ambulance", VEHICLE_AMBULANCE}, + }; + + if (!p_actor) { + return VEHICLE_NONE; + } + + for (const auto& entry : vehicleMap) { + if (p_actor->IsA(entry.className)) { + return entry.vehicleType; + } + } + return VEHICLE_NONE; +} + +} // namespace Multiplayer diff --git a/extensions/src/multiplayer/characteranimator.cpp b/extensions/src/multiplayer/characteranimator.cpp index 2a1b26f4..b348a72e 100644 --- a/extensions/src/multiplayer/characteranimator.cpp +++ b/extensions/src/multiplayer/characteranimator.cpp @@ -5,7 +5,9 @@ #include "extensions/multiplayer/charactercustomizer.h" #include "extensions/multiplayer/namebubblerenderer.h" #include "legoanimpresenter.h" +#include "legocachesoundmanager.h" #include "legocharactermanager.h" +#include "legosoundmanager.h" #include "legovideomanager.h" #include "legoworld.h" #include "misc.h" @@ -237,7 +239,7 @@ void CharacterAnimator::TriggerEmote(uint8_t p_emoteId, LegoROI* p_roi, bool p_i if (IsMultiPartEmote(p_emoteId)) { if (m_frozenEmoteId == (int8_t) p_emoteId) { // Phase 2: play the recovery animation to unfreeze - AnimCache* cache = GetOrBuildAnimCache(p_roi, g_emoteAnims[p_emoteId][1]); + AnimCache* cache = GetOrBuildAnimCache(p_roi, g_emoteEntries[p_emoteId].phases[1].anim); if (!cache || !cache->anim) { return; } @@ -250,6 +252,11 @@ void CharacterAnimator::TriggerEmote(uint8_t p_emoteId, LegoROI* p_roi, bool p_i m_emoteDuration = (float) cache->anim->GetDuration(); m_emoteActive = true; + const char* sound = g_emoteEntries[p_emoteId].phases[1].sound; + if (sound) { + SoundManager()->GetCacheSoundManager()->Play(sound, p_roi->GetName(), FALSE); + } + if (m_config.saveEmoteTransform) { m_emoteParentTransform = m_frozenParentTransform; } @@ -268,7 +275,7 @@ void CharacterAnimator::TriggerEmote(uint8_t p_emoteId, LegoROI* p_roi, bool p_i } } - AnimCache* cache = GetOrBuildAnimCache(p_roi, g_emoteAnims[p_emoteId][0]); + AnimCache* cache = GetOrBuildAnimCache(p_roi, g_emoteEntries[p_emoteId].phases[0].anim); if (!cache || !cache->anim) { return; } @@ -281,6 +288,11 @@ void CharacterAnimator::TriggerEmote(uint8_t p_emoteId, LegoROI* p_roi, bool p_i m_emoteDuration = (float) cache->anim->GetDuration(); m_emoteActive = true; + const char* sound = g_emoteEntries[p_emoteId].phases[0].sound; + if (sound) { + SoundManager()->GetCacheSoundManager()->Play(sound, p_roi->GetName(), FALSE); + } + // Save clean transform to prevent scale accumulation during emote if (m_config.saveEmoteTransform) { m_emoteParentTransform = p_roi->GetLocal2World(); @@ -371,7 +383,7 @@ void CharacterAnimator::InitAnimCaches(LegoROI* p_roi) void CharacterAnimator::SetFrozenEmoteId(int8_t p_emoteId, LegoROI* p_roi) { if (p_emoteId >= 0 && p_emoteId < g_emoteAnimCount && IsMultiPartEmote((uint8_t) p_emoteId)) { - AnimCache* cache = p_roi ? GetOrBuildAnimCache(p_roi, g_emoteAnims[p_emoteId][0]) : nullptr; + AnimCache* cache = p_roi ? GetOrBuildAnimCache(p_roi, g_emoteEntries[p_emoteId].phases[0].anim) : nullptr; m_frozenEmoteId = p_emoteId; m_frozenAnimCache = cache; m_frozenAnimDuration = (cache && cache->anim) ? (float) cache->anim->GetDuration() : 0.0f; diff --git a/extensions/src/multiplayer/protocol.cpp b/extensions/src/multiplayer/protocol.cpp index 66ad151b..987f001d 100644 --- a/extensions/src/multiplayer/protocol.cpp +++ b/extensions/src/multiplayer/protocol.cpp @@ -1,7 +1,6 @@ #include "extensions/multiplayer/protocol.h" #include "legogamestate.h" -#include "legopathactor.h" #include "misc.h" #include @@ -9,76 +8,6 @@ namespace Multiplayer { -const char* const g_walkAnimNames[] = { - "CNs001xx", // 0: Normal (default) - "CNs002xx", // 1: Joyful - "CNs003xx", // 2: Gloomy - "CNs005xx", // 3: Leaning - "CNs006xx", // 4: Scared - "CNs007xx", // 5: Hyper -}; -const int g_walkAnimCount = sizeof(g_walkAnimNames) / sizeof(g_walkAnimNames[0]); - -const char* const g_idleAnimNames[] = { - "CNs008xx", // 0: Sway (default) - "CNs009xx", // 1: Groove - "CNs010xx", // 2: Excited -}; -const int g_idleAnimCount = sizeof(g_idleAnimNames) / sizeof(g_idleAnimNames[0]); - -// Emote animation table. Each entry is {phase1, phase2}. -// phase2 is nullptr for one-shot emotes; non-null makes it a multi-part stateful emote. -const char* const g_emoteAnims[][2] = { - {"CNs011xx", nullptr}, // 0: Wave (one-shot) - {"CNs012xx", nullptr}, // 1: Hat Tip (one-shot) - {"BNsDis01", "BNsAss01"}, // 2: Disassemble / Reassemble (multi-part) -}; -const int g_emoteAnimCount = sizeof(g_emoteAnims) / sizeof(g_emoteAnims[0]); - -// Vehicle model names (LOD names). The helicopter is a compound ROI ("copter") -// with no standalone LOD; use its body part instead. -const char* const g_vehicleROINames[VEHICLE_COUNT] = - {"chtrbody", "jsuser", "dunebugy", "bike", "board", "moto", "towtk", "ambul"}; - -// Ride animation names for small vehicles (NULL = large vehicle, no ride anim) -const char* const g_rideAnimNames[VEHICLE_COUNT] = {NULL, NULL, NULL, "CNs001Bd", "CNs001sk", "CNs011Ni", NULL, NULL}; - -// Vehicle variant ROI names used in ride animations -const char* const g_rideVehicleROINames[VEHICLE_COUNT] = {NULL, NULL, NULL, "bikebd", "board", "motoni", NULL, NULL}; - -bool IsLargeVehicle(int8_t p_vehicleType) -{ - return p_vehicleType != VEHICLE_NONE && p_vehicleType < VEHICLE_COUNT && g_rideAnimNames[p_vehicleType] == NULL; -} - -int8_t DetectVehicleType(LegoPathActor* p_actor) -{ - static const struct { - const char* className; - int8_t vehicleType; - } vehicleMap[] = { - {"Helicopter", VEHICLE_HELICOPTER}, - {"Jetski", VEHICLE_JETSKI}, - {"DuneBuggy", VEHICLE_DUNEBUGGY}, - {"Bike", VEHICLE_BIKE}, - {"SkateBoard", VEHICLE_SKATEBOARD}, - {"Motorcycle", VEHICLE_MOTOCYCLE}, - {"TowTrack", VEHICLE_TOWTRACK}, - {"Ambulance", VEHICLE_AMBULANCE}, - }; - - if (!p_actor) { - return VEHICLE_NONE; - } - - for (const auto& entry : vehicleMap) { - if (p_actor->IsA(entry.className)) { - return entry.vehicleType; - } - } - return VEHICLE_NONE; -} - void EncodeUsername(char p_out[8]) { SDL_memset(p_out, 0, 8);