diff --git a/LEGO1/lego/legoomni/include/islepathactor.h b/LEGO1/lego/legoomni/include/islepathactor.h index d1966b9c..df747516 100644 --- a/LEGO1/lego/legoomni/include/islepathactor.h +++ b/LEGO1/lego/legoomni/include/islepathactor.h @@ -1,6 +1,7 @@ #ifndef ISLEPATHACTOR_H #define ISLEPATHACTOR_H +#include "extensions/fwd.h" #include "legogamestate.h" #include "legopathactor.h" #include "mxtypes.h" @@ -139,6 +140,8 @@ class IslePathActor : public LegoPathActor { // IslePathActor::`scalar deleting destructor' protected: + friend class Extensions::ThirdPersonCamera::Controller; + LegoWorld* m_world; // 0x154 LegoPathActor* m_previousActor; // 0x158 MxFloat m_previousVel; // 0x15c diff --git a/extensions/include/extensions/multiplayer/networkmanager.h b/extensions/include/extensions/multiplayer/networkmanager.h index 3d3ad76f..523a065a 100644 --- a/extensions/include/extensions/multiplayer/networkmanager.h +++ b/extensions/include/extensions/multiplayer/networkmanager.h @@ -140,6 +140,8 @@ class NetworkManager : public MxCore { void BroadcastAnimUpdate(uint16_t p_animIndex); void SendAnimUpdateToPlayer(uint16_t p_animIndex, uint32_t p_targetPeerId); void BroadcastAnimStart(uint16_t p_animIndex); + void BroadcastAnimComplete(uint16_t p_animIndex); + void HandleAnimComplete(const AnimCompleteMsg& p_msg); int16_t GetPeerLocation(uint32_t p_peerId) const; bool GetPeerPosition(uint32_t p_peerId, float& p_x, float& p_z) const; bool IsPeerNearby(uint32_t p_peerId, float p_refX, float p_refZ) const; @@ -191,7 +193,6 @@ class NetworkManager : public MxCore { std::atomic m_pendingAnimInterest; std::atomic m_pendingAnimCancel; - bool m_disableAllNPCs; bool m_showNameBubbles; bool m_lastCameraEnabled; bool m_wasInRestrictedArea; diff --git a/extensions/include/extensions/multiplayer/platformcallbacks.h b/extensions/include/extensions/multiplayer/platformcallbacks.h index da14359e..18cf610c 100644 --- a/extensions/include/extensions/multiplayer/platformcallbacks.h +++ b/extensions/include/extensions/multiplayer/platformcallbacks.h @@ -33,6 +33,11 @@ class PlatformCallbacks { // Called when animation eligibility state changes (location change, player join/leave, etc.). // p_json = JSON payload with location, coordinator state, and per-animation slot fill status. virtual void OnAnimationsAvailable(const char* p_json) = 0; + + // Called when an animation completes successfully (natural completion, not cancellation). + // Only fired for actual participants, not observers. + // p_json = JSON with eventId, animIndex, and participant details (charIndex, displayName). + virtual void OnAnimationCompleted(const char* p_json) = 0; }; } // namespace Multiplayer diff --git a/extensions/include/extensions/multiplayer/platforms/emscripten/callbacks.h b/extensions/include/extensions/multiplayer/platforms/emscripten/callbacks.h index 4e95ecd0..cb387f13 100644 --- a/extensions/include/extensions/multiplayer/platforms/emscripten/callbacks.h +++ b/extensions/include/extensions/multiplayer/platforms/emscripten/callbacks.h @@ -15,6 +15,7 @@ class EmscriptenCallbacks : public PlatformCallbacks { void OnAllowCustomizeChanged(bool p_enabled) override; void OnConnectionStatusChanged(int p_status) override; void OnAnimationsAvailable(const char* p_json) override; + void OnAnimationCompleted(const char* p_json) override; }; } // namespace Multiplayer diff --git a/extensions/include/extensions/multiplayer/platforms/native/nativecallbacks.h b/extensions/include/extensions/multiplayer/platforms/native/nativecallbacks.h index 7049ac02..1a429a9a 100644 --- a/extensions/include/extensions/multiplayer/platforms/native/nativecallbacks.h +++ b/extensions/include/extensions/multiplayer/platforms/native/nativecallbacks.h @@ -15,6 +15,7 @@ class NativeCallbacks : public PlatformCallbacks { void OnAllowCustomizeChanged(bool p_enabled) override; void OnConnectionStatusChanged(int p_status) override; void OnAnimationsAvailable(const char* p_json) override; + void OnAnimationCompleted(const char* p_json) override; }; } // namespace Multiplayer diff --git a/extensions/include/extensions/multiplayer/protocol.h b/extensions/include/extensions/multiplayer/protocol.h index 737057a5..a6e8403c 100644 --- a/extensions/include/extensions/multiplayer/protocol.h +++ b/extensions/include/extensions/multiplayer/protocol.h @@ -29,6 +29,7 @@ enum MessageType : uint8_t { MSG_ANIM_CANCEL = 12, MSG_ANIM_UPDATE = 13, MSG_ANIM_START = 14, + MSG_ANIM_COMPLETE = 15, MSG_ASSIGN_ID = 0xFF }; @@ -187,6 +188,22 @@ struct AnimStartMsg { uint16_t animIndex; }; +// Per-participant data in AnimCompleteMsg +struct AnimCompletionParticipant { + uint32_t peerId; + int8_t charIndex; // Participant's character (g_characters index) + char displayName[8]; // 7 chars + null +}; + +// Host -> All: animation completed successfully (natural completion only, not cancellation) +struct AnimCompleteMsg { + MessageHeader header; + uint64_t eventId; // Random 64-bit ID unique to this completion event + uint32_t objectId; // SI file object ID (stable, used as frontend key) + uint8_t participantCount; + AnimCompletionParticipant participants[8]; +}; + #pragma pack(pop) using Extensions::Common::IsValidActorId; diff --git a/extensions/include/extensions/multiplayer/remoteplayer.h b/extensions/include/extensions/multiplayer/remoteplayer.h index 4c0fe269..d364072a 100644 --- a/extensions/include/extensions/multiplayer/remoteplayer.h +++ b/extensions/include/extensions/multiplayer/remoteplayer.h @@ -54,6 +54,8 @@ class RemotePlayer { bool IsMoving() const { return m_animator.IsInVehicle() || m_targetSpeed > 0.01f; } bool IsInMultiPartEmote() const { return m_animator.IsInMultiPartEmote(); } + const char* GetDisplayName() const { return m_displayName; } + void SetAnimationLocked(bool p_locked) { m_animationLocked = p_locked; } bool IsAnimationLocked() const { return m_animationLocked; } diff --git a/extensions/include/extensions/thirdpersoncamera/inputhandler.h b/extensions/include/extensions/thirdpersoncamera/inputhandler.h index 7b4e07d7..36784ebb 100644 --- a/extensions/include/extensions/thirdpersoncamera/inputhandler.h +++ b/extensions/include/extensions/thirdpersoncamera/inputhandler.h @@ -22,6 +22,7 @@ class InputHandler { SDL_FingerID GetFingerID(int p_idx) const { return m_touch.id[p_idx]; } bool IsLeftButtonHeld() const { return m_leftButtonHeld; } + bool IsLmbHeldForMovement() const; bool ConsumeAutoDisable(); bool ConsumeAutoEnable(); @@ -31,6 +32,7 @@ class InputHandler { static constexpr float CAMERA_ZONE_X = 0.5f; static constexpr float PINCH_TRANSITION_THRESHOLD = 0.03f; + static constexpr Uint64 LMB_HOLD_THRESHOLD_MS = 300; private: struct TouchState { @@ -46,6 +48,7 @@ class InputHandler { bool m_wantsAutoEnable; bool m_rightButtonHeld; bool m_leftButtonHeld; + Uint64 m_leftButtonDownTime; float m_savedMouseX; float m_savedMouseY; }; diff --git a/extensions/src/multiplayer/networkmanager.cpp b/extensions/src/multiplayer/networkmanager.cpp index e1b9f4d1..34c5774b 100644 --- a/extensions/src/multiplayer/networkmanager.cpp +++ b/extensions/src/multiplayer/networkmanager.cpp @@ -68,10 +68,10 @@ NetworkManager::NetworkManager() m_inIsleWorld(false), m_registered(false), m_pendingToggleThirdPerson(false), m_pendingToggleNameBubbles(false), m_pendingWalkAnim(-1), m_pendingIdleAnim(-1), m_pendingEmote(-1), m_pendingToggleAllowCustomize(false), m_pendingAnimInterest(-1), m_pendingAnimCancel(false), m_localPendingAnimInterest(-1), - m_playingAnimIndex(Animation::ANIM_INDEX_NONE), m_disableAllNPCs(false), m_showNameBubbles(true), - m_lastCameraEnabled(false), m_wasInRestrictedArea(false), m_animStateDirty(false), m_animInterestDirty(false), - m_lastAnimPushTime(0), m_connectionState(STATE_DISCONNECTED), m_wasRejected(false), m_reconnectAttempt(0), - m_reconnectDelay(0), m_nextReconnectTime(0) + m_playingAnimIndex(Animation::ANIM_INDEX_NONE), m_showNameBubbles(true), m_lastCameraEnabled(false), + m_wasInRestrictedArea(false), m_animStateDirty(false), m_animInterestDirty(false), m_lastAnimPushTime(0), + m_connectionState(STATE_DISCONNECTED), m_wasRejected(false), m_reconnectAttempt(0), m_reconnectDelay(0), + m_nextReconnectTime(0) { } @@ -90,9 +90,7 @@ MxResult NetworkManager::Tickle() ProcessPendingRequests(); CheckConnectionState(); - if (m_disableAllNPCs) { - EnforceDisableNPCs(); - } + EnforceDisableNPCs(); // Detect camera state changes for platform notification ThirdPersonCamera::Controller* cam = GetCamera(); @@ -354,10 +352,7 @@ void NetworkManager::OnWorldEnabled(LegoWorld* p_world) } NotifyPlayerCountChanged(); - - if (m_disableAllNPCs) { - EnforceDisableNPCs(); - } + EnforceDisableNPCs(); // Refresh animation catalog from the animation manager if (AnimationManager()) { @@ -752,21 +747,13 @@ void NetworkManager::ProcessIncomingPackets() m_localPeerId = assignedId; m_worldSync.SetLocalPeerId(assignedId); m_animCoordinator.SetLocalPeerId(assignedId); - } - if (length >= 6) { - uint8_t maxActors = data[5]; - if (maxActors <= 40) { - LegoAnimationManager::configureLegoAnimationManager(maxActors); - if (AnimationManager()) { - AnimationManager()->m_maxAllowedExtras = maxActors; - AnimationManager()->m_numAllowedExtras = - SDL_min(AnimationManager()->m_numAllowedExtras, (MxU32) maxActors); - } - m_disableAllNPCs = (maxActors == 0); - if (m_disableAllNPCs) { - EnforceDisableNPCs(); - } + + LegoAnimationManager::configureLegoAnimationManager(0); + if (AnimationManager()) { + AnimationManager()->m_maxAllowedExtras = 0; + AnimationManager()->m_numAllowedExtras = 0; } + EnforceDisableNPCs(); } break; } @@ -858,6 +845,13 @@ void NetworkManager::ProcessIncomingPackets() } break; } + case MSG_ANIM_COMPLETE: { + AnimCompleteMsg msg; + if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_ANIM_COMPLETE) { + HandleAnimComplete(msg); + } + break; + } default: break; } @@ -1224,6 +1218,7 @@ void NetworkManager::TickAnimation() } if (IsHost() && m_playingAnimIndex != Animation::ANIM_INDEX_NONE) { + BroadcastAnimComplete(m_playingAnimIndex); // Must fire before EraseSession destroys participant data m_animSessionHost.EraseSession(m_playingAnimIndex); BroadcastAnimUpdate(m_playingAnimIndex); // Broadcast cleared state } @@ -1579,6 +1574,136 @@ void NetworkManager::BroadcastAnimStart(uint16_t p_animIndex) m_animCoordinator.ApplyAnimStart(p_animIndex); } +void NetworkManager::BroadcastAnimComplete(uint16_t p_animIndex) +{ + const Animation::AnimSession* session = m_animSessionHost.FindSession(p_animIndex); + if (!session) { + return; + } + + const AnimInfo* animInfo = m_animCatalog.GetAnimInfo(p_animIndex); + if (!animInfo) { + return; + } + + AnimCompleteMsg msg{}; + msg.header = {MSG_ANIM_COMPLETE, m_localPeerId, m_sequence++, TARGET_BROADCAST}; + msg.eventId = (static_cast(SDL_rand_bits()) << 32) | static_cast(SDL_rand_bits()); + msg.objectId = animInfo->m_objectId; + msg.participantCount = 0; + + char localName[8]; + EncodeUsername(localName); + + for (const auto& slot : session->slots) { + if (slot.peerId == 0 || msg.participantCount >= 8) { + continue; + } + + AnimCompletionParticipant& p = msg.participants[msg.participantCount]; + p.peerId = slot.peerId; + + if (slot.IsSpectator()) { + // Resolve spectator's actual character from their display actor + if (slot.peerId == m_localPeerId) { + ThirdPersonCamera::Controller* cam = GetCamera(); + p.charIndex = cam ? Animation::Catalog::DisplayActorToCharacterIndex(cam->GetDisplayActorIndex()) : -1; + } + else { + auto it = m_remotePlayers.find(slot.peerId); + p.charIndex = it != m_remotePlayers.end() + ? Animation::Catalog::DisplayActorToCharacterIndex(it->second->GetDisplayActorIndex()) + : -1; + } + } + else { + p.charIndex = slot.charIndex; + } + + if (slot.peerId == m_localPeerId) { + SDL_memcpy(p.displayName, localName, sizeof(p.displayName)); + } + else { + auto it = m_remotePlayers.find(slot.peerId); + if (it != m_remotePlayers.end()) { + SDL_memcpy(p.displayName, it->second->GetDisplayName(), sizeof(p.displayName)); + } + else { + p.displayName[0] = '\0'; + } + } + + msg.participantCount++; + } + + SendMessage(msg); + + // Also handle locally on the host (message sent to TARGET_BROADCAST excludes sender) + HandleAnimComplete(msg); +} + +void NetworkManager::HandleAnimComplete(const AnimCompleteMsg& p_msg) +{ + // Only fire callback for actual participants, not observers + int localIdx = -1; + for (uint8_t i = 0; i < p_msg.participantCount; i++) { + if (p_msg.participants[i].peerId == m_localPeerId) { + localIdx = i; + break; + } + } + + if (localIdx < 0 || !m_callbacks) { + return; + } + + // Build JSON for frontend + char eventIdHex[17]; + SDL_snprintf( + eventIdHex, + sizeof(eventIdHex), + "%08x%08x", + static_cast(p_msg.eventId >> 32), + static_cast(p_msg.eventId & 0xFFFFFFFF) + ); + + std::string json = "{\"eventId\":\""; + json += eventIdHex; + json += "\",\"objectId\":"; + json += std::to_string(p_msg.objectId); + json += ",\"participants\":["; + + // Emit local player first so frontend can rely on participants[0] being self + bool first = true; + auto appendParticipant = [&](uint8_t i) { + if (!first) { + json += ','; + } + first = false; + const AnimCompletionParticipant& p = p_msg.participants[i]; + // Ensure null-termination safety for displayName (protocol uses fixed char[8]) + char name[8]; + SDL_memcpy(name, p.displayName, sizeof(name)); + name[7] = '\0'; + json += "{\"charIndex\":"; + json += std::to_string(static_cast(p.charIndex)); + json += ",\"displayName\":\""; + json += name; + json += "\"}"; + }; + + appendParticipant(static_cast(localIdx)); + for (uint8_t i = 0; i < p_msg.participantCount; i++) { + if (i != static_cast(localIdx)) { + appendParticipant(i); + } + } + + json += "]}"; + + m_callbacks->OnAnimationCompleted(json.c_str()); +} + int16_t NetworkManager::GetPeerLocation(uint32_t p_peerId) const { if (p_peerId == m_localPeerId) { diff --git a/extensions/src/multiplayer/platforms/emscripten/callbacks.cpp b/extensions/src/multiplayer/platforms/emscripten/callbacks.cpp index 6ef1d5f6..c6ffb182 100644 --- a/extensions/src/multiplayer/platforms/emscripten/callbacks.cpp +++ b/extensions/src/multiplayer/platforms/emscripten/callbacks.cpp @@ -78,6 +78,20 @@ void EmscriptenCallbacks::OnAnimationsAvailable(const char* p_json) // clang-format on } +void EmscriptenCallbacks::OnAnimationCompleted(const char* p_json) +{ + // clang-format off + MAIN_THREAD_EM_ASM({ + var canvas = Module.canvas; + if (canvas) { + canvas.dispatchEvent(new CustomEvent('animationCompleted', { + detail: { json: UTF8ToString($0) } + })); + } + }, p_json); + // clang-format on +} + } // namespace Multiplayer #endif // __EMSCRIPTEN__ diff --git a/extensions/src/multiplayer/platforms/native/nativecallbacks.cpp b/extensions/src/multiplayer/platforms/native/nativecallbacks.cpp index 6f834b46..c1e35627 100644 --- a/extensions/src/multiplayer/platforms/native/nativecallbacks.cpp +++ b/extensions/src/multiplayer/platforms/native/nativecallbacks.cpp @@ -57,6 +57,11 @@ void NativeCallbacks::OnAnimationsAvailable(const char* p_json) (void) p_json; } +void NativeCallbacks::OnAnimationCompleted(const char* p_json) +{ + SDL_Log("[Multiplayer] Animation completed: %s", p_json); +} + } // namespace Multiplayer #endif // !__EMSCRIPTEN__ diff --git a/extensions/src/multiplayer/server/gameroom.ts b/extensions/src/multiplayer/server/gameroom.ts index 1ef2e50d..c0f0d01f 100644 --- a/extensions/src/multiplayer/server/gameroom.ts +++ b/extensions/src/multiplayer/server/gameroom.ts @@ -23,7 +23,6 @@ export class GameRoom implements DurableObject { private nextPeerId = 1; private hostPeerId = 0; private maxPlayers = 5; - private maxActors = 0; constructor( private state: DurableObjectState, @@ -52,7 +51,7 @@ export class GameRoom implements DurableObject { server.accept(); this.connections.set(peerId, server); - server.send(createAssignIdMsg(peerId, this.maxActors)); + server.send(createAssignIdMsg(peerId)); this.assignHostIfNeeded(peerId, server); server.addEventListener("message", (event) => @@ -77,7 +76,6 @@ export class GameRoom implements DurableObject { try { const body = (await request.json()) as { maxPlayers?: number; - maxActors?: number; }; const ceiling = this.env.MAX_PLAYERS_CEILING ? Number(this.env.MAX_PLAYERS_CEILING) @@ -88,17 +86,11 @@ export class GameRoom implements DurableObject { Math.min(body.maxPlayers, ceiling) ); } - if (body.maxActors !== undefined) { - this.maxActors = Math.max( - 0, - Math.min(body.maxActors, 40) - ); - } } catch { // Ignore parse errors, keep defaults } return new Response( - JSON.stringify({ maxPlayers: this.maxPlayers, maxActors: this.maxActors }), + JSON.stringify({ maxPlayers: this.maxPlayers }), { headers: { "Content-Type": "application/json", @@ -113,7 +105,6 @@ export class GameRoom implements DurableObject { JSON.stringify({ players: this.connections.size, maxPlayers: this.maxPlayers, - maxActors: this.maxActors, }), { headers: { diff --git a/extensions/src/multiplayer/server/protocol.ts b/extensions/src/multiplayer/server/protocol.ts index ba87dcac..33c89638 100644 --- a/extensions/src/multiplayer/server/protocol.ts +++ b/extensions/src/multiplayer/server/protocol.ts @@ -16,18 +16,17 @@ export const MSG_LEAVE = 2; export const MSG_HOST_ASSIGN = 4; export const MSG_ASSIGN_ID = 0xff; -// AssignIdMsg: compact server-only message — type(1) + peerId(4) + maxActors(1) -const ASSIGN_ID_SIZE = 1 + 4 + 1; +// AssignIdMsg: compact server-only message — type(1) + peerId(4) +const ASSIGN_ID_SIZE = 1 + 4; // HostAssignMsg: header(13) + hostPeerId(4) const HOST_ASSIGN_SIZE = HEADER_SIZE + 4; -export function createAssignIdMsg(peerId: number, maxActors: number): ArrayBuffer { +export function createAssignIdMsg(peerId: number): ArrayBuffer { const buf = new ArrayBuffer(ASSIGN_ID_SIZE); const view = new DataView(buf); view.setUint8(0, MSG_ASSIGN_ID); view.setUint32(1, peerId, true); - view.setUint8(5, maxActors); return buf; } diff --git a/extensions/src/multiplayer/server/wrangler.toml b/extensions/src/multiplayer/server/wrangler.toml index 7ed1a726..f421e611 100644 --- a/extensions/src/multiplayer/server/wrangler.toml +++ b/extensions/src/multiplayer/server/wrangler.toml @@ -1,6 +1,8 @@ name = "isle-relay" main = "relay.ts" compatibility_date = "2024-01-01" +workers_dev = false +preview_urls = false [durable_objects] bindings = [ diff --git a/extensions/src/thirdpersoncamera/controller.cpp b/extensions/src/thirdpersoncamera/controller.cpp index ad44a164..1e715c95 100644 --- a/extensions/src/thirdpersoncamera/controller.cpp +++ b/extensions/src/thirdpersoncamera/controller.cpp @@ -87,6 +87,14 @@ void Controller::OnActorEnter(IslePathActor* p_actor) return; } + // Prevent the previous actor from wandering on the path system with stale + // spline state while the player is in a vehicle. Exit() will later call + // SetBoundary() without updating m_destEdge, so any non-user-nav animation + // with the old spline would use a mismatched boundary/edge pair. + if (p_actor->m_previousActor) { + p_actor->m_previousActor->SetWorldSpeed(0); + } + m_animator.SetCurrentVehicleType(DetectVehicleType(userActor)); if (!m_enabled || IsRestrictedArea(GameState()->m_currentArea)) { @@ -392,7 +400,7 @@ MxBool Controller::HandleCameraRelativeMovement( p_newDir, p_deltaTime, m_animator.IsInMultiPartEmote() || m_animPlaying, - m_input.IsLeftButtonHeld() + m_input.IsLmbHeldForMovement() ); } diff --git a/extensions/src/thirdpersoncamera/inputhandler.cpp b/extensions/src/thirdpersoncamera/inputhandler.cpp index 376c04bc..be179620 100644 --- a/extensions/src/thirdpersoncamera/inputhandler.cpp +++ b/extensions/src/thirdpersoncamera/inputhandler.cpp @@ -3,13 +3,14 @@ #include "extensions/thirdpersoncamera/orbitcamera.h" #include +#include #include using namespace Extensions::ThirdPersonCamera; InputHandler::InputHandler() : m_touch{}, m_wantsAutoDisable(false), m_wantsAutoEnable(false), m_rightButtonHeld(false), - m_leftButtonHeld(false), m_savedMouseX(0.0f), m_savedMouseY(0.0f) + m_leftButtonHeld(false), m_leftButtonDownTime(0), m_savedMouseX(0.0f), m_savedMouseY(0.0f) { } @@ -75,6 +76,12 @@ bool InputHandler::ConsumeAutoEnable() return std::exchange(m_wantsAutoEnable, false); } +bool InputHandler::IsLmbHeldForMovement() const +{ + return m_leftButtonHeld && m_leftButtonDownTime > 0 && + (SDL_GetTicks() - m_leftButtonDownTime) >= LMB_HOLD_THRESHOLD_MS; +} + void InputHandler::SuppressGestures() { m_touch.synced[0] = false; @@ -132,6 +139,7 @@ void InputHandler::HandleSDLEvent(SDL_Event* p_event, OrbitCamera& p_orbit, bool } else if (p_event->button.button == SDL_BUTTON_LEFT) { m_leftButtonHeld = p_event->button.down; + m_leftButtonDownTime = p_event->button.down ? SDL_GetTicks() : 0; } break; }