Add disassemble/assemble emote (#13)

* Add stateful multi-part emote system with disassemble/reassemble

Introduces a generalized multi-part emote framework where emotes can have
two phases. The first trigger plays phase 1 and freezes the character at its
last frame; the second trigger plays phase 2 to restore normal state.

Movement is blocked for the entire duration of a multi-part emote (from
phase 1 start through frozen state to phase 2 completion). The frozen
state is synced to all peers via customizeFlags bits in PlayerStateMsg,
so new joiners see disassembled players correctly.

The emote table is now a 2D array (g_emoteAnims[][2]) where [1] is the
phase-2 animation name (nullptr for one-shot emotes). Adding future
multi-part emotes only requires a new row in the table.

https://claude.ai/code/session_01L5FiuVFUqASR93iJcaXfEi

* Fix emote movement blocking and frozen state sync

Move movement blocking from CalculateTransform hook (which broke the
camera by skipping p_transform output) to ThirdPersonCamera::Tick where
it zeroes speed/velocity directly. Remove ShouldBlockMovement and
ShouldInvertMovement hooks entirely.

Rebuild frozen emote animation cache in InitAnimCaches when the frozen
state was set before the ROI was available (state message arrived before
world was ready).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Clean up emote branch: remove unused include, extract ClearFrozenState helper

- Remove unused multiplayer.h include and using-directive from legopathactor.cpp
- Extract ClearFrozenState() to DRY up 4 identical frozen state reset blocks
- Clarify bit-encoding comment with mask value and emote ID limit

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-09 18:18:37 -07:00 committed by GitHub
parent 04730bcc97
commit 1a8c6c70ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 199 additions and 26 deletions

View File

@ -75,6 +75,12 @@ class CharacterAnimator {
// Emote state accessors
bool IsEmoteActive() const { return m_emoteActive; }
// Multi-part emote state. Returns true when the player is in any phase of a multi-part
// emote (playing phase 1, frozen at last frame, or playing phase 2). Movement is blocked.
bool IsInMultiPartEmote() const { return m_frozenEmoteId >= 0 || (m_emoteActive && IsMultiPartEmote(m_currentEmoteId)); }
int8_t GetFrozenEmoteId() const { return m_frozenEmoteId; }
void SetFrozenEmoteId(int8_t p_emoteId, LegoROI* p_roi);
// Animation time (needed for vehicle ride tick in ThirdPersonCamera)
float GetAnimTime() const { return m_animTime; }
void SetAnimTime(float p_time) { m_animTime = p_time; }
@ -84,6 +90,7 @@ class CharacterAnimator {
using AnimCache = AnimUtils::AnimCache;
AnimCache* GetOrBuildAnimCache(LegoROI* p_roi, const char* p_animName);
void ClearFrozenState();
CharacterAnimatorConfig m_config;
@ -102,8 +109,15 @@ class CharacterAnimator {
float m_emoteTime;
float m_emoteDuration;
bool m_emoteActive;
uint8_t m_currentEmoteId;
MxMatrix m_emoteParentTransform;
// Multi-part emote frozen state (-1 = not frozen)
int8_t m_frozenEmoteId;
AnimCache* m_frozenAnimCache;
float m_frozenAnimDuration;
MxMatrix m_frozenParentTransform;
// Click animation tracking (0 = none)
MxU32 m_clickAnimObjectId;

View File

@ -162,9 +162,16 @@ extern const int g_walkAnimCount;
extern const char* const g_idleAnimNames[];
extern const int g_idleAnimCount;
extern const char* const g_emoteAnimNames[];
// 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];

View File

@ -47,6 +47,7 @@ class RemotePlayer {
void StopClickAnimation();
bool IsInVehicle() const { return m_animator.IsInVehicle(); }
bool IsMoving() const { return m_animator.IsInVehicle() || m_targetSpeed > 0.01f; }
bool IsInMultiPartEmote() const { return m_animator.IsInMultiPartEmote(); }
private:
const char* GetDisplayActorName() const;

View File

@ -38,6 +38,8 @@ class ThirdPersonCamera {
void SetWalkAnimId(uint8_t p_walkAnimId);
void SetIdleAnimId(uint8_t p_idleAnimId);
void TriggerEmote(uint8_t p_emoteId);
bool IsInMultiPartEmote() const;
int8_t GetFrozenEmoteId() const;
void SetDisplayActorIndex(uint8_t p_displayActorIndex);
uint8_t GetDisplayActorIndex() const { return m_displayActorIndex; }
LegoROI* GetDisplayROI() const { return m_displayROI; }

View File

@ -20,7 +20,8 @@ using namespace Multiplayer;
CharacterAnimator::CharacterAnimator(const CharacterAnimatorConfig& p_config)
: m_config(p_config), m_walkAnimId(0), m_idleAnimId(0), m_walkAnimCache(nullptr), m_idleAnimCache(nullptr),
m_animTime(0.0f), m_idleTime(0.0f), m_idleAnimTime(0.0f), m_wasMoving(false), m_emoteAnimCache(nullptr),
m_emoteTime(0.0f), m_emoteDuration(0.0f), m_emoteActive(false), m_clickAnimObjectId(0), m_rideAnim(nullptr),
m_emoteTime(0.0f), m_emoteDuration(0.0f), m_emoteActive(false), m_currentEmoteId(0), m_frozenEmoteId(-1),
m_frozenAnimCache(nullptr), m_frozenAnimDuration(0.0f), m_clickAnimObjectId(0), m_rideAnim(nullptr),
m_rideRoiMap(nullptr), m_rideRoiMapSize(0), m_rideVehicleROI(nullptr), m_currentVehicleType(VEHICLE_NONE),
m_nameBubble(nullptr)
{
@ -71,10 +72,10 @@ void CharacterAnimator::Tick(float p_deltaTime, LegoROI* p_roi, bool p_isMoving)
bool inVehicle = (m_currentVehicleType != VEHICLE_NONE);
bool isMoving = inVehicle || p_isMoving;
// Movement interrupts click animations and emotes
if (isMoving) {
// Movement interrupts click animations and emotes (but not multi-part emotes)
if (isMoving && m_frozenEmoteId < 0) {
StopClickAnimation();
if (m_emoteActive) {
if (m_emoteActive && !IsMultiPartEmote(m_currentEmoteId)) {
m_emoteActive = false;
m_emoteAnimCache = nullptr;
}
@ -104,16 +105,32 @@ void CharacterAnimator::Tick(float p_deltaTime, LegoROI* p_roi, bool p_isMoving)
m_idleAnimTime = 0.0f;
}
else if (m_emoteActive && m_emoteAnimCache && m_emoteAnimCache->anim && m_emoteAnimCache->roiMap) {
// Emote playback (one-shot)
// Emote playback
m_emoteTime += p_deltaTime * 1000.0f;
if (m_emoteTime >= m_emoteDuration) {
// Emote completed -- return to stationary flow
m_emoteActive = false;
m_emoteAnimCache = nullptr;
m_wasMoving = false;
m_idleTime = 0.0f;
m_idleAnimTime = 0.0f;
if (IsMultiPartEmote(m_currentEmoteId) && m_frozenEmoteId < 0) {
// Phase 1 completed -> freeze at last frame
m_frozenEmoteId = (int8_t) m_currentEmoteId;
m_frozenAnimCache = m_emoteAnimCache;
m_frozenAnimDuration = m_emoteDuration;
m_emoteActive = false;
if (m_config.saveEmoteTransform) {
m_frozenParentTransform = m_emoteParentTransform;
}
}
else {
if (IsMultiPartEmote(m_currentEmoteId) && m_frozenEmoteId >= 0) {
// Phase 2 completed -> unfreeze
ClearFrozenState();
}
// Emote completed -- return to stationary flow
m_emoteActive = false;
m_emoteAnimCache = nullptr;
m_wasMoving = false;
m_idleTime = 0.0f;
m_idleAnimTime = 0.0f;
}
}
else {
MxMatrix transform(m_config.saveEmoteTransform ? m_emoteParentTransform : p_roi->GetLocal2World());
@ -134,6 +151,24 @@ void CharacterAnimator::Tick(float p_deltaTime, LegoROI* p_roi, bool p_isMoving)
}
}
}
else if (m_frozenEmoteId >= 0 && m_frozenAnimCache && m_frozenAnimCache->anim && m_frozenAnimCache->roiMap) {
// Frozen at last frame of a multi-part emote's phase-1 animation
MxMatrix transform(m_config.saveEmoteTransform ? m_frozenParentTransform : p_roi->GetLocal2World());
LegoTreeNode* root = m_frozenAnimCache->anim->GetRoot();
for (LegoU32 i = 0; i < root->GetNumChildren(); i++) {
LegoROI::ApplyAnimationTransformation(
root->GetChild(i),
transform,
(LegoTime) m_frozenAnimDuration,
m_frozenAnimCache->roiMap
);
}
if (m_config.saveEmoteTransform) {
p_roi->WrappedSetLocal2WorldWithWorldDataUpdate(m_frozenParentTransform);
}
}
else if (m_idleAnimCache && m_idleAnimCache->anim && m_idleAnimCache->roiMap) {
// Idle animation
if (m_wasMoving) {
@ -199,18 +234,48 @@ void CharacterAnimator::TriggerEmote(uint8_t p_emoteId, LegoROI* p_roi, bool p_i
return;
}
// Only play emotes when stationary
if (p_isMoving) {
return;
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]);
if (!cache || !cache->anim) {
return;
}
StopClickAnimation();
m_currentEmoteId = p_emoteId;
m_emoteAnimCache = cache;
m_emoteTime = 0.0f;
m_emoteDuration = (float) cache->anim->GetDuration();
m_emoteActive = true;
if (m_config.saveEmoteTransform) {
m_emoteParentTransform = m_frozenParentTransform;
}
return;
}
else if (m_frozenEmoteId >= 0) {
// Already frozen in a different emote, ignore
return;
}
// Phase 1: fall through to play the primary animation
}
else {
// One-shot emote: block if moving or frozen in any multi-part emote
if (p_isMoving || m_frozenEmoteId >= 0) {
return;
}
}
AnimCache* cache = GetOrBuildAnimCache(p_roi, g_emoteAnimNames[p_emoteId]);
AnimCache* cache = GetOrBuildAnimCache(p_roi, g_emoteAnims[p_emoteId][0]);
if (!cache || !cache->anim) {
return;
}
StopClickAnimation();
m_currentEmoteId = p_emoteId;
m_emoteAnimCache = cache;
m_emoteTime = 0.0f;
m_emoteDuration = (float) cache->anim->GetDuration();
@ -295,6 +360,36 @@ void CharacterAnimator::InitAnimCaches(LegoROI* p_roi)
{
m_walkAnimCache = GetOrBuildAnimCache(p_roi, g_walkAnimNames[m_walkAnimId]);
m_idleAnimCache = GetOrBuildAnimCache(p_roi, g_idleAnimNames[m_idleAnimId]);
// Rebuild frozen emote cache if the frozen state was set before the ROI was available
// (e.g. state message arrived before world was ready, or world was re-enabled).
if (m_frozenEmoteId >= 0 && !m_frozenAnimCache) {
SetFrozenEmoteId(m_frozenEmoteId, 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;
m_frozenEmoteId = p_emoteId;
m_frozenAnimCache = cache;
m_frozenAnimDuration = (cache && cache->anim) ? (float) cache->anim->GetDuration() : 0.0f;
m_emoteActive = false;
if (m_config.saveEmoteTransform && p_roi) {
m_frozenParentTransform = p_roi->GetLocal2World();
}
}
else {
ClearFrozenState();
}
}
void CharacterAnimator::ClearFrozenState()
{
m_frozenEmoteId = -1;
m_frozenAnimCache = nullptr;
m_frozenAnimDuration = 0.0f;
}
void CharacterAnimator::ClearAnimCaches()
@ -303,6 +398,7 @@ void CharacterAnimator::ClearAnimCaches()
m_idleAnimCache = nullptr;
m_emoteAnimCache = nullptr;
m_emoteActive = false;
ClearFrozenState();
}
void CharacterAnimator::ClearAll()
@ -318,6 +414,7 @@ void CharacterAnimator::ResetAnimState()
m_idleAnimTime = 0.0f;
m_wasMoving = false;
m_emoteActive = false;
ClearFrozenState();
}
void CharacterAnimator::ApplyIdleFrame0(LegoROI* p_roi)

View File

@ -330,6 +330,18 @@ void NetworkManager::BroadcastLocalState()
m_thirdPersonCamera.GetCustomizeState().Pack(msg.customizeData);
msg.customizeFlags = m_localAllowRemoteCustomize ? 0x01 : 0x00;
// Encode multi-part emote frozen state (0x02 = frozen, emote ID in bits 2-4, max 8 emotes)
int8_t frozenId = m_thirdPersonCamera.GetFrozenEmoteId();
if (frozenId >= 0) {
msg.customizeFlags |= 0x02;
msg.customizeFlags |= (frozenId & 0x07) << 2;
}
// Zero speed when in any phase of a multi-part emote
if (m_thirdPersonCamera.IsInMultiPartEmote()) {
msg.speed = 0.0f;
}
SendMessage(msg);
}
@ -687,7 +699,7 @@ void NetworkManager::HandleCustomize(const CustomizeMsg& p_msg)
it->second->GetCustomizeState(),
p_msg.changeType == CHANGE_MOOD
);
if (!it->second->IsMoving()) {
if (!it->second->IsMoving() && !it->second->IsInMultiPartEmote()) {
MxU32 clickAnimId =
CharacterCustomizer::PlayClickAnimation(it->second->GetROI(), it->second->GetCustomizeState());
it->second->SetClickAnimObjectId(clickAnimId);
@ -719,8 +731,9 @@ void NetworkManager::HandleCustomize(const CustomizeMsg& p_msg)
p_msg.changeType == CHANGE_MOOD
);
// Only play click animation in 3rd person (not visible in 1st person)
if (m_thirdPersonCamera.GetDisplayROI() && !m_thirdPersonCamera.IsInVehicle()) {
// Only play click animation in 3rd person (not visible in 1st person or multi-part emote)
if (m_thirdPersonCamera.GetDisplayROI() && !m_thirdPersonCamera.IsInVehicle() &&
!m_thirdPersonCamera.IsInMultiPartEmote()) {
MxU32 clickAnimId = CharacterCustomizer::PlayClickAnimation(
m_thirdPersonCamera.GetDisplayROI(),
m_thirdPersonCamera.GetCustomizeState()

View File

@ -26,11 +26,14 @@ const char* const g_idleAnimNames[] = {
};
const int g_idleAnimCount = sizeof(g_idleAnimNames) / sizeof(g_idleAnimNames[0]);
const char* const g_emoteAnimNames[] = {
"CNs011xx", // 0: Wave
"CNs012xx", // 1: Hat Tip
// 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_emoteAnimNames) / sizeof(g_emoteAnimNames[0]);
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.

View File

@ -172,6 +172,13 @@ void RemotePlayer::UpdateFromNetwork(const PlayerStateMsg& p_msg)
// Update allow remote customize flag
m_allowRemoteCustomize = (p_msg.customizeFlags & 0x01) != 0;
// Sync multi-part emote frozen state from remote
bool isFrozen = (p_msg.customizeFlags & 0x02) != 0;
int8_t frozenEmoteId = isFrozen ? (int8_t) ((p_msg.customizeFlags >> 2) & 0x07) : -1;
if (frozenEmoteId != m_animator.GetFrozenEmoteId()) {
m_animator.SetFrozenEmoteId(frozenEmoteId, m_roi);
}
// Swap walk animation if changed
if (p_msg.walkAnimId != m_animator.GetWalkAnimId() && p_msg.walkAnimId < g_walkAnimCount) {
m_animator.SetWalkAnimId(p_msg.walkAnimId, m_roi);
@ -191,7 +198,12 @@ void RemotePlayer::Tick(float p_deltaTime)
UpdateVehicleState();
UpdateTransform(p_deltaTime);
m_animator.Tick(p_deltaTime, m_roi, m_targetSpeed > 0.01f);
bool isMoving = m_targetSpeed > 0.01f;
if (m_animator.IsInMultiPartEmote()) {
isMoving = false;
}
m_animator.Tick(p_deltaTime, m_roi, isMoving);
// Update name bubble position and billboard orientation
m_animator.UpdateNameBubble(m_roi);
@ -249,7 +261,11 @@ void RemotePlayer::TriggerEmote(uint8_t p_emoteId)
return;
}
m_animator.TriggerEmote(p_emoteId, m_roi, m_targetSpeed > 0.01f);
bool isMoving = m_targetSpeed > 0.01f;
if (m_animator.IsInMultiPartEmote()) {
isMoving = false;
}
m_animator.TriggerEmote(p_emoteId, m_roi, isMoving);
}
void RemotePlayer::UpdateTransform(float p_deltaTime)

View File

@ -7,6 +7,7 @@
#include "islepathactor.h"
#include "legocameracontroller.h"
#include "legocharactermanager.h"
#include "legonavcontroller.h"
#include "legopathactor.h"
#include "legovideomanager.h"
#include "legoworld.h"
@ -294,6 +295,11 @@ void ThirdPersonCamera::Tick(float p_deltaTime)
float speed = userActor->GetWorldSpeed();
bool isMoving = SDL_fabsf(speed) > 0.01f;
if (m_animator.IsInMultiPartEmote()) {
isMoving = false;
userActor->SetWorldSpeed(0.0f);
NavController()->SetLinearVel(0.0f);
}
m_animator.Tick(p_deltaTime, m_playerROI, isMoving);
}
@ -308,6 +314,16 @@ void ThirdPersonCamera::SetIdleAnimId(uint8_t p_idleAnimId)
m_animator.SetIdleAnimId(p_idleAnimId, m_active ? m_playerROI : nullptr);
}
bool ThirdPersonCamera::IsInMultiPartEmote() const
{
return m_animator.IsInMultiPartEmote();
}
int8_t ThirdPersonCamera::GetFrozenEmoteId() const
{
return m_animator.GetFrozenEmoteId();
}
void ThirdPersonCamera::TriggerEmote(uint8_t p_emoteId)
{
if (!m_active) {
@ -319,7 +335,11 @@ void ThirdPersonCamera::TriggerEmote(uint8_t p_emoteId)
return;
}
m_animator.TriggerEmote(p_emoteId, m_playerROI, SDL_fabsf(userActor->GetWorldSpeed()) > 0.01f);
bool isMoving = SDL_fabsf(userActor->GetWorldSpeed()) > 0.01f;
if (m_animator.IsInMultiPartEmote()) {
isMoving = false;
}
m_animator.TriggerEmote(p_emoteId, m_playerROI, isMoving);
}
void ThirdPersonCamera::ApplyCustomizeChange(uint8_t p_changeType, uint8_t p_partIndex)