Add emote prop ROI support with dynamic detection from animation tree

Emote animations like Toss (CNs013Pa) reference prop nodes (POPMUG,
*POPMUG01) that don't exist in the player's ROI hierarchy. This change
dynamically detects unmatched animation tree nodes and creates prop ROIs
for them, making pizza props visible during the Toss emote.

- Add shared PropGroup struct for ride and emote prop lifecycle
- Add CollectUnmatchedNodes to scan animation trees for missing ROIs
- Extend BuildROIMap/AssignROIIndices to accept an array of extra ROIs
- Add *-prefix fallback: subsequent *-nodes search extra ROIs
- Add ResolvePropLODName mapping for node-to-LOD name differences
- Refactor ride system to use PropGroup (no behavior change)
- Clean up emote props on completion, movement interrupt, and world transition

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Semmler 2026-03-13 17:55:10 -07:00
parent 0de220644b
commit ede39a8bde
No known key found for this signature in database
GPG Key ID: 086DAA1360BEEE5C
6 changed files with 249 additions and 53 deletions

View File

@ -1,12 +1,13 @@
#pragma once
#include "mxtypes.h"
#include "mxgeometry/mxmatrix.h"
#include "mxtypes.h"
#include "realtime/vector.h"
#include "roi/legoroi.h"
#include <map>
#include <string>
#include <vector>
class LegoAnim;
@ -34,8 +35,7 @@ struct AnimCache {
AnimCache(const AnimCache&) = delete;
AnimCache& operator=(const AnimCache&) = delete;
AnimCache(AnimCache&& p_other) noexcept
: anim(p_other.anim), roiMap(p_other.roiMap), roiMapSize(p_other.roiMapSize)
AnimCache(AnimCache&& p_other) noexcept : anim(p_other.anim), roiMap(p_other.roiMap), roiMapSize(p_other.roiMapSize)
{
p_other.roiMap = nullptr;
p_other.roiMapSize = 0;
@ -61,16 +61,15 @@ struct AnimCache {
void BuildROIMap(
LegoAnim* p_anim,
LegoROI* p_rootROI,
LegoROI* p_extraROI,
LegoROI** p_extraROIs,
int p_extraROICount,
LegoROI**& p_roiMap,
MxU32& p_roiMapSize
);
AnimCache* GetOrBuildAnimCache(
std::map<std::string, AnimCache>& p_cacheMap,
LegoROI* p_roi,
const char* p_animName
);
void CollectUnmatchedNodes(LegoAnim* p_anim, LegoROI* p_rootROI, std::vector<std::string>& p_unmatchedNames);
AnimCache* GetOrBuildAnimCache(std::map<std::string, AnimCache>& p_cacheMap, LegoROI* p_roi, const char* p_animName);
inline void EnsureROIMapVisibility(LegoROI** p_roiMap, MxU32 p_roiMapSize)
{

View File

@ -24,6 +24,19 @@ struct CharacterAnimatorConfig {
// When true, save/restore the parent ROI transform during emote playback
// to prevent scale accumulation (needed for ThirdPersonCameraExt's display clone).
bool saveEmoteTransform;
// Suffix used for unique naming of prop ROIs.
// Remote players use m_peerId, local player uses 0.
uint32_t propSuffix;
};
// A group of dynamically-created prop ROIs for an animation (ride or emote).
struct PropGroup {
LegoAnim* anim = nullptr;
LegoROI** roiMap = nullptr;
MxU32 roiMapSize = 0;
LegoROI** propROIs = nullptr;
uint8_t propCount = 0;
};
// Unified character animation component used by both RemotePlayer and ThirdPersonCameraExt.
@ -61,10 +74,10 @@ class CharacterAnimator {
int8_t GetCurrentVehicleType() const { return m_currentVehicleType; }
void SetCurrentVehicleType(int8_t p_vehicleType) { m_currentVehicleType = p_vehicleType; }
bool IsInVehicle() const { return m_currentVehicleType != VEHICLE_NONE; }
LegoROI* GetRideVehicleROI() const { return m_rideVehicleROI; }
LegoAnim* GetRideAnim() const { return m_rideAnim; }
LegoROI** GetRideRoiMap() const { return m_rideRoiMap; }
MxU32 GetRideRoiMapSize() const { return m_rideRoiMapSize; }
LegoROI* GetRideVehicleROI() const { return m_ridePropGroup.propCount > 0 ? m_ridePropGroup.propROIs[0] : nullptr; }
LegoAnim* GetRideAnim() const { return m_ridePropGroup.anim; }
LegoROI** GetRideRoiMap() const { return m_ridePropGroup.roiMap; }
MxU32 GetRideRoiMapSize() const { return m_ridePropGroup.roiMapSize; }
// Animation cache management
void InitAnimCaches(LegoROI* p_roi);
@ -94,6 +107,8 @@ class CharacterAnimator {
AnimCache* GetOrBuildAnimCache(LegoROI* p_roi, const char* p_animName);
void ClearFrozenState();
void ClearPropGroup(PropGroup& p_group);
void BuildEmoteProps(PropGroup& p_group, LegoAnim* p_anim, LegoROI* p_playerROI);
void PlayROISound(const char* p_key, LegoROI* p_roi);
CharacterAnimatorConfig m_config;
@ -133,12 +148,11 @@ class CharacterAnimator {
std::map<std::string, AnimCache> m_animCacheMap;
// Ride animation (vehicle-specific)
LegoAnim* m_rideAnim;
LegoROI** m_rideRoiMap;
MxU32 m_rideRoiMapSize;
LegoROI* m_rideVehicleROI;
PropGroup m_ridePropGroup;
int8_t m_currentVehicleType;
// Emote prop animation (dynamically-created props for emotes like Toss)
PropGroup m_emotePropGroup;
};
} // namespace Common

View File

@ -7,6 +7,7 @@
#include "misc/legotree.h"
#include "roi/legoroi.h"
#include <algorithm>
#include <vector>
using namespace Extensions::Common;
@ -17,7 +18,8 @@ static void AssignROIIndices(
LegoTreeNode* p_node,
LegoROI* p_parentROI,
LegoROI* p_rootROI,
LegoROI* p_extraROI,
LegoROI** p_extraROIs,
int p_extraROICount,
MxU32& p_nextIndex,
std::vector<LegoROI*>& p_entries,
bool& p_rootClaimed
@ -36,11 +38,29 @@ static void AssignROIIndices(
matchedROI = p_rootROI;
p_rootClaimed = true;
}
else if (*name == '*' && p_extraROICount > 0) {
// Subsequent *-prefixed node: search extra ROIs by stripped name.
// FindChildROI checks self first, then children recursively.
const char* stripped = name + 1;
for (int e = 0; e < p_extraROICount; e++) {
matchedROI = p_extraROIs[e]->FindChildROI(stripped, p_extraROIs[e]);
if (matchedROI != nullptr) {
break;
}
}
}
}
else {
matchedROI = p_parentROI->FindChildROI(name, p_parentROI);
if (matchedROI == nullptr && p_extraROI != nullptr) {
matchedROI = p_extraROI->FindChildROI(name, p_extraROI);
if (matchedROI == nullptr) {
// FindChildROI checks self first, so this handles both
// direct name matches and child searches on extra ROIs.
for (int e = 0; e < p_extraROICount; e++) {
matchedROI = p_extraROIs[e]->FindChildROI(name, p_extraROIs[e]);
if (matchedROI != nullptr) {
break;
}
}
}
}
@ -55,14 +75,24 @@ static void AssignROIIndices(
}
for (MxS32 i = 0; i < p_node->GetNumChildren(); i++) {
AssignROIIndices(p_node->GetChild(i), roi, p_rootROI, p_extraROI, p_nextIndex, p_entries, p_rootClaimed);
AssignROIIndices(
p_node->GetChild(i),
roi,
p_rootROI,
p_extraROIs,
p_extraROICount,
p_nextIndex,
p_entries,
p_rootClaimed
);
}
}
void AnimUtils::BuildROIMap(
LegoAnim* p_anim,
LegoROI* p_rootROI,
LegoROI* p_extraROI,
LegoROI** p_extraROIs,
int p_extraROICount,
LegoROI**& p_roiMap,
MxU32& p_roiMapSize
)
@ -79,7 +109,7 @@ void AnimUtils::BuildROIMap(
MxU32 nextIndex = 1;
std::vector<LegoROI*> entries;
bool rootClaimed = false;
AssignROIIndices(root, nullptr, p_rootROI, p_extraROI, nextIndex, entries, rootClaimed);
AssignROIIndices(root, nullptr, p_rootROI, p_extraROIs, p_extraROICount, nextIndex, entries, rootClaimed);
if (entries.empty()) {
return;
@ -129,7 +159,67 @@ AnimUtils::AnimCache* AnimUtils::GetOrBuildAnimCache(
// Build and cache
AnimCache& cache = p_cacheMap[p_animName];
cache.anim = anim;
BuildROIMap(anim, p_roi, nullptr, cache.roiMap, cache.roiMapSize);
BuildROIMap(anim, p_roi, nullptr, 0, cache.roiMap, cache.roiMapSize);
return &cache;
}
// Read-only tree walk: collect names of animation nodes that don't match the player's ROI hierarchy.
static void CollectUnmatchedNodesRecursive(
LegoTreeNode* p_node,
LegoROI* p_parentROI,
LegoROI* p_rootROI,
std::vector<std::string>& p_unmatchedNames,
bool& p_rootClaimed
)
{
LegoROI* roi = p_parentROI;
LegoAnimNodeData* data = (LegoAnimNodeData*) p_node->GetData();
const char* name = data ? data->GetName() : nullptr;
if (name != nullptr && *name != '-') {
if (*name == '*' || p_parentROI == nullptr) {
roi = p_rootROI;
if (!p_rootClaimed) {
p_rootClaimed = true;
}
else if (*name == '*') {
// Subsequent *-prefixed node: strip prefix, add lowercased name
std::string stripped(name + 1);
std::transform(stripped.begin(), stripped.end(), stripped.begin(), ::tolower);
if (std::find(p_unmatchedNames.begin(), p_unmatchedNames.end(), stripped) == p_unmatchedNames.end()) {
p_unmatchedNames.push_back(stripped);
}
}
}
else {
LegoROI* matchedROI = p_parentROI->FindChildROI(name, p_parentROI);
if (matchedROI == nullptr) {
std::string lowered(name);
std::transform(lowered.begin(), lowered.end(), lowered.begin(), ::tolower);
if (std::find(p_unmatchedNames.begin(), p_unmatchedNames.end(), lowered) == p_unmatchedNames.end()) {
p_unmatchedNames.push_back(lowered);
}
}
}
}
for (MxS32 i = 0; i < p_node->GetNumChildren(); i++) {
CollectUnmatchedNodesRecursive(p_node->GetChild(i), roi, p_rootROI, p_unmatchedNames, p_rootClaimed);
}
}
void AnimUtils::CollectUnmatchedNodes(LegoAnim* p_anim, LegoROI* p_rootROI, std::vector<std::string>& p_unmatchedNames)
{
if (!p_anim || !p_rootROI) {
return;
}
LegoTreeNode* root = p_anim->GetRoot();
if (!root) {
return;
}
bool rootClaimed = false;
CollectUnmatchedNodesRecursive(root, nullptr, p_rootROI, p_unmatchedNames, rootClaimed);
}

View File

@ -23,13 +23,13 @@ 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_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_frozenAnimCache(nullptr), m_frozenAnimDuration(0.0f), m_clickAnimObjectId(0), m_currentVehicleType(VEHICLE_NONE)
{
}
CharacterAnimator::~CharacterAnimator()
{
ClearPropGroup(m_emotePropGroup);
ClearRideAnimation();
}
@ -50,10 +50,10 @@ void CharacterAnimator::Tick(float p_deltaTime, LegoROI* p_roi, bool p_isMoving)
LegoROI** walkRoiMap = nullptr;
MxU32 walkRoiMapSize = 0;
if (m_currentVehicleType != VEHICLE_NONE && m_rideAnim && m_rideRoiMap) {
walkAnim = m_rideAnim;
walkRoiMap = m_rideRoiMap;
walkRoiMapSize = m_rideRoiMapSize;
if (m_currentVehicleType != VEHICLE_NONE && m_ridePropGroup.anim && m_ridePropGroup.roiMap) {
walkAnim = m_ridePropGroup.anim;
walkRoiMap = m_ridePropGroup.roiMap;
walkRoiMapSize = m_ridePropGroup.roiMapSize;
}
else if (m_walkAnimCache && m_walkAnimCache->anim && m_walkAnimCache->roiMap) {
walkAnim = m_walkAnimCache->anim;
@ -78,6 +78,7 @@ void CharacterAnimator::Tick(float p_deltaTime, LegoROI* p_roi, bool p_isMoving)
if (m_emoteActive) {
m_emoteActive = false;
m_emoteAnimCache = nullptr;
ClearPropGroup(m_emotePropGroup);
}
}
@ -127,12 +128,15 @@ void CharacterAnimator::Tick(float p_deltaTime, LegoROI* p_roi, bool p_isMoving)
// Emote completed -- return to stationary flow
m_emoteActive = false;
m_emoteAnimCache = nullptr;
ClearPropGroup(m_emotePropGroup);
m_wasMoving = false;
m_idleTime = 0.0f;
m_idleAnimTime = 0.0f;
}
}
else {
LegoROI** emoteRoiMap =
m_emotePropGroup.roiMap != nullptr ? m_emotePropGroup.roiMap : m_emoteAnimCache->roiMap;
MxMatrix transform(m_config.saveEmoteTransform ? m_emoteParentTransform : p_roi->GetLocal2World());
LegoTreeNode* root = m_emoteAnimCache->anim->GetRoot();
@ -141,7 +145,7 @@ void CharacterAnimator::Tick(float p_deltaTime, LegoROI* p_roi, bool p_isMoving)
root->GetChild(i),
transform,
(LegoTime) m_emoteTime,
m_emoteAnimCache->roiMap
emoteRoiMap
);
}
@ -243,6 +247,7 @@ void CharacterAnimator::TriggerEmote(uint8_t p_emoteId, LegoROI* p_roi, bool p_i
}
StopClickAnimation();
ClearPropGroup(m_emotePropGroup);
m_currentEmoteId = p_emoteId;
m_emoteAnimCache = cache;
@ -250,6 +255,8 @@ void CharacterAnimator::TriggerEmote(uint8_t p_emoteId, LegoROI* p_roi, bool p_i
m_emoteDuration = (float) cache->anim->GetDuration();
m_emoteActive = true;
BuildEmoteProps(m_emotePropGroup, cache->anim, p_roi);
const char* sound = g_emoteEntries[p_emoteId].phases[1].sound;
if (sound) {
PlayROISound(sound, p_roi);
@ -279,6 +286,7 @@ void CharacterAnimator::TriggerEmote(uint8_t p_emoteId, LegoROI* p_roi, bool p_i
}
StopClickAnimation();
ClearPropGroup(m_emotePropGroup);
m_currentEmoteId = p_emoteId;
m_emoteAnimCache = cache;
@ -286,6 +294,8 @@ void CharacterAnimator::TriggerEmote(uint8_t p_emoteId, LegoROI* p_roi, bool p_i
m_emoteDuration = (float) cache->anim->GetDuration();
m_emoteActive = true;
BuildEmoteProps(m_emotePropGroup, cache->anim, p_roi);
const char* sound = g_emoteEntries[p_emoteId].phases[0].sound;
if (sound) {
PlayROISound(sound, p_roi);
@ -344,8 +354,8 @@ void CharacterAnimator::BuildRideAnimation(int8_t p_vehicleType, LegoROI* p_play
return;
}
m_rideAnim = static_cast<LegoAnimPresenter*>(presenter)->GetAnimation();
if (!m_rideAnim) {
m_ridePropGroup.anim = static_cast<LegoAnimPresenter*>(presenter)->GetAnimation();
if (!m_ridePropGroup.anim) {
return;
}
@ -358,28 +368,28 @@ void CharacterAnimator::BuildRideAnimation(int8_t p_vehicleType, LegoROI* p_play
else {
SDL_snprintf(variantName, sizeof(variantName), "tp_vehicle");
}
m_rideVehicleROI = CharacterManager()->CreateAutoROI(variantName, baseName, FALSE);
if (m_rideVehicleROI) {
m_rideVehicleROI->SetName(vehicleVariantName);
LegoROI* vehicleROI = CharacterManager()->CreateAutoROI(variantName, baseName, FALSE);
if (vehicleROI) {
vehicleROI->SetName(vehicleVariantName);
m_ridePropGroup.propROIs = new LegoROI*[1];
m_ridePropGroup.propROIs[0] = vehicleROI;
m_ridePropGroup.propCount = 1;
}
AnimUtils::BuildROIMap(m_rideAnim, p_playerROI, m_rideVehicleROI, m_rideRoiMap, m_rideRoiMapSize);
AnimUtils::BuildROIMap(
m_ridePropGroup.anim,
p_playerROI,
m_ridePropGroup.propROIs,
m_ridePropGroup.propCount,
m_ridePropGroup.roiMap,
m_ridePropGroup.roiMapSize
);
m_animTime = 0.0f;
}
void CharacterAnimator::ClearRideAnimation()
{
if (m_rideRoiMap) {
delete[] m_rideRoiMap;
m_rideRoiMap = nullptr;
m_rideRoiMapSize = 0;
}
if (m_rideVehicleROI) {
VideoManager()->Get3DManager()->Remove(*m_rideVehicleROI);
CharacterManager()->ReleaseAutoROI(m_rideVehicleROI);
m_rideVehicleROI = nullptr;
}
m_rideAnim = nullptr;
ClearPropGroup(m_ridePropGroup);
m_currentVehicleType = VEHICLE_NONE;
}
@ -417,6 +427,89 @@ void CharacterAnimator::ClearFrozenState()
m_frozenEmoteId = -1;
m_frozenAnimCache = nullptr;
m_frozenAnimDuration = 0.0f;
ClearPropGroup(m_emotePropGroup);
}
void CharacterAnimator::ClearPropGroup(PropGroup& p_group)
{
delete[] p_group.roiMap;
p_group.roiMap = nullptr;
p_group.roiMapSize = 0;
for (uint8_t i = 0; i < p_group.propCount; i++) {
if (p_group.propROIs[i]) {
VideoManager()->Get3DManager()->Remove(*p_group.propROIs[i]);
CharacterManager()->ReleaseAutoROI(p_group.propROIs[i]);
}
}
delete[] p_group.propROIs;
p_group.propROIs = nullptr;
p_group.propCount = 0;
p_group.anim = nullptr;
}
// Maps animation tree node names to actual LOD names when they differ.
static const char* ResolvePropLODName(const char* p_nodeName)
{
static const struct {
const char* nodePrefix;
const char* lodName;
} mappings[] = {
{"popmug", "pizpie"},
};
for (const auto& m : mappings) {
if (!SDL_strncasecmp(p_nodeName, m.nodePrefix, SDL_strlen(m.nodePrefix))) {
return m.lodName;
}
}
return p_nodeName;
}
void CharacterAnimator::BuildEmoteProps(PropGroup& p_group, LegoAnim* p_anim, LegoROI* p_playerROI)
{
std::vector<std::string> unmatchedNames;
AnimUtils::CollectUnmatchedNodes(p_anim, p_playerROI, unmatchedNames);
if (unmatchedNames.empty()) {
return;
}
std::vector<LegoROI*> createdROIs;
for (const std::string& name : unmatchedNames) {
char uniqueName[64];
if (m_config.propSuffix != 0) {
SDL_snprintf(uniqueName, sizeof(uniqueName), "%s_mp_%u", name.c_str(), m_config.propSuffix);
}
else {
SDL_snprintf(uniqueName, sizeof(uniqueName), "tp_prop_%s", name.c_str());
}
const char* lodName = ResolvePropLODName(name.c_str());
LegoROI* propROI = CharacterManager()->CreateAutoROI(uniqueName, lodName, FALSE);
if (propROI) {
propROI->SetName(name.c_str());
createdROIs.push_back(propROI);
}
}
if (createdROIs.empty()) {
return;
}
p_group.propCount = (uint8_t) createdROIs.size();
p_group.propROIs = new LegoROI*[p_group.propCount];
for (uint8_t i = 0; i < p_group.propCount; i++) {
p_group.propROIs[i] = createdROIs[i];
}
AnimUtils::BuildROIMap(
p_anim,
p_playerROI,
p_group.propROIs,
p_group.propCount,
p_group.roiMap,
p_group.roiMapSize
);
}
void CharacterAnimator::ClearAnimCaches()

View File

@ -28,8 +28,8 @@ RemotePlayer::RemotePlayer(uint32_t p_peerId, uint8_t p_actorId, uint8_t p_displ
: m_peerId(p_peerId), m_actorId(p_actorId), m_displayActorIndex(p_displayActorIndex), m_roi(nullptr),
m_spawned(false), m_visible(false), m_targetSpeed(0.0f), m_targetVehicleType(VEHICLE_NONE), m_targetWorldId(-1),
m_lastUpdateTime(SDL_GetTicks()), m_hasReceivedUpdate(false),
m_animator(Common::CharacterAnimatorConfig{/*.saveEmoteTransform=*/false}), m_vehicleROI(nullptr),
m_nameBubble(nullptr), m_allowRemoteCustomize(true)
m_animator(Common::CharacterAnimatorConfig{/*.saveEmoteTransform=*/false, /*.propSuffix=*/p_peerId}),
m_vehicleROI(nullptr), m_nameBubble(nullptr), m_allowRemoteCustomize(true)
{
m_displayName[0] = '\0';
const char* displayName = GetDisplayActorName();

View File

@ -28,8 +28,8 @@ using namespace Extensions::Common;
using namespace Extensions::ThirdPersonCamera;
Controller::Controller()
: m_animator(CharacterAnimatorConfig{/*.saveEmoteTransform=*/true}), m_enabled(false), m_active(false),
m_pendingWorldTransition(false), m_playerROI(nullptr)
: m_animator(CharacterAnimatorConfig{/*.saveEmoteTransform=*/true, /*.propSuffix=*/0}), m_enabled(false),
m_active(false), m_pendingWorldTransition(false), m_playerROI(nullptr)
{
}