isle-portable/extensions/src/multiplayer/remoteplayer.cpp
foxtacles 1a8c6c70ea
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>
2026-03-10 02:18:37 +01:00

374 lines
9.7 KiB
C++

#include "extensions/multiplayer/remoteplayer.h"
#include "3dmanager/lego3dmanager.h"
#include "extensions/multiplayer/charactercloner.h"
#include "extensions/multiplayer/charactercustomizer.h"
#include "legocharactermanager.h"
#include "legovideomanager.h"
#include "legoworld.h"
#include "misc.h"
#include "mxgeometry/mxgeometry3d.h"
#include "realtime/realtime.h"
#include "roi/legoroi.h"
#include <SDL3/SDL_log.h>
#include <SDL3/SDL_stdinc.h>
#include <SDL3/SDL_timer.h>
#include <vec.h>
using namespace Multiplayer;
RemotePlayer::RemotePlayer(uint32_t p_peerId, uint8_t p_actorId, uint8_t p_displayActorIndex)
: 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(CharacterAnimatorConfig{/*.saveEmoteTransform=*/false}), m_vehicleROI(nullptr),
m_allowRemoteCustomize(true)
{
m_displayName[0] = '\0';
const char* displayName = GetDisplayActorName();
SDL_snprintf(m_uniqueName, sizeof(m_uniqueName), "%s_mp_%u", displayName, p_peerId);
ZEROVEC3(m_targetPosition);
m_targetDirection[0] = 0.0f;
m_targetDirection[1] = 0.0f;
m_targetDirection[2] = 1.0f;
m_targetUp[0] = 0.0f;
m_targetUp[1] = 1.0f;
m_targetUp[2] = 0.0f;
SET3(m_currentPosition, m_targetPosition);
SET3(m_currentDirection, m_targetDirection);
SET3(m_currentUp, m_targetUp);
}
RemotePlayer::~RemotePlayer()
{
Despawn();
}
void RemotePlayer::Spawn(LegoWorld* p_isleWorld)
{
if (m_spawned) {
return;
}
LegoCharacterManager* charMgr = CharacterManager();
if (!charMgr) {
return;
}
const char* actorName = GetDisplayActorName();
if (!actorName) {
return;
}
m_roi = CharacterCloner::Clone(charMgr, m_uniqueName, actorName);
if (!m_roi) {
return;
}
VideoManager()->Get3DManager()->Add(*m_roi);
VideoManager()->Get3DManager()->Moved(*m_roi);
m_roi->SetVisibility(FALSE);
m_spawned = true;
m_visible = false;
// Initialize customize state from the display actor's info
uint8_t actorInfoIndex = CharacterCustomizer::ResolveActorInfoIndex(m_displayActorIndex);
m_customizeState.InitFromActorInfo(actorInfoIndex);
// Build initial walk and idle animation caches
m_animator.InitAnimCaches(m_roi);
// Create name bubble if we already have a name
if (m_displayName[0] != '\0') {
CreateNameBubble();
}
}
void RemotePlayer::Despawn()
{
if (!m_spawned) {
return;
}
m_animator.StopClickAnimation();
DestroyNameBubble();
ExitVehicle();
if (m_roi) {
VideoManager()->Get3DManager()->Remove(*m_roi);
CharacterManager()->ReleaseActor(m_uniqueName);
m_roi = nullptr;
}
// Clear cached animation ROI maps (anim pointers are world-owned).
m_animator.ClearAll();
m_spawned = false;
m_visible = false;
}
const char* RemotePlayer::GetDisplayActorName() const
{
return CharacterManager()->GetActorName(m_displayActorIndex);
}
void RemotePlayer::UpdateFromNetwork(const PlayerStateMsg& p_msg)
{
float posDelta = SDL_sqrtf(DISTSQRD3(p_msg.position, m_targetPosition));
SET3(m_targetPosition, p_msg.position);
SET3(m_targetDirection, p_msg.direction);
SET3(m_targetUp, p_msg.up);
m_targetSpeed = posDelta > 0.01f ? posDelta : 0.0f;
m_targetVehicleType = p_msg.vehicleType;
m_targetWorldId = p_msg.worldId;
m_lastUpdateTime = SDL_GetTicks();
if (!m_hasReceivedUpdate) {
SET3(m_currentPosition, m_targetPosition);
SET3(m_currentDirection, m_targetDirection);
SET3(m_currentUp, m_targetUp);
m_targetSpeed = 0.0f; // No meaningful speed from first sample
m_hasReceivedUpdate = true;
}
// Update display name (can change when player switches save file)
char newName[8];
SDL_memcpy(newName, p_msg.name, sizeof(newName));
newName[sizeof(newName) - 1] = '\0';
if (SDL_strcmp(m_displayName, newName) != 0) {
SDL_Log(
"RemotePlayer[%u] name changed: '%s' -> '%s' (spawned=%d)",
m_peerId,
m_displayName,
newName,
m_spawned
);
SDL_memcpy(m_displayName, newName, sizeof(m_displayName));
// Recreate bubble with new name (or create for the first time)
if (m_spawned) {
DestroyNameBubble();
CreateNameBubble();
}
}
// Update customize state from packed data
CustomizeState newState;
newState.Unpack(p_msg.customizeData);
if (newState != m_customizeState) {
uint8_t actorInfoIndex = CharacterCustomizer::ResolveActorInfoIndex(m_displayActorIndex);
m_customizeState = newState;
if (m_spawned && m_roi) {
CharacterCustomizer::ApplyFullState(m_roi, actorInfoIndex, m_customizeState);
}
}
// 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);
}
// Swap idle animation if changed
if (p_msg.idleAnimId != m_animator.GetIdleAnimId() && p_msg.idleAnimId < g_idleAnimCount) {
m_animator.SetIdleAnimId(p_msg.idleAnimId, m_roi);
}
}
void RemotePlayer::Tick(float p_deltaTime)
{
if (!m_spawned || !m_visible) {
return;
}
UpdateVehicleState();
UpdateTransform(p_deltaTime);
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);
}
void RemotePlayer::ReAddToScene()
{
if (m_spawned && m_roi) {
VideoManager()->Get3DManager()->Add(*m_roi);
}
if (m_vehicleROI) {
VideoManager()->Get3DManager()->Add(*m_vehicleROI);
}
if (m_animator.GetRideVehicleROI()) {
VideoManager()->Get3DManager()->Add(*m_animator.GetRideVehicleROI());
}
}
void RemotePlayer::SetVisible(bool p_visible)
{
if (!m_spawned || !m_roi) {
return;
}
m_visible = p_visible;
if (p_visible) {
if (m_animator.GetCurrentVehicleType() != VEHICLE_NONE && IsLargeVehicle(m_animator.GetCurrentVehicleType())) {
m_roi->SetVisibility(FALSE);
if (m_vehicleROI) {
m_vehicleROI->SetVisibility(TRUE);
}
}
else {
m_roi->SetVisibility(TRUE);
if (m_vehicleROI) {
m_vehicleROI->SetVisibility(FALSE);
}
}
}
else {
m_roi->SetVisibility(FALSE);
if (m_vehicleROI) {
m_vehicleROI->SetVisibility(FALSE);
}
if (m_animator.GetRideVehicleROI()) {
m_animator.GetRideVehicleROI()->SetVisibility(FALSE);
}
}
}
void RemotePlayer::TriggerEmote(uint8_t p_emoteId)
{
if (!m_spawned) {
return;
}
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)
{
LERP3(m_currentPosition, m_currentPosition, m_targetPosition, 0.2f);
LERP3(m_currentDirection, m_currentDirection, m_targetDirection, 0.2f);
LERP3(m_currentUp, m_currentUp, m_targetUp, 0.2f);
// The network sends forward-z (visual forward). Character meshes face -z,
// so negate to get backward-z for the ROI (mesh faces the correct way).
Mx3DPointFloat pos(m_currentPosition[0], m_currentPosition[1], m_currentPosition[2]);
Mx3DPointFloat dir(-m_currentDirection[0], -m_currentDirection[1], -m_currentDirection[2]);
Mx3DPointFloat up(m_currentUp[0], m_currentUp[1], m_currentUp[2]);
MxMatrix mat;
CalcLocalTransform(pos, dir, up, mat);
m_roi->WrappedSetLocal2WorldWithWorldDataUpdate(mat);
VideoManager()->Get3DManager()->Moved(*m_roi);
if (m_vehicleROI && m_animator.GetCurrentVehicleType() != VEHICLE_NONE &&
IsLargeVehicle(m_animator.GetCurrentVehicleType())) {
m_vehicleROI->WrappedSetLocal2WorldWithWorldDataUpdate(mat);
VideoManager()->Get3DManager()->Moved(*m_vehicleROI);
}
}
void RemotePlayer::UpdateVehicleState()
{
if (m_targetVehicleType != m_animator.GetCurrentVehicleType()) {
if (m_animator.GetCurrentVehicleType() != VEHICLE_NONE) {
ExitVehicle();
}
if (m_targetVehicleType != VEHICLE_NONE) {
EnterVehicle(m_targetVehicleType);
}
}
}
void RemotePlayer::EnterVehicle(int8_t p_vehicleType)
{
if (p_vehicleType < 0 || p_vehicleType >= VEHICLE_COUNT) {
return;
}
m_animator.SetCurrentVehicleType(p_vehicleType);
m_animator.SetAnimTime(0.0f);
if (IsLargeVehicle(p_vehicleType)) {
char vehicleName[48];
SDL_snprintf(vehicleName, sizeof(vehicleName), "%s_mp_%u", g_vehicleROINames[p_vehicleType], m_peerId);
m_vehicleROI = CharacterManager()->CreateAutoROI(vehicleName, g_vehicleROINames[p_vehicleType], FALSE);
if (m_vehicleROI) {
m_roi->SetVisibility(FALSE);
MxMatrix mat(m_roi->GetLocal2World());
m_vehicleROI->WrappedSetLocal2WorldWithWorldDataUpdate(mat);
m_vehicleROI->SetVisibility(m_visible ? TRUE : FALSE);
}
}
else {
m_animator.BuildRideAnimation(p_vehicleType, m_roi, m_peerId);
}
}
void RemotePlayer::ExitVehicle()
{
if (m_animator.GetCurrentVehicleType() == VEHICLE_NONE) {
return;
}
if (m_vehicleROI) {
VideoManager()->Get3DManager()->Remove(*m_vehicleROI);
CharacterManager()->ReleaseAutoROI(m_vehicleROI);
m_vehicleROI = nullptr;
}
m_animator.ClearRideAnimation();
if (m_visible) {
m_roi->SetVisibility(TRUE);
}
m_animator.SetAnimTime(0.0f);
}
void RemotePlayer::CreateNameBubble()
{
m_animator.CreateNameBubble(m_displayName);
}
void RemotePlayer::DestroyNameBubble()
{
m_animator.DestroyNameBubble();
}
void RemotePlayer::SetNameBubbleVisible(bool p_visible)
{
m_animator.SetNameBubbleVisible(p_visible);
}
void RemotePlayer::StopClickAnimation()
{
m_animator.StopClickAnimation();
}