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 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
foxtacles 2026-03-10 17:36:50 -07:00 committed by GitHub
parent 1a8c6c70ea
commit 630368c89f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 144 additions and 105 deletions

View File

@ -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

View File

@ -0,0 +1,49 @@
#pragma once
#include "extensions/multiplayer/protocol.h"
#include <cstdint>
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

View File

@ -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"

View File

@ -5,8 +5,6 @@
#include <cstdint>
#include <type_traits>
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)
{

View File

@ -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"

View File

@ -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

View File

@ -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;

View File

@ -1,7 +1,6 @@
#include "extensions/multiplayer/protocol.h"
#include "legogamestate.h"
#include "legopathactor.h"
#include "misc.h"
#include <SDL3/SDL_stdinc.h>
@ -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);