Nick bricks memories (#21)

* WIP: Add animation completion protocol message for Nick Brick's Memories

Add MSG_ANIM_COMPLETE (15) protocol message broadcast by the host on
natural animation completion. Contains a 64-bit random event ID, the
SI object ID, and per-participant data (charIndex, displayName).

- BroadcastAnimComplete: gathers participants from session slots,
  resolves spectator characters from display actors, generates event ID
- HandleAnimComplete: filters observers (only participants get callback),
  builds JSON with eventId/objectId/participants for frontend
- OnAnimationCompleted callback in PlatformCallbacks, implemented for
  Emscripten (CustomEvent dispatch) and Native (SDL_Log)
- GetDisplayName() getter on RemotePlayer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix participant ordering in animation completion message

Emit the local player first in the JSON participants array so the
frontend can rely on participants[0] being self when reporting to the
server. Extract participant JSON-building into a lambda to avoid
duplication. Retain null-termination safety for displayName.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Disable workers.dev and preview URLs for relay server

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix path actor assertion failure / freeze on repeated vehicle enter/exit

When the third-person camera is active, LMB triggers both forward movement
and vehicle interaction. This leaves the previous actor with m_worldSpeed > 0
when entering a vehicle, causing it to wander on the path system in non-user-nav
mode. On exit, SetBoundary() overwrites m_boundary without updating m_destEdge,
creating a boundary/edge mismatch. On the next vehicle enter, the stale spline
finishes and SwitchBoundary asserts (debug) or loops infinitely (release).

Stop the previous actor from wandering by zeroing its world speed on enter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add hold threshold to disambiguate LMB click from hold-to-walk

A 300ms time threshold prevents brief clicks (for interacting with
world objects) from also triggering forward movement in third-person
camera mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add missing SDL_timer.h include for SDL_GetTicks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Remove maxActors setting from multiplayer — NPCs are always disabled

The maxActors room setting added unnecessary complexity for a feature
that should always be off in multiplayer. Remove it from the relay
server protocol, room configuration API, C++ client, and simplify
NPC enforcement to be unconditional.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
foxtacles 2026-03-23 15:46:16 -07:00 committed by GitHub
parent 9a20bd42d1
commit 99c871ab16
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 228 additions and 43 deletions

View File

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

View File

@ -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<int32_t> m_pendingAnimInterest;
std::atomic<bool> m_pendingAnimCancel;
bool m_disableAllNPCs;
bool m_showNameBubbles;
bool m_lastCameraEnabled;
bool m_wasInRestrictedArea;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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();
}
// 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();
}
// Refresh animation catalog from the animation manager
if (AnimationManager()) {
@ -752,22 +747,14 @@ 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);
LegoAnimationManager::configureLegoAnimationManager(0);
if (AnimationManager()) {
AnimationManager()->m_maxAllowedExtras = maxActors;
AnimationManager()->m_numAllowedExtras =
SDL_min(AnimationManager()->m_numAllowedExtras, (MxU32) maxActors);
AnimationManager()->m_maxAllowedExtras = 0;
AnimationManager()->m_numAllowedExtras = 0;
}
m_disableAllNPCs = (maxActors == 0);
if (m_disableAllNPCs) {
EnforceDisableNPCs();
}
}
}
break;
}
case MSG_HOST_ASSIGN: {
@ -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<uint64_t>(SDL_rand_bits()) << 32) | static_cast<uint64_t>(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<uint32_t>(p_msg.eventId >> 32),
static_cast<uint32_t>(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<int>(p.charIndex));
json += ",\"displayName\":\"";
json += name;
json += "\"}";
};
appendParticipant(static_cast<uint8_t>(localIdx));
for (uint8_t i = 0; i < p_msg.participantCount; i++) {
if (i != static_cast<uint8_t>(localIdx)) {
appendParticipant(i);
}
}
json += "]}";
m_callbacks->OnAnimationCompleted(json.c_str());
}
int16_t NetworkManager::GetPeerLocation(uint32_t p_peerId) const
{
if (p_peerId == m_localPeerId) {

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,8 @@
name = "isle-relay"
main = "relay.ts"
compatibility_date = "2024-01-01"
workers_dev = false
preview_urls = false
[durable_objects]
bindings = [

View File

@ -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()
);
}

View File

@ -3,13 +3,14 @@
#include "extensions/thirdpersoncamera/orbitcamera.h"
#include <SDL3/SDL_stdinc.h>
#include <SDL3/SDL_timer.h>
#include <utility>
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;
}