mirror of
https://github.com/isledecomp/isle-portable.git
synced 2026-05-02 10:33:57 +00:00
Implement multiplayer world state sync for plants and buildings
Add serialization framework using C++ templates and table-driven lookup to sync plant and building state between players. Includes world snapshot routing to requesting peer, relay server Docker support, and fixes for building color sync, ride vehicle visibility, and ARM compilation.
This commit is contained in:
parent
21a12d480c
commit
12a63c105c
@ -99,8 +99,6 @@ class LegoBuildingManager : public MxCore {
|
||||
|
||||
private:
|
||||
static char* g_customizeAnimFile;
|
||||
static MxS32 g_maxMove[16];
|
||||
static MxU32 g_maxSound;
|
||||
|
||||
MxU8 m_nextVariant; // 0x08
|
||||
MxBool m_boundariesDetermined; // 0x09
|
||||
|
||||
@ -74,8 +74,6 @@ class LegoPlantManager : public MxCore {
|
||||
void AdjustCounter(LegoEntity* p_entity, MxS32 p_adjust);
|
||||
|
||||
static char* g_customizeAnimFile;
|
||||
static MxS32 g_maxMove[4];
|
||||
static MxU32 g_maxSound;
|
||||
|
||||
LegoOmni::World m_worldId; // 0x08
|
||||
MxBool m_boundariesDetermined; // 0x0c
|
||||
|
||||
@ -197,7 +197,7 @@ LegoBuildingInfo g_buildingInfoInit[16] = {
|
||||
// clang-format on
|
||||
|
||||
// GLOBAL: LEGO1 0x100f3738
|
||||
MxU32 LegoBuildingManager::g_maxSound = 6;
|
||||
MxU32 g_buildingMaxSound = 6;
|
||||
|
||||
// GLOBAL: LEGO1 0x100f373c
|
||||
MxU32 g_buildingSoundIdOffset = 0x3c;
|
||||
@ -226,7 +226,7 @@ MxS32 g_buildingManagerConfig = 1;
|
||||
LegoBuildingInfo g_buildingInfo[16];
|
||||
|
||||
// GLOBAL: LEGO1 0x100f3748
|
||||
MxS32 LegoBuildingManager::g_maxMove[16] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 3, 3, 3, 3, 3, 0};
|
||||
MxS32 g_buildingMaxMove[16] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 3, 3, 3, 3, 3, 0};
|
||||
|
||||
#define HAUS1_INDEX 12
|
||||
|
||||
@ -493,7 +493,7 @@ MxBool LegoBuildingManager::SwitchSound(LegoEntity* p_entity)
|
||||
if (info != NULL && info->m_flags & LegoBuildingInfo::c_hasSounds) {
|
||||
info->m_sound++;
|
||||
|
||||
if (info->m_sound >= g_maxSound) {
|
||||
if (info->m_sound >= g_buildingMaxSound) {
|
||||
info->m_sound = 0;
|
||||
}
|
||||
|
||||
@ -513,7 +513,7 @@ MxBool LegoBuildingManager::SwitchMove(LegoEntity* p_entity)
|
||||
if (info != NULL && info->m_flags & LegoBuildingInfo::c_hasMoves) {
|
||||
info->m_move++;
|
||||
|
||||
if (info->m_move >= g_maxMove[info - g_buildingInfo]) {
|
||||
if (info->m_move >= g_buildingMaxMove[info - g_buildingInfo]) {
|
||||
info->m_move = 0;
|
||||
}
|
||||
|
||||
|
||||
@ -37,7 +37,7 @@ float g_heightPerCount[] = {0.1f, 0.7f, 0.5f, 0.9f};
|
||||
MxU8 g_counters[] = {1, 2, 2, 3};
|
||||
|
||||
// GLOBAL: LEGO1 0x100f315c
|
||||
MxU32 LegoPlantManager::g_maxSound = 8;
|
||||
MxU32 g_plantMaxSound = 8;
|
||||
|
||||
// GLOBAL: LEGO1 0x100f3160
|
||||
MxU32 g_plantSoundIdOffset = 56;
|
||||
@ -46,7 +46,7 @@ MxU32 g_plantSoundIdOffset = 56;
|
||||
MxU32 g_plantSoundIdMoodOffset = 66;
|
||||
|
||||
// GLOBAL: LEGO1 0x100f3168
|
||||
MxS32 LegoPlantManager::g_maxMove[4] = {3, 3, 3, 3};
|
||||
MxS32 g_plantMaxMove[4] = {3, 3, 3, 3};
|
||||
|
||||
// GLOBAL: LEGO1 0x100f3178
|
||||
MxU32 g_plantAnimationId[4] = {30, 33, 36, 39};
|
||||
@ -433,8 +433,8 @@ MxBool LegoPlantManager::SwitchVariant(LegoEntity* p_entity)
|
||||
lodList->Release();
|
||||
CharacterManager()->UpdateBoundingSphereAndBox(roi);
|
||||
|
||||
if (info->m_move != 0 && info->m_move >= g_maxMove[info->m_variant]) {
|
||||
info->m_move = g_maxMove[info->m_variant] - 1;
|
||||
if (info->m_move != 0 && info->m_move >= g_plantMaxMove[info->m_variant]) {
|
||||
info->m_move = g_plantMaxMove[info->m_variant] - 1;
|
||||
}
|
||||
|
||||
return TRUE;
|
||||
@ -450,7 +450,7 @@ MxBool LegoPlantManager::SwitchSound(LegoEntity* p_entity)
|
||||
if (info != NULL) {
|
||||
info->m_sound++;
|
||||
|
||||
if (info->m_sound >= g_maxSound) {
|
||||
if (info->m_sound >= g_plantMaxSound) {
|
||||
info->m_sound = 0;
|
||||
}
|
||||
|
||||
@ -470,7 +470,7 @@ MxBool LegoPlantManager::SwitchMove(LegoEntity* p_entity)
|
||||
if (info != NULL) {
|
||||
info->m_move++;
|
||||
|
||||
if (info->m_move >= g_maxMove[info->m_variant]) {
|
||||
if (info->m_move >= g_plantMaxMove[info->m_variant]) {
|
||||
info->m_move = 0;
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
#include "3dmanager/lego3dmanager.h"
|
||||
#include "define.h"
|
||||
#include "extensions/multiplayer.h"
|
||||
#include "legoanimationmanager.h"
|
||||
#include "legobuildingmanager.h"
|
||||
#include "legocameracontroller.h"
|
||||
@ -483,6 +484,15 @@ MxLong LegoEntity::Notify(MxParam& p_param)
|
||||
InvokeAction(m_actionType, MxAtomId(m_siFile, e_lowerCase2), m_targetEntityId, this);
|
||||
}
|
||||
else {
|
||||
// Multiplayer extension intercept: for plants and buildings, route through
|
||||
// the multiplayer system. Returns TRUE if the click should be suppressed
|
||||
// locally (non-host sends a request to the host instead of applying directly).
|
||||
auto intercepted =
|
||||
Extensions::Extension<Extensions::MultiplayerExt>::Call(Extensions::HandleEntityNotify, this);
|
||||
if (intercepted.has_value() && intercepted.value()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
switch (GameState()->GetActorId()) {
|
||||
case LegoActor::c_pepper:
|
||||
if (GameState()->GetCurrentAct() != LegoGameState::e_act2 &&
|
||||
|
||||
12
docker/relay/Dockerfile
Normal file
12
docker/relay/Dockerfile
Normal file
@ -0,0 +1,12 @@
|
||||
FROM node:22-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY extensions/src/multiplayer/server/package.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY extensions/src/multiplayer/server/relay.ts extensions/src/multiplayer/server/wrangler.toml ./
|
||||
|
||||
EXPOSE 8787
|
||||
|
||||
CMD ["npx", "wrangler", "dev", "--ip", "0.0.0.0", "--port", "8787"]
|
||||
246
docs/multiplayer-world-state-assessment.md
Normal file
246
docs/multiplayer-world-state-assessment.md
Normal file
@ -0,0 +1,246 @@
|
||||
# Assessment: Sharing Overworld State in Multiplayer
|
||||
|
||||
## Context
|
||||
|
||||
The multiplayer extension currently syncs only player character positions/animations (~48 bytes at 15 Hz per peer). Each player sees others walking around, but the world itself is completely independent -- everyone has their own copy of plants, buildings, characters, etc. The question is: what would it take to share some of this world state, and is it worth the complexity?
|
||||
|
||||
## The Short Answer
|
||||
|
||||
Feasible, data is tiny (~3 KB total), but architecturally hard. The challenge isn't bandwidth -- it's introducing **authority** into a currently symmetric system, and **intercepting mutations** in code designed for single-player direct-mutation patterns. Tier 1 (plants + buildings) would be roughly **700-1,150 new lines** across client and server, taking an estimated **2-3x the effort of the original MVP**.
|
||||
|
||||
---
|
||||
|
||||
## What State Exists to Share
|
||||
|
||||
| Category | Count | Bytes (serialized) | Mutation Frequency | Visual Impact |
|
||||
|----------|-------|--------------------|--------------------|---------------|
|
||||
| Plants | 81 | 972 | Click-driven (low) | HIGH - variant, color, height visible |
|
||||
| Buildings | 16 | 161 | Click-driven (low) | MEDIUM - mood, animation |
|
||||
| Characters (NPCs) | 66 | 1,056 | Click-driven (low) | MEDIUM - hat, colors |
|
||||
| Vehicle colors | 43 | ~860 | Build-screen only | LOW |
|
||||
| Environment | 2 | ~60 | Rare | LOW |
|
||||
| **Total** | | **~3,100 bytes** | | |
|
||||
|
||||
The entire syncable world state fits in a single WebSocket frame. Bandwidth is a non-issue.
|
||||
|
||||
## The Core Problem: No Authority Model
|
||||
|
||||
The current system is fully symmetric -- all peers are equal, the relay server (`extensions/src/multiplayer/server/relay.ts`) just stamps peer IDs and broadcasts. For shared world state, exactly one peer must be the "source of truth."
|
||||
|
||||
### Recommended: Server-Assigned Host
|
||||
|
||||
The relay server already assigns peer IDs incrementally. Adding host tracking is ~40-60 lines of TypeScript:
|
||||
|
||||
- First peer to connect becomes host; server sends `MSG_HOST_ASSIGN`
|
||||
- On host disconnect, lowest-ID remaining peer becomes host; server broadcasts new `MSG_HOST_ASSIGN`
|
||||
- New peers receive `MSG_HOST_ASSIGN` on connect so they know who the host is
|
||||
|
||||
This is clearly better than client-side election (no race conditions, no timeout-based detection). The server is the one entity that knows the full connection set.
|
||||
|
||||
### Host Migration Risk
|
||||
|
||||
When the host disconnects, the new host has its own local copy of the world (which was being kept in sync). It immediately broadcasts a full snapshot to converge everyone. The data-loss window is tiny -- at most one unbroadcast click. For plants/buildings this is imperceptible.
|
||||
|
||||
## The Second Core Problem: Intercepting Mutations
|
||||
|
||||
This is where the real complexity lives. State mutations happen via direct function calls with no event system:
|
||||
|
||||
```
|
||||
Player clicks plant -> LegoEntity::Notify() -> SwitchVariant()
|
||||
-> PlantManager()->SwitchVariant(this) // directly mutates g_plantInfo[81]
|
||||
```
|
||||
|
||||
The `Switch*` methods in `legoplantmanager.cpp`, `legobuildingmanager.cpp`, and `legocharactermanager.cpp` modify global arrays in-place. There is no observer pattern, no event bus, nothing to hook into.
|
||||
|
||||
### Recommended: Single Extension Hook in LegoEntity::Notify()
|
||||
|
||||
Add one `Extensions::Call()` invocation in `LegoEntity::Notify()` (in `legoentity.cpp`), following the established pattern already used in `LegoWorld::Enable()`. This is the natural chokepoint -- all click-driven mutations flow through here.
|
||||
|
||||
The extension hook would:
|
||||
- **If host**: allow mutation locally, then broadcast a `MSG_WORLD_EVENT` (12 bytes: entity type, index, change type, actor ID)
|
||||
- **If non-host**: block the local mutation, send a `MSG_WORLD_EVENT_REQUEST` (same 12-byte format as `MSG_WORLD_EVENT`) to the host via the relay, wait for the host's broadcast to apply it
|
||||
|
||||
Non-host click latency would be one relay round trip (20-100ms depending on region) -- imperceptible for clicking on plants.
|
||||
|
||||
**Important edge case: host outside the Isle world.** The host may not be in the Isle world when a non-host's `MSG_WORLD_EVENT_REQUEST` arrives (e.g., the host is in a building interior). The host's `m_entity` pointers are also NULL in this case, so the host cannot call `Switch*` methods either. The host must apply the mutation to its data arrays directly (same increment/cycle logic as the `Switch*` methods, without the ROI visual update), then broadcast the change-type event. This works because the data arrays (`g_plantInfo[]`, `g_buildingInfo[]`) persist across world unloads.
|
||||
|
||||
**Why change-type events, not value-based events:** The `Switch*` methods are increment/cycle operations (e.g., `SwitchColor` does `color = (color + 1) % 5`), and they combine the data mutation with the visual update (ROI/LOD swap) in a single call. If `MSG_WORLD_EVENT` carried a resulting value like "color = RED", receivers couldn't easily use the existing `Switch*` methods -- they'd need separate visual-update-only helpers that don't exist today.
|
||||
|
||||
Instead, `MSG_WORLD_EVENT` carries a **change type** (e.g., "cycle color on plant 42"). Since WebSocket/TCP guarantees in-order delivery and all peers start from the same snapshot, applying the same sequence of change-type events keeps everyone in sync:
|
||||
- **In-world peers**: call the same `Switch*` method, which increments the data AND updates the ROI
|
||||
- **Out-of-world peers**: apply the same increment logic to the data arrays directly (no visual update needed; `LoadWorldInfo()` rebuilds visuals from the data when re-entering the Isle world)
|
||||
- **Host outside the Isle world**: same as out-of-world peers -- apply data increment, then broadcast the event
|
||||
|
||||
## Synchronization: Hybrid Approach
|
||||
|
||||
**On join**: New peer requests a snapshot from the host. Host serializes using the *existing* `PlantManager::Write()` / `BuildingManager::Write()` / `CharacterManager::Write()` methods via the existing `LegoMemory` class (a `LegoStorage` subclass at `legostorage.h:194` that reads/writes to a `uint8_t*` buffer -- no new adapter class needed). New peer deserializes using the existing `Read()` methods. This reuses all existing serialization code.
|
||||
|
||||
**During play**: Individual mutations broadcast as 12-byte `MSG_WORLD_EVENT` messages. Receiving peers that are currently in the Isle world call the same `Switch*` methods locally to get both data mutation and visual update in one step. Peers that are NOT in the Isle world (in Act 2, a building, etc.) instead write the changed field directly to the `g_plantInfo[]` / `g_buildingInfo[]` arrays -- the visual update happens automatically when they re-enter the Isle world and `LoadWorldInfo()` rebuilds entities from the updated data.
|
||||
|
||||
**Late joiner edge case**: Queue any `MSG_WORLD_EVENT` messages that arrive between snapshot request and snapshot response, apply them after the snapshot.
|
||||
|
||||
## Protocol Changes
|
||||
|
||||
```
|
||||
New message types needed:
|
||||
MSG_HOST_ASSIGN (13 bytes) - server -> all, announces host peer ID
|
||||
MSG_REQUEST_SNAPSHOT (9 bytes) - client -> host, request full world state
|
||||
MSG_WORLD_SNAPSHOT (~3.1 KB) - host -> client, full world state blob
|
||||
MSG_WORLD_EVENT (12 bytes) - host -> all, single state mutation (change type, not value)
|
||||
MSG_WORLD_EVENT_REQUEST (12 bytes) - non-host -> host, request a mutation (same format)
|
||||
```
|
||||
|
||||
Steady-state bandwidth addition: essentially zero (events are click-driven, ~0.1 Hz). Snapshot is a one-time 3 KB message on join.
|
||||
|
||||
## Tiered Implementation
|
||||
|
||||
### Tier 1: Plants + Buildings (recommended starting point)
|
||||
- **Value**: HIGH -- most visible shared elements. Two players clicking the same flower and seeing each other's changes is the core "shared world" experience.
|
||||
- **Effort**: ~700-1,150 new lines of C++ and TypeScript
|
||||
- **Scope**: Host election, snapshot sync, event-based deltas, mutation interception hook, visual state application on receiving peers
|
||||
- **Estimated time**: 2-3 weeks for someone familiar with the codebase
|
||||
|
||||
### Tier 2: NPC Character Customization
|
||||
- **Value**: MEDIUM -- less frequent but visible
|
||||
- **Effort**: ~150-200 additional lines
|
||||
- **Risk**: Character customization involves LOD cloning and texture swapping (`legocharactermanager.cpp:807-858`). Remote triggering could have subtle visual bugs.
|
||||
|
||||
### Tier 3: Vehicle Colors + Environment
|
||||
- **Value**: LOW -- vehicle colors are rarely changed during gameplay, environment changes are cosmetic
|
||||
- **Effort**: ~100-150 additional lines
|
||||
|
||||
### Tier 4: Vehicle World Presence (placement, enter/exit, visibility)
|
||||
- **Value**: HIGH -- without this, each player sees all 7 vehicles sitting idle in the world even when another player is driving one. When Player A enters the helicopter, Player B should see it disappear (it's "in use"), and when Player A exits, it should reappear at the exit location for everyone.
|
||||
- **Effort**: ~300-500 additional lines (MEDIUM-HIGH complexity)
|
||||
- **Scope**:
|
||||
- Sync vehicle enter/exit events so vehicles disappear/reappear for all peers
|
||||
- Sync vehicle positions on exit (where the vehicle is "parked")
|
||||
- Sync custom textures for built vehicles (helicopter windshield, jetski front, etc.)
|
||||
|
||||
**How vehicles work:**
|
||||
|
||||
There are 7 vehicles in the Isle world: motorcycle, bike, skateboard, helicopter, jetski, dunebuggy, racecar. Their world state is stored in `Act1State` as `LegoNamedPlane` structs (76 bytes each: name + position + direction + up vector). Built vehicles (helicopter, jetski, dunebuggy, racecar) also have custom textures (`LegoNamedTexture*`).
|
||||
|
||||
**Vehicle enter/exit lifecycle:**
|
||||
1. **Idle**: Vehicle is visible in the world via `m_roi->SetVisibility(TRUE)`
|
||||
2. **Enter** (`IslePathActor::Enter()`): `m_roi->SetVisibility(FALSE)` -- vehicle disappears, player takes control
|
||||
3. **Active**: Vehicle is invisible; dashboard UI is shown; vehicle is the `UserActor`
|
||||
4. **Exit** (`IslePathActor::Exit()`): `m_roi->SetVisibility(TRUE)` -- vehicle reappears at exit position
|
||||
|
||||
The existing multiplayer MVP already syncs `vehicleType` in `PlayerStateMsg` (the remote player rendering knows which vehicle model to show). What's missing is the **world-side effect**: hiding/showing the idle vehicle entity when any player enters/exits it.
|
||||
|
||||
**Sync approach:**
|
||||
- `MSG_WORLD_EVENT` with a new `EVENT_VEHICLE_ENTER` / `EVENT_VEHICLE_EXIT` type
|
||||
- On enter: host broadcasts which vehicle was entered; all peers call `SetVisibility(FALSE)` on that vehicle's ROI
|
||||
- On exit: host broadcasts the vehicle exit + new position (the `LegoNamedPlane` data -- boundary name, position, direction, up); all peers call `SetVisibility(TRUE)` and update the vehicle's transform
|
||||
- On snapshot: include which vehicles are currently "in use" (a bitmask, 1 byte) plus the saved `LegoNamedPlane` for each parked vehicle
|
||||
|
||||
**Key complexity**: Built vehicles have custom textures that are serialized as bitmap data (variable size, potentially several KB each). For Tier 4, syncing textures on join would require including them in the snapshot. An alternative is to skip texture sync initially and use default textures for remote players' built vehicles -- visually imperfect but functional.
|
||||
|
||||
**Out-of-world handling**: Vehicle state is managed by `Act1State`, which persists across world transitions. `RemoveActors()` calls `UpdatePlane()` to save positions when leaving the Isle; `PlaceActors()` restores them on re-entry. The same direct-write approach from Tier 1 applies: update the `LegoNamedPlane` data in `Act1State` when the receiving peer is outside the Isle world.
|
||||
|
||||
**Data size:**
|
||||
| Component | Size |
|
||||
|-----------|------|
|
||||
| 7 vehicle planes | ~532 bytes (7 x 76) |
|
||||
| Vehicle-in-use bitmask | 1 byte |
|
||||
| Custom textures (optional) | 0-50+ KB (bitmap data) |
|
||||
| 43 vehicle color strings | ~500-1,000 bytes |
|
||||
|
||||
## Estimated Effort Summary
|
||||
|
||||
| Component | New Lines | Difficulty |
|
||||
|-----------|-----------|------------|
|
||||
| relay.ts host tracking | 40-60 | Easy |
|
||||
| protocol.h new messages | 100-150 | Easy |
|
||||
| ~~MemoryStorage adapter~~ | 0 | `LegoMemory` already exists in `legostorage.h` |
|
||||
| NetworkManager world sync | 200-300 | Medium |
|
||||
| Mutation hooks + routing | 200-400 | **Hard** |
|
||||
| Visual state application | 100-150 | Medium |
|
||||
| Out-of-world state handling | 50-100 | Medium (NULL entity branching, direct array writes) |
|
||||
| Extension hook in LegoEntity | 5-10 | Easy (but architecturally important) |
|
||||
| **Total (Tier 1)** | **~750-1,250** | |
|
||||
| **Total (Tiers 1-3)** | **~1,000-1,600** | |
|
||||
| Tier 4: Vehicle enter/exit/visibility | 300-500 | Medium-Hard (Act1State integration, texture sync) |
|
||||
| **Total (Tiers 1-4)** | **~1,300-2,100** | |
|
||||
|
||||
For comparison, the current MVP is ~1,620 lines across 8 files. Tier 1 is roughly 60-75% as much code but substantially more complex due to the authority model, two-way communication patterns, out-of-world state handling, and decompiled code hooks.
|
||||
|
||||
## Open Questions and Risks
|
||||
|
||||
1. **Sound on remote mutations**: `ClickSound()` uses 3D positional audio via `Lego3DSound`. Sounds are anchored to the entity's world position and naturally attenuate with distance (min 15, max 100 units; effectively silent beyond ~100 units). This means **sounds should be played on remote peers** -- a player clicking a plant far away from you will be quiet or silent. The 3D sound system handles this correctly without any special networking logic. The `ClickAnimation()` visual effect should also play for the same reason.
|
||||
|
||||
2. **Conflict resolution**: Two players click the same plant simultaneously. With host authority, the host processes them sequentially -- the second click advances from the state the first click left it in. Both players see the same result. No special handling needed.
|
||||
|
||||
3. **State sync for players not in the Isle world** (IMPORTANT): When a player leaves the Isle world (enters Act 2, a building interior, etc.), `PlantManager()->Reset()` and `BuildingManager()->Reset()` set all `m_entity` pointers to NULL. Several `Switch*` methods access `m_entity->GetROI()` without NULL checks and will **crash** if called when the entity doesn't exist:
|
||||
- **CRASHES outside Isle**: `SwitchColor()`, `SwitchVariant()`, `DecrementCounter()` (both plant and building) -- access `m_entity->GetROI()` which is NULL
|
||||
- **NO-OPS outside Isle**: `SwitchSound()`, `SwitchMove()`, `SwitchMood()` -- these use `GetInfo(p_entity)` which searches `g_plantInfo[].m_entity` for a matching pointer. When entities are NULL, the search finds nothing and returns NULL, so the method does nothing. They don't crash, but they also **don't apply the data change**.
|
||||
|
||||
**Solution**: When receiving state updates from other players while not in the Isle world, **ALL methods require direct array writes** -- not just the crash-prone ones. The data fields (variant, color, mood, sound, move, counter) persist across world unloads. When the player re-enters the Isle world, `LoadWorldInfo()` → `CreatePlant()` / `CreateBuilding()` reads these fields to create entities with the correct appearance. The receiving code must check `info->m_entity != NULL` and branch accordingly: if non-NULL, call the `LegoEntity::Switch*` method (which handles data + visual + sound + animation); if NULL, write the field directly to the data array by index.
|
||||
|
||||
4. **Testing**: Requires testing late-joiner sync, host migration, simultaneous clicks, snapshot + queued events ordering, and state application when players are in different worlds. The existing `relay.ts` dev server (via `wrangler dev`) helps, but automated multi-client testing would need a new test harness.
|
||||
|
||||
5. **World reload**: When a player leaves and re-enters the Isle world, `PlantManager::LoadWorldInfo()` re-creates plants from `g_plantInfo[]`. As noted in point 3, if the data arrays have been kept in sync via direct writes, the re-created entities will have the correct appearance. This is confirmed to work because `CreatePlant()` reads `g_plantInfo[index].m_variant` and `g_plantInfo[index].m_color` to select the correct LOD list.
|
||||
|
||||
## Verified Findings & Additional Gotchas (code review)
|
||||
|
||||
The following were discovered by cross-checking every claim against the actual source code:
|
||||
|
||||
6. **`LegoMemory` already exists** (`legostorage.h:194`): The plan proposed creating a "MemoryStorage adapter" but `LegoMemory` is already a `LegoStorage` subclass that reads/writes to a `uint8_t*` buffer with position tracking. This eliminates ~50-80 lines of estimated work.
|
||||
|
||||
7. **`BuildingManager::SwitchVariant()` is disabled by default**: `g_buildingManagerConfig` is initialized to 1 (`legobuildingmanager.cpp:222`), and `SwitchVariant()` returns TRUE immediately when `g_buildingManagerConfig <= 1`. Building variant switching only works if this config is explicitly set to > 1 (via a registry/config setting from the original game). This means building variant sync may be a no-op in practice.
|
||||
|
||||
8. **`BuildingManager::SwitchVariant()` creates a new entity**: Unlike plant mutations (which swap LOD lists), `SwitchVariant()` at line 474 calls `CreateBuilding(HAUS1_INDEX, CurrentWorld())`, which requires the Isle world to be loaded. This mutation **cannot be applied via direct array writes** for out-of-world peers -- you can write `m_variant` to the data array, but the visual swap requires `CreateBuilding()`. For out-of-world peers, the data write is sufficient; `LoadWorldInfo()` will create the correct variant on re-entry.
|
||||
|
||||
9. **`ClickAnimation()` blocks rapid clicks**: `ClickAnimation()` sets `m_interaction |= c_disabled` (`legoentity.cpp:340`), preventing further clicks until the animation finishes. Both `ClickSound()` and `ClickAnimation()` check `!IsInteraction(c_disabled)` and skip if set. If a remote world event arrives while an entity is already animating, the data mutation (via the manager's `Switch*` method) still applies correctly, but the sound and animation for that remote event will be silently skipped. This is consistent with single-player behavior but means rapid multiplayer clicks may have fewer visual/audio effects than expected.
|
||||
|
||||
10. **Every mutation decrements the interaction counter**: `ClickAnimation()` triggers `ScheduleAnimation()`, which calls `AdjustCounter(-1)` for plants and `AdjustCounter(-2)` for buildings. This means every click-driven mutation decrements the entity's counter as a side effect through the animation system. When the counter reaches 0, the entity sinks and disappears. This is already correctly captured when calling `LegoEntity::SwitchVariant()` etc. (which call `ClickAnimation()` internally), but it means **the receiving side must call the full `LegoEntity::Switch*` method (not just the manager method)** to get the counter decrement and animation.
|
||||
|
||||
11. **Plant `Write()` / `Read()` asymmetry**: `Write()` saves `m_initialCounter` but `Read()` reads into `m_counter` (then copies to `m_initialCounter` and calls `AdjustHeight()`). The snapshot sender must be aware that what gets written is `initialCounter`, not the current `counter`. This matters if a plant has been partially decremented -- the snapshot should send the current state, not the initial state. The snapshot serialization should either use the existing `Write()`/`Read()` pair as-is (which saves/restores `initialCounter` and adjusts heights accordingly), or use a custom serialization that captures `m_counter` directly.
|
||||
|
||||
12. **Snapshot `Read()` doesn't update visual positions**: `PlantManager::Read()` calls `AdjustHeight()` which modifies `g_plantInfo[].m_position[1]` (Y coordinate) but does NOT call `SetLocation()` on the entity. If a snapshot is received while in the Isle world, the entity's visual position won't update until the player leaves and re-enters. An additional `SetLocation()` call per entity is needed after applying a snapshot in-world. Same issue applies to buildings via `AdjustHeight()` in `BuildingManager::Read()`.
|
||||
|
||||
13. **PlayerStateMsg is 52 bytes, not 48**: Header(9) + actorId(1) + worldId(1) + vehicleType(1) + position(12) + direction(12) + up(12) + speed(4) = 52 bytes. The bandwidth estimate changes negligibly (from 720 to 780 bytes/s per peer at 15 Hz).
|
||||
|
||||
## Impact Assessment of Verified Findings
|
||||
|
||||
None of the verified findings above are architectural blockers. Summary:
|
||||
|
||||
- **#6 (LegoMemory exists)**: Positive — reduces work.
|
||||
- **#7 (SwitchVariant disabled)**: Neutral — less to sync; already handled if config changes.
|
||||
- **#8 (CreateBuilding for SwitchVariant)**: Already handled — out-of-world peers use direct array writes; `LoadWorldInfo()` creates the correct variant on re-entry.
|
||||
- **#9 (ClickAnimation blocks rapid clicks)**: Matches single-player behavior — data still applies, only sound/animation skipped. Prevents audio spam.
|
||||
- **#10 (Counter decrement side effect)**: Most important finding. Receiving peers **must call `LegoEntity::Switch*()` methods** (not `PlantManager::Switch*()` directly) to get the full effect chain (data + visual + sound + animation + counter decrement). For out-of-world peers, direct array writes must also decrement `m_counter` and call `AdjustHeight()`.
|
||||
- **#11 (Write/Read asymmetry)**: Manageable — use existing `Write()`/`Read()` pair as-is for snapshots. The sequence "snapshot (initial state) + queued change-type events" produces the correct final state.
|
||||
- **#12 (Snapshot Read doesn't update visuals)**: Minor — add a `SetLocation()` loop (~10 lines) after applying a snapshot in-world.
|
||||
- **#13 (52 bytes not 48)**: Cosmetic correction, no design impact.
|
||||
|
||||
## Opinion
|
||||
|
||||
The host/leader election concern you raised is valid -- it IS the main source of complexity. But in this case, the server-assigned approach keeps it manageable because:
|
||||
|
||||
1. The relay server already has the connection lifecycle (it knows who joined/left)
|
||||
2. Adding host tracking to it is small (~50 lines)
|
||||
3. Host migration is low-risk because the world state is small and changes are infrequent
|
||||
|
||||
The harder part is the mutation interception -- surgically hooking into decompiled game code. But the extension pattern already established a precedent for this (the `LegoWorld::Enable()` hook), so one more hook in `LegoEntity::Notify()` is consistent.
|
||||
|
||||
**Bottom line**: Tier 1 (plants + buildings) is a meaningful but manageable extension. It's 2-3x the complexity of the original MVP, not 10x. The architecture is clean (host authority, hybrid sync, single hook), the data volumes are trivial, and the existing serialization code can be reused wholesale. The main risk is the mutation interception in decompiled code, which requires careful testing but is architecturally sound.
|
||||
|
||||
I would **recommend implementing Tier 1** as a natural next step. Tiers 2-3 are incremental. Tier 4 (vehicle world presence) is independently valuable and could be tackled in parallel with or after Tier 1 -- it addresses one of the most visible multiplayer artifacts (seeing idle vehicles that another player is actually driving).
|
||||
|
||||
## Key Files
|
||||
|
||||
- `extensions/src/multiplayer/server/relay.ts` - host tracking additions
|
||||
- `extensions/include/extensions/multiplayer/protocol.h` - new message types
|
||||
- `extensions/src/multiplayer/networkmanager.cpp` - world sync logic
|
||||
- `extensions/include/extensions/multiplayer/networkmanager.h` - new fields/methods
|
||||
- `LEGO1/lego/legoomni/src/entity/legoentity.cpp` - extension hook in Notify()
|
||||
- `LEGO1/lego/legoomni/src/common/legoplantmanager.cpp` - existing Write/Read to reuse
|
||||
- `LEGO1/lego/legoomni/src/common/legobuildingmanager.cpp` - existing Write/Read to reuse
|
||||
- `LEGO1/lego/legoomni/include/legoplants.h` - LegoPlantInfo struct definition
|
||||
- `LEGO1/lego/legoomni/include/legobuildingmanager.h` - LegoBuildingInfo struct definition
|
||||
- `LEGO1/lego/legoomni/include/isle.h` - Act1State with vehicle LegoNamedPlane fields
|
||||
- `LEGO1/lego/legoomni/src/worlds/isle.cpp` - Act1State::Serialize(), RemoveActors(), PlaceActors()
|
||||
- `LEGO1/lego/legoomni/src/actors/islepathactor.cpp` - Enter()/Exit() with SetVisibility toggle
|
||||
- `LEGO1/lego/legoomni/include/legonamedplane.h` - LegoNamedPlane struct (position/direction/up)
|
||||
@ -6,6 +6,7 @@
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
class LegoEntity;
|
||||
class LegoWorld;
|
||||
|
||||
namespace Multiplayer
|
||||
@ -22,6 +23,10 @@ class MultiplayerExt {
|
||||
static void Initialize();
|
||||
static MxBool HandleWorldEnable(LegoWorld* p_world, MxBool p_enable);
|
||||
|
||||
// Intercepts click notifications on plants/buildings for multiplayer routing.
|
||||
// Returns TRUE if the click should be suppressed locally (non-host).
|
||||
static MxBool HandleEntityNotify(LegoEntity* p_entity);
|
||||
|
||||
static std::map<std::string, std::string> options;
|
||||
static bool enabled;
|
||||
|
||||
@ -37,8 +42,10 @@ class MultiplayerExt {
|
||||
|
||||
#ifdef EXTENSIONS
|
||||
constexpr auto HandleWorldEnable = &MultiplayerExt::HandleWorldEnable;
|
||||
constexpr auto HandleEntityNotify = &MultiplayerExt::HandleEntityNotify;
|
||||
#else
|
||||
constexpr decltype(&MultiplayerExt::HandleWorldEnable) HandleWorldEnable = nullptr;
|
||||
constexpr decltype(&MultiplayerExt::HandleEntityNotify) HandleEntityNotify = nullptr;
|
||||
#endif
|
||||
|
||||
}; // namespace Extensions
|
||||
|
||||
@ -10,7 +10,9 @@
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
class LegoEntity;
|
||||
class LegoWorld;
|
||||
|
||||
namespace Multiplayer
|
||||
@ -40,6 +42,12 @@ class NetworkManager : public MxCore {
|
||||
void OnWorldEnabled(LegoWorld* p_world);
|
||||
void OnWorldDisabled(LegoWorld* p_world);
|
||||
|
||||
// Called from multiplayer extension when a plant/building entity is clicked.
|
||||
// Returns TRUE if the mutation should be suppressed locally (non-host).
|
||||
MxBool HandleEntityMutation(LegoEntity* p_entity, MxU8 p_changeType);
|
||||
|
||||
bool IsHost() const { return m_localPeerId != 0 && m_localPeerId == m_hostPeerId; }
|
||||
|
||||
private:
|
||||
void BroadcastLocalState();
|
||||
void ProcessIncomingPackets();
|
||||
@ -50,6 +58,11 @@ class NetworkManager : public MxCore {
|
||||
void HandleJoin(const PlayerJoinMsg& p_msg);
|
||||
void HandleLeave(const PlayerLeaveMsg& p_msg);
|
||||
void HandleState(const PlayerStateMsg& p_msg);
|
||||
void HandleHostAssign(const HostAssignMsg& p_msg);
|
||||
void HandleRequestSnapshot(const RequestSnapshotMsg& p_msg);
|
||||
void HandleWorldSnapshot(const uint8_t* p_data, size_t p_length);
|
||||
void HandleWorldEvent(const WorldEventMsg& p_msg);
|
||||
void HandleWorldEventRequest(const WorldEventRequestMsg& p_msg);
|
||||
|
||||
void RemoveRemotePlayer(uint32_t p_peerId);
|
||||
void RemoveAllRemotePlayers();
|
||||
@ -57,15 +70,33 @@ class NetworkManager : public MxCore {
|
||||
int8_t DetectLocalVehicleType();
|
||||
bool IsInIsleWorld() const;
|
||||
|
||||
// Serialize and send a fixed-size message via the transport
|
||||
template <typename T>
|
||||
void SendMessage(const T& p_msg);
|
||||
|
||||
// World state sync helpers
|
||||
void SendSnapshotRequest();
|
||||
void SendWorldSnapshot(uint32_t p_targetPeerId);
|
||||
void BroadcastWorldEvent(uint8_t p_entityType, uint8_t p_changeType, uint8_t p_entityIndex);
|
||||
void SendWorldEventRequest(uint8_t p_entityType, uint8_t p_changeType, uint8_t p_entityIndex);
|
||||
|
||||
// Apply a world event mutation locally (for both host and receiving peers)
|
||||
void ApplyWorldEvent(uint8_t p_entityType, uint8_t p_changeType, uint8_t p_entityIndex);
|
||||
|
||||
NetworkTransport* m_transport;
|
||||
std::map<uint32_t, std::unique_ptr<RemotePlayer>> m_remotePlayers;
|
||||
|
||||
uint32_t m_localPeerId;
|
||||
uint32_t m_hostPeerId;
|
||||
uint32_t m_sequence;
|
||||
uint32_t m_lastBroadcastTime;
|
||||
uint8_t m_lastValidActorId;
|
||||
bool m_inIsleWorld;
|
||||
bool m_registered;
|
||||
bool m_snapshotRequested;
|
||||
|
||||
// Queue world events that arrive between snapshot request and response
|
||||
std::vector<WorldEventMsg> m_pendingWorldEvents;
|
||||
|
||||
static const uint32_t BROADCAST_INTERVAL_MS = 66; // ~15Hz
|
||||
static const uint32_t TIMEOUT_MS = 5000; // 5 second timeout
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
#include <SDL3/SDL_stdinc.h>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <type_traits>
|
||||
|
||||
namespace Multiplayer
|
||||
{
|
||||
@ -11,6 +12,11 @@ enum MessageType : uint8_t {
|
||||
MSG_JOIN = 1,
|
||||
MSG_LEAVE = 2,
|
||||
MSG_STATE = 3,
|
||||
MSG_HOST_ASSIGN = 4,
|
||||
MSG_REQUEST_SNAPSHOT = 5,
|
||||
MSG_WORLD_SNAPSHOT = 6,
|
||||
MSG_WORLD_EVENT = 7,
|
||||
MSG_WORLD_EVENT_REQUEST = 8,
|
||||
MSG_ASSIGN_ID = 0xFF
|
||||
};
|
||||
|
||||
@ -27,6 +33,22 @@ enum VehicleType : int8_t {
|
||||
VEHICLE_COUNT = 8
|
||||
};
|
||||
|
||||
// Entity types for world events
|
||||
enum WorldEntityType : uint8_t {
|
||||
ENTITY_PLANT = 0,
|
||||
ENTITY_BUILDING = 1
|
||||
};
|
||||
|
||||
// Change types for world events (maps to Switch* methods on LegoEntity)
|
||||
enum WorldChangeType : uint8_t {
|
||||
CHANGE_VARIANT = 0,
|
||||
CHANGE_SOUND = 1,
|
||||
CHANGE_MOVE = 2,
|
||||
CHANGE_COLOR = 3,
|
||||
CHANGE_MOOD = 4,
|
||||
CHANGE_DECREMENT = 5
|
||||
};
|
||||
|
||||
#pragma pack(push, 1)
|
||||
|
||||
struct MessageHeader {
|
||||
@ -56,12 +78,45 @@ struct PlayerStateMsg {
|
||||
float speed;
|
||||
};
|
||||
|
||||
#pragma pack(pop)
|
||||
// Server -> all: announces which peer is the host
|
||||
struct HostAssignMsg {
|
||||
MessageHeader header;
|
||||
uint32_t hostPeerId;
|
||||
};
|
||||
|
||||
static const size_t STATE_MSG_SIZE = sizeof(PlayerStateMsg);
|
||||
static const size_t JOIN_MSG_SIZE = sizeof(PlayerJoinMsg);
|
||||
static const size_t LEAVE_MSG_SIZE = sizeof(PlayerLeaveMsg);
|
||||
static const size_t HEADER_SIZE = sizeof(MessageHeader);
|
||||
// Client -> host: request full world state snapshot
|
||||
struct RequestSnapshotMsg {
|
||||
MessageHeader header;
|
||||
};
|
||||
|
||||
// Host -> specific client: full world state blob (variable length)
|
||||
// Relay reads targetPeerId at offset 9 and routes to that peer only.
|
||||
struct WorldSnapshotMsg {
|
||||
MessageHeader header;
|
||||
uint32_t targetPeerId;
|
||||
uint16_t dataLength;
|
||||
// Followed by dataLength bytes of serialized plant + building state
|
||||
};
|
||||
|
||||
// Host -> all: single world state mutation
|
||||
struct WorldEventMsg {
|
||||
MessageHeader header;
|
||||
uint8_t entityType; // WorldEntityType
|
||||
uint8_t changeType; // WorldChangeType
|
||||
uint8_t entityIndex; // Index into g_plantInfo[] or g_buildingInfo[]
|
||||
uint8_t padding; // Alignment
|
||||
};
|
||||
|
||||
// Non-host -> host: request a mutation (same layout as WorldEventMsg)
|
||||
struct WorldEventRequestMsg {
|
||||
MessageHeader header;
|
||||
uint8_t entityType; // WorldEntityType
|
||||
uint8_t changeType; // WorldChangeType
|
||||
uint8_t entityIndex; // Index into g_plantInfo[] or g_buildingInfo[]
|
||||
uint8_t padding; // Alignment
|
||||
};
|
||||
|
||||
#pragma pack(pop)
|
||||
|
||||
// Validate actorId is a playable character (1-5, not brickster)
|
||||
inline bool IsValidActorId(uint8_t p_actorId)
|
||||
@ -69,85 +124,6 @@ inline bool IsValidActorId(uint8_t p_actorId)
|
||||
return p_actorId >= 1 && p_actorId <= 5;
|
||||
}
|
||||
|
||||
// Serialize a STATE message into a buffer. Returns bytes written.
|
||||
inline size_t SerializeStateMsg(
|
||||
uint8_t* p_buf,
|
||||
size_t p_bufLen,
|
||||
uint32_t p_peerId,
|
||||
uint32_t p_sequence,
|
||||
uint8_t p_actorId,
|
||||
int8_t p_worldId,
|
||||
int8_t p_vehicleType,
|
||||
const float p_position[3],
|
||||
const float p_direction[3],
|
||||
const float p_up[3],
|
||||
float p_speed
|
||||
)
|
||||
{
|
||||
if (p_bufLen < STATE_MSG_SIZE) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
PlayerStateMsg msg;
|
||||
msg.header.type = MSG_STATE;
|
||||
msg.header.peerId = p_peerId;
|
||||
msg.header.sequence = p_sequence;
|
||||
msg.actorId = p_actorId;
|
||||
msg.worldId = p_worldId;
|
||||
msg.vehicleType = p_vehicleType;
|
||||
SDL_memcpy(msg.position, p_position, sizeof(float) * 3);
|
||||
SDL_memcpy(msg.direction, p_direction, sizeof(float) * 3);
|
||||
SDL_memcpy(msg.up, p_up, sizeof(float) * 3);
|
||||
msg.speed = p_speed;
|
||||
|
||||
SDL_memcpy(p_buf, &msg, STATE_MSG_SIZE);
|
||||
return STATE_MSG_SIZE;
|
||||
}
|
||||
|
||||
// Serialize a JOIN message into a buffer. Returns bytes written.
|
||||
inline size_t SerializeJoinMsg(
|
||||
uint8_t* p_buf,
|
||||
size_t p_bufLen,
|
||||
uint32_t p_peerId,
|
||||
uint32_t p_sequence,
|
||||
uint8_t p_actorId,
|
||||
const char* p_name
|
||||
)
|
||||
{
|
||||
if (p_bufLen < JOIN_MSG_SIZE) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
PlayerJoinMsg msg;
|
||||
msg.header.type = MSG_JOIN;
|
||||
msg.header.peerId = p_peerId;
|
||||
msg.header.sequence = p_sequence;
|
||||
msg.actorId = p_actorId;
|
||||
SDL_memset(msg.name, 0, sizeof(msg.name));
|
||||
if (p_name) {
|
||||
SDL_strlcpy(msg.name, p_name, sizeof(msg.name));
|
||||
}
|
||||
|
||||
SDL_memcpy(p_buf, &msg, JOIN_MSG_SIZE);
|
||||
return JOIN_MSG_SIZE;
|
||||
}
|
||||
|
||||
// Serialize a LEAVE message into a buffer. Returns bytes written.
|
||||
inline size_t SerializeLeaveMsg(uint8_t* p_buf, size_t p_bufLen, uint32_t p_peerId, uint32_t p_sequence)
|
||||
{
|
||||
if (p_bufLen < LEAVE_MSG_SIZE) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
PlayerLeaveMsg msg;
|
||||
msg.header.type = MSG_LEAVE;
|
||||
msg.header.peerId = p_peerId;
|
||||
msg.header.sequence = p_sequence;
|
||||
|
||||
SDL_memcpy(p_buf, &msg, LEAVE_MSG_SIZE);
|
||||
return LEAVE_MSG_SIZE;
|
||||
}
|
||||
|
||||
// Parse the message type from a buffer. Returns MSG type or 0 on error.
|
||||
inline uint8_t ParseMessageType(const uint8_t* p_data, size_t p_length)
|
||||
{
|
||||
@ -157,45 +133,28 @@ inline uint8_t ParseMessageType(const uint8_t* p_data, size_t p_length)
|
||||
return p_data[0];
|
||||
}
|
||||
|
||||
// Deserialize a message header from a buffer.
|
||||
inline bool DeserializeHeader(const uint8_t* p_data, size_t p_length, MessageHeader& p_out)
|
||||
// Generic serialization: copy a packed message struct into a buffer.
|
||||
template <typename T>
|
||||
inline size_t SerializeMsg(uint8_t* p_buf, size_t p_bufLen, const T& p_msg)
|
||||
{
|
||||
if (p_length < HEADER_SIZE) {
|
||||
static_assert(std::is_trivially_copyable_v<T>);
|
||||
if (p_bufLen < sizeof(T)) {
|
||||
return 0;
|
||||
}
|
||||
SDL_memcpy(p_buf, &p_msg, sizeof(T));
|
||||
return sizeof(T);
|
||||
}
|
||||
|
||||
// Generic deserialization: copy raw bytes into a packed message struct.
|
||||
template <typename T>
|
||||
inline bool DeserializeMsg(const uint8_t* p_data, size_t p_length, T& p_out)
|
||||
{
|
||||
static_assert(std::is_trivially_copyable_v<T>);
|
||||
if (p_length < sizeof(T)) {
|
||||
return false;
|
||||
}
|
||||
SDL_memcpy(&p_out, p_data, HEADER_SIZE);
|
||||
SDL_memcpy(&p_out, p_data, sizeof(T));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Deserialize a STATE message from a buffer.
|
||||
inline bool DeserializeStateMsg(const uint8_t* p_data, size_t p_length, PlayerStateMsg& p_out)
|
||||
{
|
||||
if (p_length < STATE_MSG_SIZE) {
|
||||
return false;
|
||||
}
|
||||
SDL_memcpy(&p_out, p_data, STATE_MSG_SIZE);
|
||||
return p_out.header.type == MSG_STATE;
|
||||
}
|
||||
|
||||
// Deserialize a JOIN message from a buffer.
|
||||
inline bool DeserializeJoinMsg(const uint8_t* p_data, size_t p_length, PlayerJoinMsg& p_out)
|
||||
{
|
||||
if (p_length < JOIN_MSG_SIZE) {
|
||||
return false;
|
||||
}
|
||||
SDL_memcpy(&p_out, p_data, JOIN_MSG_SIZE);
|
||||
p_out.name[sizeof(p_out.name) - 1] = '\0';
|
||||
return p_out.header.type == MSG_JOIN && IsValidActorId(p_out.actorId);
|
||||
}
|
||||
|
||||
// Deserialize a LEAVE message from a buffer.
|
||||
inline bool DeserializeLeaveMsg(const uint8_t* p_data, size_t p_length, PlayerLeaveMsg& p_out)
|
||||
{
|
||||
if (p_length < LEAVE_MSG_SIZE) {
|
||||
return false;
|
||||
}
|
||||
SDL_memcpy(&p_out, p_data, LEAVE_MSG_SIZE);
|
||||
return p_out.header.type == MSG_LEAVE;
|
||||
}
|
||||
|
||||
} // namespace Multiplayer
|
||||
|
||||
@ -2,6 +2,11 @@
|
||||
|
||||
#include "extensions/multiplayer/networkmanager.h"
|
||||
#include "extensions/multiplayer/networktransport.h"
|
||||
#include "extensions/multiplayer/protocol.h"
|
||||
#include "legoactor.h"
|
||||
#include "legoentity.h"
|
||||
#include "legogamestate.h"
|
||||
#include "misc.h"
|
||||
#ifdef __EMSCRIPTEN__
|
||||
#include "extensions/multiplayer/websockettransport.h"
|
||||
#endif
|
||||
@ -48,6 +53,51 @@ MxBool MultiplayerExt::HandleWorldEnable(LegoWorld* p_world, MxBool p_enable)
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
MxBool MultiplayerExt::HandleEntityNotify(LegoEntity* p_entity)
|
||||
{
|
||||
if (!s_networkManager) {
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
// Only intercept plants and buildings
|
||||
MxU8 type = p_entity->GetType();
|
||||
if (type != LegoEntity::e_plant && type != LegoEntity::e_building) {
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
// Determine the change type based on the active character,
|
||||
// mirroring the logic in LegoEntity::Notify().
|
||||
MxU8 changeType;
|
||||
switch (GameState()->GetActorId()) {
|
||||
case LegoActor::c_pepper:
|
||||
if (GameState()->GetCurrentAct() == LegoGameState::e_act2 ||
|
||||
GameState()->GetCurrentAct() == LegoGameState::e_act3) {
|
||||
return FALSE;
|
||||
}
|
||||
changeType = Multiplayer::CHANGE_VARIANT;
|
||||
break;
|
||||
case LegoActor::c_mama:
|
||||
changeType = Multiplayer::CHANGE_SOUND;
|
||||
break;
|
||||
case LegoActor::c_papa:
|
||||
changeType = Multiplayer::CHANGE_MOVE;
|
||||
break;
|
||||
case LegoActor::c_nick:
|
||||
changeType = Multiplayer::CHANGE_COLOR;
|
||||
break;
|
||||
case LegoActor::c_laura:
|
||||
changeType = Multiplayer::CHANGE_MOOD;
|
||||
break;
|
||||
case LegoActor::c_brickster:
|
||||
changeType = Multiplayer::CHANGE_DECREMENT;
|
||||
break;
|
||||
default:
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
return s_networkManager->HandleEntityMutation(p_entity, changeType);
|
||||
}
|
||||
|
||||
void MultiplayerExt::SetNetworkManager(Multiplayer::NetworkManager* p_mgr)
|
||||
{
|
||||
s_networkManager = p_mgr;
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
#include "extensions/multiplayer/networkmanager.h"
|
||||
|
||||
#include "legobuildingmanager.h"
|
||||
#include "legoentity.h"
|
||||
#include "legomain.h"
|
||||
#include "legopathactor.h"
|
||||
#include "legoplantmanager.h"
|
||||
#include "legoplants.h"
|
||||
#include "legoworld.h"
|
||||
#include "misc.h"
|
||||
#include "misc/legostorage.h"
|
||||
#include "mxmisc.h"
|
||||
#include "mxticklemanager.h"
|
||||
#include "roi/legoroi.h"
|
||||
@ -12,11 +17,32 @@
|
||||
#include <SDL3/SDL_timer.h>
|
||||
#include <vector>
|
||||
|
||||
extern MxU8 g_counters[];
|
||||
extern MxS32 g_plantMaxMove[];
|
||||
extern MxU32 g_plantMaxSound;
|
||||
extern MxU8 g_buildingInfoDownshift[];
|
||||
extern MxS32 g_buildingMaxMove[];
|
||||
extern MxU32 g_buildingMaxSound;
|
||||
|
||||
using namespace Multiplayer;
|
||||
|
||||
template <typename T>
|
||||
void NetworkManager::SendMessage(const T& p_msg)
|
||||
{
|
||||
if (!m_transport || !m_transport->IsConnected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
uint8_t buf[sizeof(T)];
|
||||
size_t len = SerializeMsg(buf, sizeof(buf), p_msg);
|
||||
if (len > 0) {
|
||||
m_transport->Send(buf, len);
|
||||
}
|
||||
}
|
||||
|
||||
NetworkManager::NetworkManager()
|
||||
: m_transport(nullptr), m_localPeerId(0), m_sequence(0), m_lastBroadcastTime(0), m_lastValidActorId(0),
|
||||
m_inIsleWorld(false), m_registered(false)
|
||||
: m_transport(nullptr), m_localPeerId(0), m_hostPeerId(0), m_sequence(0), m_lastBroadcastTime(0),
|
||||
m_lastValidActorId(0), m_inIsleWorld(false), m_registered(false), m_snapshotRequested(false)
|
||||
{
|
||||
}
|
||||
|
||||
@ -175,27 +201,17 @@ void NetworkManager::BroadcastLocalState()
|
||||
return;
|
||||
}
|
||||
|
||||
int8_t worldId = (int8_t) currentWorld->GetWorldId();
|
||||
int8_t vehicleType = DetectLocalVehicleType();
|
||||
PlayerStateMsg msg{};
|
||||
msg.header = {MSG_STATE, m_localPeerId, m_sequence++};
|
||||
msg.actorId = actorId;
|
||||
msg.worldId = (int8_t) currentWorld->GetWorldId();
|
||||
msg.vehicleType = DetectLocalVehicleType();
|
||||
SDL_memcpy(msg.position, pos, sizeof(msg.position));
|
||||
SDL_memcpy(msg.direction, dir, sizeof(msg.direction));
|
||||
SDL_memcpy(msg.up, up, sizeof(msg.up));
|
||||
msg.speed = speed;
|
||||
|
||||
uint8_t buf[64];
|
||||
size_t len = SerializeStateMsg(
|
||||
buf,
|
||||
sizeof(buf),
|
||||
m_localPeerId,
|
||||
m_sequence++,
|
||||
actorId,
|
||||
worldId,
|
||||
vehicleType,
|
||||
pos,
|
||||
dir,
|
||||
up,
|
||||
speed
|
||||
);
|
||||
|
||||
if (len > 0) {
|
||||
m_transport->Send(buf, len);
|
||||
}
|
||||
SendMessage(msg);
|
||||
}
|
||||
|
||||
void NetworkManager::ProcessIncomingPackets()
|
||||
@ -216,27 +232,62 @@ void NetworkManager::ProcessIncomingPackets()
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MSG_HOST_ASSIGN: {
|
||||
HostAssignMsg msg;
|
||||
if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_HOST_ASSIGN) {
|
||||
HandleHostAssign(msg);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MSG_JOIN: {
|
||||
PlayerJoinMsg msg;
|
||||
if (DeserializeJoinMsg(data, length, msg)) {
|
||||
HandleJoin(msg);
|
||||
if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_JOIN) {
|
||||
msg.name[sizeof(msg.name) - 1] = '\0';
|
||||
if (IsValidActorId(msg.actorId)) {
|
||||
HandleJoin(msg);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MSG_LEAVE: {
|
||||
PlayerLeaveMsg msg;
|
||||
if (DeserializeLeaveMsg(data, length, msg)) {
|
||||
if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_LEAVE) {
|
||||
HandleLeave(msg);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MSG_STATE: {
|
||||
PlayerStateMsg msg;
|
||||
if (DeserializeStateMsg(data, length, msg)) {
|
||||
if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_STATE) {
|
||||
HandleState(msg);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MSG_REQUEST_SNAPSHOT: {
|
||||
RequestSnapshotMsg msg;
|
||||
if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_REQUEST_SNAPSHOT) {
|
||||
HandleRequestSnapshot(msg);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MSG_WORLD_SNAPSHOT: {
|
||||
HandleWorldSnapshot(data, length);
|
||||
break;
|
||||
}
|
||||
case MSG_WORLD_EVENT: {
|
||||
WorldEventMsg msg;
|
||||
if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_WORLD_EVENT) {
|
||||
HandleWorldEvent(msg);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MSG_WORLD_EVENT_REQUEST: {
|
||||
WorldEventRequestMsg msg;
|
||||
if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_WORLD_EVENT_REQUEST) {
|
||||
HandleWorldEventRequest(msg);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@ -312,6 +363,97 @@ void NetworkManager::HandleState(const PlayerStateMsg& p_msg)
|
||||
}
|
||||
}
|
||||
|
||||
void NetworkManager::HandleHostAssign(const HostAssignMsg& p_msg)
|
||||
{
|
||||
uint32_t oldHost = m_hostPeerId;
|
||||
m_hostPeerId = p_msg.hostPeerId;
|
||||
|
||||
// If the host changed and we're not the new host, request a snapshot.
|
||||
// Reset any pending snapshot state since the old host may have disconnected
|
||||
// before responding to our previous request.
|
||||
if (!IsHost() && oldHost != m_hostPeerId) {
|
||||
m_snapshotRequested = false;
|
||||
m_pendingWorldEvents.clear();
|
||||
SendSnapshotRequest();
|
||||
}
|
||||
}
|
||||
|
||||
void NetworkManager::HandleRequestSnapshot(const RequestSnapshotMsg& p_msg)
|
||||
{
|
||||
// Only the host should respond to snapshot requests
|
||||
if (!IsHost()) {
|
||||
return;
|
||||
}
|
||||
|
||||
SendWorldSnapshot(p_msg.header.peerId);
|
||||
}
|
||||
|
||||
void NetworkManager::HandleWorldSnapshot(const uint8_t* p_data, size_t p_length)
|
||||
{
|
||||
WorldSnapshotMsg header;
|
||||
if (!DeserializeMsg(p_data, p_length, header) || header.header.type != MSG_WORLD_SNAPSHOT) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (p_length < sizeof(WorldSnapshotMsg) + header.dataLength) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uint8_t* snapshotData = p_data + sizeof(WorldSnapshotMsg);
|
||||
|
||||
// Apply the snapshot using LegoMemory with the existing Read() methods
|
||||
LegoMemory memory((void*) snapshotData, header.dataLength);
|
||||
|
||||
PlantManager()->Read(&memory);
|
||||
BuildingManager()->Read(&memory);
|
||||
|
||||
// If we're in the Isle world, update entity visuals after applying the snapshot.
|
||||
// Read() calls AdjustHeight() which updates data arrays, but doesn't update
|
||||
// entity positions. We need to reload world info to refresh visuals.
|
||||
if (m_inIsleWorld) {
|
||||
// Reset and reload plant entities with the new data
|
||||
LegoWorld* world = CurrentWorld();
|
||||
if (world && world->GetWorldId() == LegoOmni::e_act1) {
|
||||
PlantManager()->Reset(LegoOmni::e_act1);
|
||||
PlantManager()->LoadWorldInfo(LegoOmni::e_act1);
|
||||
BuildingManager()->Reset();
|
||||
BuildingManager()->LoadWorldInfo();
|
||||
}
|
||||
}
|
||||
|
||||
// Apply any world events that were queued between snapshot request and response
|
||||
for (const auto& evt : m_pendingWorldEvents) {
|
||||
ApplyWorldEvent(evt.entityType, evt.changeType, evt.entityIndex);
|
||||
}
|
||||
m_pendingWorldEvents.clear();
|
||||
m_snapshotRequested = false;
|
||||
}
|
||||
|
||||
void NetworkManager::HandleWorldEvent(const WorldEventMsg& p_msg)
|
||||
{
|
||||
// If we're waiting for a snapshot, queue this event for later
|
||||
if (m_snapshotRequested) {
|
||||
m_pendingWorldEvents.push_back(p_msg);
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyWorldEvent(p_msg.entityType, p_msg.changeType, p_msg.entityIndex);
|
||||
}
|
||||
|
||||
void NetworkManager::HandleWorldEventRequest(const WorldEventRequestMsg& p_msg)
|
||||
{
|
||||
// Only the host processes event requests
|
||||
if (!IsHost()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply locally on the host
|
||||
ApplyWorldEvent(p_msg.entityType, p_msg.changeType, p_msg.entityIndex);
|
||||
|
||||
// Broadcast to all peers as an authoritative world event
|
||||
BroadcastWorldEvent(p_msg.entityType, p_msg.changeType, p_msg.entityIndex);
|
||||
}
|
||||
|
||||
void NetworkManager::RemoveRemotePlayer(uint32_t p_peerId)
|
||||
{
|
||||
auto it = m_remotePlayers.find(p_peerId);
|
||||
@ -331,36 +473,30 @@ void NetworkManager::RemoveAllRemotePlayers()
|
||||
|
||||
int8_t NetworkManager::DetectLocalVehicleType()
|
||||
{
|
||||
static const struct {
|
||||
const char* className;
|
||||
int8_t vehicleType;
|
||||
} vehicleMap[] = {
|
||||
{"Helicopter", VEHICLE_HELICOPTER},
|
||||
{"Jetski", VEHICLE_JETSKI},
|
||||
{"DuneBuggy", VEHICLE_DUNEBUGGY},
|
||||
{"Bike", VEHICLE_BIKE},
|
||||
{"SkateBoard", VEHICLE_SKATEBOARD},
|
||||
{"Motorcycle", VEHICLE_MOTOCYCLE},
|
||||
{"TowTrack", VEHICLE_TOWTRACK},
|
||||
{"Ambulance", VEHICLE_AMBULANCE},
|
||||
};
|
||||
|
||||
LegoPathActor* actor = UserActor();
|
||||
if (!actor) {
|
||||
return VEHICLE_NONE;
|
||||
}
|
||||
|
||||
if (actor->IsA("Helicopter")) {
|
||||
return VEHICLE_HELICOPTER;
|
||||
for (const auto& entry : vehicleMap) {
|
||||
if (actor->IsA(entry.className)) {
|
||||
return entry.vehicleType;
|
||||
}
|
||||
}
|
||||
if (actor->IsA("Jetski")) {
|
||||
return VEHICLE_JETSKI;
|
||||
}
|
||||
if (actor->IsA("DuneBuggy")) {
|
||||
return VEHICLE_DUNEBUGGY;
|
||||
}
|
||||
if (actor->IsA("Bike")) {
|
||||
return VEHICLE_BIKE;
|
||||
}
|
||||
if (actor->IsA("SkateBoard")) {
|
||||
return VEHICLE_SKATEBOARD;
|
||||
}
|
||||
if (actor->IsA("Motocycle")) {
|
||||
return VEHICLE_MOTOCYCLE;
|
||||
}
|
||||
if (actor->IsA("TowTrack")) {
|
||||
return VEHICLE_TOWTRACK;
|
||||
}
|
||||
if (actor->IsA("Ambulance")) {
|
||||
return VEHICLE_AMBULANCE;
|
||||
}
|
||||
|
||||
return VEHICLE_NONE;
|
||||
}
|
||||
|
||||
@ -368,3 +504,288 @@ bool NetworkManager::IsInIsleWorld() const
|
||||
{
|
||||
return m_inIsleWorld;
|
||||
}
|
||||
|
||||
// ---- World state sync ----
|
||||
|
||||
template <typename TInfo>
|
||||
static int FindEntityIndex(TInfo* p_infoArray, MxS32 p_count, LegoEntity* p_entity)
|
||||
{
|
||||
for (MxS32 i = 0; i < p_count; i++) {
|
||||
if (p_infoArray[i].m_entity == p_entity) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
MxBool NetworkManager::HandleEntityMutation(LegoEntity* p_entity, MxU8 p_changeType)
|
||||
{
|
||||
if (!IsConnected()) {
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
uint8_t entityType;
|
||||
int idx;
|
||||
|
||||
if (p_entity->GetType() == LegoEntity::e_plant) {
|
||||
entityType = ENTITY_PLANT;
|
||||
MxS32 count;
|
||||
idx = FindEntityIndex(PlantManager()->GetInfoArray(count), count, p_entity);
|
||||
}
|
||||
else if (p_entity->GetType() == LegoEntity::e_building) {
|
||||
entityType = ENTITY_BUILDING;
|
||||
MxS32 count;
|
||||
idx = FindEntityIndex(BuildingManager()->GetInfoArray(count), count, p_entity);
|
||||
}
|
||||
else {
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
if (idx < 0) {
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
if (IsHost()) {
|
||||
// Host: allow local mutation, then broadcast to all peers
|
||||
BroadcastWorldEvent(entityType, p_changeType, (uint8_t) idx);
|
||||
return FALSE; // FALSE = allow local mutation to proceed
|
||||
}
|
||||
else {
|
||||
// Non-host: send request to host, block local mutation
|
||||
SendWorldEventRequest(entityType, p_changeType, (uint8_t) idx);
|
||||
return TRUE; // TRUE = suppress local mutation
|
||||
}
|
||||
}
|
||||
|
||||
void NetworkManager::SendSnapshotRequest()
|
||||
{
|
||||
RequestSnapshotMsg msg{};
|
||||
msg.header = {MSG_REQUEST_SNAPSHOT, m_localPeerId, m_sequence++};
|
||||
SendMessage(msg);
|
||||
|
||||
m_snapshotRequested = true;
|
||||
m_pendingWorldEvents.clear();
|
||||
}
|
||||
|
||||
void NetworkManager::SendWorldSnapshot(uint32_t p_targetPeerId)
|
||||
{
|
||||
if (!m_transport || !m_transport->IsConnected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Serialize plant + building state into a buffer using existing Write() methods
|
||||
// Max sizes: 81 plants * (1+4+4+1+1+1) = 81*12 = 972 bytes
|
||||
// 16 buildings * (4+4+1+1) = 16*10 = 160 bytes + 1 byte nextVariant
|
||||
// Total ~1133 bytes. Use 4096 for safety.
|
||||
uint8_t stateBuffer[4096];
|
||||
LegoMemory memory(stateBuffer, sizeof(stateBuffer));
|
||||
|
||||
PlantManager()->Write(&memory);
|
||||
BuildingManager()->Write(&memory);
|
||||
|
||||
LegoU32 dataLength;
|
||||
memory.GetPosition(dataLength);
|
||||
|
||||
// Build the snapshot header + trailing payload
|
||||
WorldSnapshotMsg msg{};
|
||||
msg.header = {MSG_WORLD_SNAPSHOT, m_localPeerId, m_sequence++};
|
||||
msg.targetPeerId = p_targetPeerId;
|
||||
msg.dataLength = (uint16_t) dataLength;
|
||||
|
||||
std::vector<uint8_t> msgBuf(sizeof(WorldSnapshotMsg) + dataLength);
|
||||
SDL_memcpy(msgBuf.data(), &msg, sizeof(WorldSnapshotMsg));
|
||||
SDL_memcpy(msgBuf.data() + sizeof(WorldSnapshotMsg), stateBuffer, dataLength);
|
||||
|
||||
m_transport->Send(msgBuf.data(), msgBuf.size());
|
||||
}
|
||||
|
||||
void NetworkManager::BroadcastWorldEvent(uint8_t p_entityType, uint8_t p_changeType, uint8_t p_entityIndex)
|
||||
{
|
||||
WorldEventMsg msg{};
|
||||
msg.header = {MSG_WORLD_EVENT, m_localPeerId, m_sequence++};
|
||||
msg.entityType = p_entityType;
|
||||
msg.changeType = p_changeType;
|
||||
msg.entityIndex = p_entityIndex;
|
||||
SendMessage(msg);
|
||||
}
|
||||
|
||||
void NetworkManager::SendWorldEventRequest(uint8_t p_entityType, uint8_t p_changeType, uint8_t p_entityIndex)
|
||||
{
|
||||
WorldEventRequestMsg msg{};
|
||||
msg.header = {MSG_WORLD_EVENT_REQUEST, m_localPeerId, m_sequence++};
|
||||
msg.entityType = p_entityType;
|
||||
msg.changeType = p_changeType;
|
||||
msg.entityIndex = p_entityIndex;
|
||||
SendMessage(msg);
|
||||
}
|
||||
|
||||
// Dispatch Switch*() calls shared by all entity types.
|
||||
// Returns true if the change was handled, false for type-specific changes.
|
||||
static bool DispatchEntitySwitch(LegoEntity* p_entity, uint8_t p_changeType)
|
||||
{
|
||||
switch (p_changeType) {
|
||||
case CHANGE_VARIANT:
|
||||
p_entity->SwitchVariant();
|
||||
return true;
|
||||
case CHANGE_SOUND:
|
||||
p_entity->SwitchSound();
|
||||
return true;
|
||||
case CHANGE_MOVE:
|
||||
p_entity->SwitchMove();
|
||||
return true;
|
||||
case CHANGE_MOOD:
|
||||
p_entity->SwitchMood();
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void NetworkManager::ApplyWorldEvent(uint8_t p_entityType, uint8_t p_changeType, uint8_t p_entityIndex)
|
||||
{
|
||||
if (p_entityType == ENTITY_PLANT) {
|
||||
MxS32 numPlants;
|
||||
LegoPlantInfo* plantInfo = PlantManager()->GetInfoArray(numPlants);
|
||||
if (p_entityIndex >= numPlants) {
|
||||
return;
|
||||
}
|
||||
|
||||
LegoPlantInfo* info = &plantInfo[p_entityIndex];
|
||||
|
||||
// If entity exists (we're in the Isle world), use LegoEntity::Switch*()
|
||||
// which handles data mutation + visual update + sound + animation + counter
|
||||
if (info->m_entity != NULL) {
|
||||
if (!DispatchEntitySwitch(info->m_entity, p_changeType)) {
|
||||
if (p_changeType == CHANGE_COLOR) {
|
||||
info->m_entity->SwitchColor(info->m_entity->GetROI());
|
||||
}
|
||||
else if (p_changeType == CHANGE_DECREMENT) {
|
||||
PlantManager()->DecrementCounter(info->m_entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Entity is NULL (we're outside the Isle world).
|
||||
// Apply changes directly to the data array.
|
||||
switch (p_changeType) {
|
||||
case CHANGE_VARIANT:
|
||||
if (info->m_counter == -1) {
|
||||
info->m_variant++;
|
||||
if (info->m_variant > LegoPlantInfo::e_palm) {
|
||||
info->m_variant = LegoPlantInfo::e_flower;
|
||||
}
|
||||
|
||||
// Clamp move to the new variant's max (mirrors SwitchVariant)
|
||||
if (info->m_move != 0 && info->m_move >= (MxU32) g_plantMaxMove[info->m_variant]) {
|
||||
info->m_move = g_plantMaxMove[info->m_variant] - 1;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case CHANGE_SOUND:
|
||||
info->m_sound++;
|
||||
if (info->m_sound >= g_plantMaxSound) {
|
||||
info->m_sound = 0;
|
||||
}
|
||||
break;
|
||||
case CHANGE_MOVE:
|
||||
info->m_move++;
|
||||
if (info->m_move >= (MxU32) g_plantMaxMove[info->m_variant]) {
|
||||
info->m_move = 0;
|
||||
}
|
||||
break;
|
||||
case CHANGE_COLOR:
|
||||
info->m_color++;
|
||||
if (info->m_color > LegoPlantInfo::e_green) {
|
||||
info->m_color = LegoPlantInfo::e_white;
|
||||
}
|
||||
break;
|
||||
case CHANGE_MOOD:
|
||||
info->m_mood++;
|
||||
if (info->m_mood > 3) {
|
||||
info->m_mood = 0;
|
||||
}
|
||||
break;
|
||||
case CHANGE_DECREMENT: {
|
||||
if (info->m_counter < 0) {
|
||||
info->m_counter = g_counters[info->m_variant];
|
||||
}
|
||||
if (info->m_counter > 0) {
|
||||
info->m_counter--;
|
||||
if (info->m_counter == 1) {
|
||||
info->m_counter = 0;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (p_entityType == ENTITY_BUILDING) {
|
||||
MxS32 numBuildings;
|
||||
LegoBuildingInfo* buildingInfo = BuildingManager()->GetInfoArray(numBuildings);
|
||||
if (p_entityIndex >= numBuildings) {
|
||||
return;
|
||||
}
|
||||
|
||||
LegoBuildingInfo* info = &buildingInfo[p_entityIndex];
|
||||
|
||||
// If entity exists (we're in the Isle world), use LegoEntity::Switch*()
|
||||
if (info->m_entity != NULL) {
|
||||
if (!DispatchEntitySwitch(info->m_entity, p_changeType)) {
|
||||
if (p_changeType == CHANGE_COLOR) {
|
||||
info->m_entity->SwitchColor(info->m_entity->GetROI());
|
||||
}
|
||||
else if (p_changeType == CHANGE_DECREMENT) {
|
||||
BuildingManager()->DecrementCounter(info->m_entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Entity is NULL (we're outside the Isle world).
|
||||
// Apply changes directly to the data array.
|
||||
switch (p_changeType) {
|
||||
case CHANGE_SOUND:
|
||||
if (info->m_flags & LegoBuildingInfo::c_hasSounds) {
|
||||
info->m_sound++;
|
||||
if (info->m_sound >= g_buildingMaxSound) {
|
||||
info->m_sound = 0;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case CHANGE_MOVE:
|
||||
if (info->m_flags & LegoBuildingInfo::c_hasMoves) {
|
||||
info->m_move++;
|
||||
if (info->m_move >= (MxU32) g_buildingMaxMove[p_entityIndex]) {
|
||||
info->m_move = 0;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case CHANGE_MOOD:
|
||||
if (info->m_flags & LegoBuildingInfo::c_hasMoods) {
|
||||
info->m_mood++;
|
||||
if (info->m_mood > 3) {
|
||||
info->m_mood = 0;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case CHANGE_DECREMENT: {
|
||||
if (info->m_counter < 0) {
|
||||
info->m_counter = g_buildingInfoDownshift[p_entityIndex];
|
||||
}
|
||||
if (info->m_counter > 0) {
|
||||
info->m_counter -= 2;
|
||||
if (info->m_counter == 1) {
|
||||
info->m_counter = 0;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case CHANGE_VARIANT:
|
||||
case CHANGE_COLOR:
|
||||
// Variant switching is config-dependent, color N/A for buildings
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -21,8 +21,10 @@
|
||||
|
||||
using namespace Multiplayer;
|
||||
|
||||
// LOD names for vehicle models. The helicopter is a compound ROI ("copter")
|
||||
// with no standalone LOD; use its body part instead.
|
||||
static const char* g_vehicleROINames[VEHICLE_COUNT] =
|
||||
{"copter", "jsuser", "dunebugy", "bike", "board", "moto", "towtk", "ambul"};
|
||||
{"chtrbody", "jsuser", "dunebugy", "bike", "board", "moto", "towtk", "ambul"};
|
||||
|
||||
static const char* g_rideAnimNames[VEHICLE_COUNT] = {NULL, NULL, NULL, "CNs001Bd", "CNs001sk", "CNs011Ni", NULL, NULL};
|
||||
|
||||
@ -437,13 +439,12 @@ void RemotePlayer::EnterVehicle(int8_t p_vehicleType)
|
||||
m_animTime = 0.0f;
|
||||
|
||||
if (IsLargeVehicle(p_vehicleType)) {
|
||||
m_roi->SetVisibility(FALSE);
|
||||
|
||||
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);
|
||||
@ -473,12 +474,16 @@ void RemotePlayer::EnterVehicle(int8_t p_vehicleType)
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the base vehicle LOD (e.g. "moto", "bike") which is always loaded as
|
||||
// a world object. The ride-specific variant LODs (e.g. "motoni", "bikebd")
|
||||
// are only available when the original animation pipeline starts locally.
|
||||
const char* baseName = g_vehicleROINames[p_vehicleType];
|
||||
char variantName[48];
|
||||
SDL_snprintf(variantName, sizeof(variantName), "%s_mp_%u", vehicleVariantName, m_peerId);
|
||||
m_rideVehicleROI = CharacterManager()->CreateAutoROI(variantName, vehicleVariantName, FALSE);
|
||||
m_rideVehicleROI = CharacterManager()->CreateAutoROI(variantName, baseName, FALSE);
|
||||
|
||||
// Rename to base name so FindChildROI can match animation tree nodes.
|
||||
// ReleaseAutoROI uses pointer comparison, not name.
|
||||
// Rename to variant name so FindChildROI can match animation tree nodes
|
||||
// (e.g. "MOTONI" in the anim tree matches ROI named "motoni").
|
||||
if (m_rideVehicleROI) {
|
||||
m_rideVehicleROI->SetName(vehicleVariantName);
|
||||
}
|
||||
|
||||
1300
extensions/src/multiplayer/server/package-lock.json
generated
1300
extensions/src/multiplayer/server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,9 @@
|
||||
{
|
||||
"scripts": {
|
||||
"dev": "wrangler dev --port 8787",
|
||||
"deploy": "wrangler deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.19.0"
|
||||
"wrangler": "^4"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,104 +0,0 @@
|
||||
import { createServer } from "http";
|
||||
import { WebSocketServer } from "ws";
|
||||
|
||||
const PORT = process.env.PORT || 8787;
|
||||
const rooms = new Map();
|
||||
|
||||
function getRoom(roomId) {
|
||||
if (!rooms.has(roomId)) {
|
||||
rooms.set(roomId, { connections: new Map(), nextPeerId: 1 });
|
||||
}
|
||||
return rooms.get(roomId);
|
||||
}
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
if (req.url === "/" || req.url === "/health") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "ok" }));
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end("Not Found");
|
||||
}
|
||||
});
|
||||
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
wss.on("connection", (ws, req) => {
|
||||
const pathParts = (req.url || "").split("/").filter(Boolean);
|
||||
if (pathParts.length !== 2 || pathParts[0] !== "room") {
|
||||
console.log(`[REJECT] Invalid path: ${req.url}`);
|
||||
ws.close(1008, "Invalid path");
|
||||
return;
|
||||
}
|
||||
|
||||
const roomId = pathParts[1];
|
||||
const room = getRoom(roomId);
|
||||
const peerId = room.nextPeerId++;
|
||||
const peerIdStr = String(peerId);
|
||||
room.connections.set(peerIdStr, ws);
|
||||
console.log(`[CONNECT] Peer ${peerId} joined room "${roomId}" (${room.connections.size} peers)`);
|
||||
|
||||
// Send the peer its assigned ID as the first message
|
||||
const idMsg = Buffer.alloc(5);
|
||||
idMsg.writeUInt8(0xff, 0);
|
||||
idMsg.writeUInt32LE(peerId, 1);
|
||||
ws.send(idMsg);
|
||||
|
||||
ws.on("message", (data) => {
|
||||
if (!(data instanceof Buffer) || data.length < 9) {
|
||||
return;
|
||||
}
|
||||
|
||||
const msgType = data.readUInt8(0);
|
||||
console.log(`[MSG] Peer ${peerId} sent type=${msgType} len=${data.length}`);
|
||||
|
||||
// Stamp the peerId into the message header (bytes 1-4)
|
||||
const stamped = Buffer.from(data);
|
||||
stamped.writeUInt32LE(peerId, 1);
|
||||
|
||||
// Broadcast to all other peers in this room
|
||||
let sent = 0;
|
||||
for (const [id, peer] of room.connections) {
|
||||
if (id !== peerIdStr) {
|
||||
try {
|
||||
peer.send(stamped);
|
||||
sent++;
|
||||
} catch {
|
||||
room.connections.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`[RELAY] Forwarded to ${sent} peers`);
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
console.log(`[DISCONNECT] Peer ${peerId} left room "${roomId}" (${room.connections.size - 1} peers remaining)`);
|
||||
room.connections.delete(peerIdStr);
|
||||
|
||||
// Broadcast LEAVE message to remaining peers
|
||||
const leaveMsg = Buffer.alloc(9);
|
||||
leaveMsg.writeUInt8(2, 0); // MSG_LEAVE
|
||||
leaveMsg.writeUInt32LE(peerId, 1);
|
||||
leaveMsg.writeUInt32LE(0, 5); // sequence 0
|
||||
|
||||
for (const [, peer] of room.connections) {
|
||||
try {
|
||||
peer.send(leaveMsg);
|
||||
} catch {
|
||||
// Ignore send errors on cleanup
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up empty rooms
|
||||
if (room.connections.size === 0) {
|
||||
rooms.delete(pathParts[1]);
|
||||
}
|
||||
};
|
||||
|
||||
ws.on("close", onClose);
|
||||
ws.on("error", onClose);
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Relay server listening on http://localhost:${PORT}`);
|
||||
});
|
||||
@ -26,9 +26,18 @@ export default {
|
||||
},
|
||||
};
|
||||
|
||||
// Message types matching protocol.h
|
||||
const MSG_LEAVE = 2;
|
||||
const MSG_HOST_ASSIGN = 4;
|
||||
const MSG_REQUEST_SNAPSHOT = 5;
|
||||
const MSG_WORLD_SNAPSHOT = 6;
|
||||
const MSG_WORLD_EVENT_REQUEST = 8;
|
||||
const MSG_ASSIGN_ID = 0xff;
|
||||
|
||||
export class GameRoom implements DurableObject {
|
||||
private connections: Map<string, WebSocket> = new Map();
|
||||
private nextPeerId: number = 1;
|
||||
private hostPeerId: number = 0;
|
||||
|
||||
constructor(
|
||||
private state: DurableObjectState,
|
||||
@ -46,16 +55,25 @@ export class GameRoom implements DurableObject {
|
||||
const peerId = this.nextPeerId++;
|
||||
const peerIdStr = String(peerId);
|
||||
|
||||
this.state.acceptWebSocket(server);
|
||||
server.accept();
|
||||
this.connections.set(peerIdStr, server);
|
||||
|
||||
// Send the peer its assigned ID as the first message
|
||||
const idMsg = new ArrayBuffer(5);
|
||||
const view = new DataView(idMsg);
|
||||
view.setUint8(0, 0xff); // Special "assign ID" message type
|
||||
view.setUint8(0, MSG_ASSIGN_ID);
|
||||
view.setUint32(1, peerId, true); // little-endian peer ID
|
||||
server.send(idMsg);
|
||||
|
||||
// Assign host if none exists (first peer becomes host)
|
||||
if (this.hostPeerId === 0 || !this.connections.has(String(this.hostPeerId))) {
|
||||
this.hostPeerId = peerId;
|
||||
this.broadcastHostAssign();
|
||||
} else {
|
||||
// Send current host assignment to the new peer only
|
||||
this.sendHostAssign(server);
|
||||
}
|
||||
|
||||
server.addEventListener("message", (event) => {
|
||||
if (!(event.data instanceof ArrayBuffer)) {
|
||||
return;
|
||||
@ -66,30 +84,55 @@ export class GameRoom implements DurableObject {
|
||||
return; // Too short for header
|
||||
}
|
||||
|
||||
const msgType = data[0];
|
||||
|
||||
// Stamp the peerId into the message header (bytes 1-4)
|
||||
const stamped = new Uint8Array(data.length);
|
||||
stamped.set(data);
|
||||
new DataView(stamped.buffer).setUint32(1, peerId, true);
|
||||
|
||||
// Broadcast to all other peers in this room
|
||||
for (const [id, ws] of this.connections) {
|
||||
if (id !== peerIdStr) {
|
||||
if (msgType === MSG_REQUEST_SNAPSHOT || msgType === MSG_WORLD_EVENT_REQUEST) {
|
||||
// Route to host only
|
||||
const hostWs = this.connections.get(String(this.hostPeerId));
|
||||
if (hostWs) {
|
||||
try {
|
||||
ws.send(stamped.buffer);
|
||||
hostWs.send(stamped.buffer);
|
||||
} catch {
|
||||
this.connections.delete(id);
|
||||
// Host disconnected; will be handled by close event
|
||||
}
|
||||
}
|
||||
} else if (msgType === MSG_WORLD_SNAPSHOT && data.length >= 15) {
|
||||
// Route to the target peer only (targetPeerId at offset 9)
|
||||
const targetId = new DataView(stamped.buffer).getUint32(9, true);
|
||||
const targetWs = this.connections.get(String(targetId));
|
||||
if (targetWs) {
|
||||
try {
|
||||
targetWs.send(stamped.buffer);
|
||||
} catch {
|
||||
this.connections.delete(String(targetId));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Broadcast to all other peers in this room
|
||||
for (const [id, ws] of this.connections) {
|
||||
if (id !== peerIdStr) {
|
||||
try {
|
||||
ws.send(stamped.buffer);
|
||||
} catch {
|
||||
this.connections.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
server.addEventListener("close", () => {
|
||||
const handleDisconnect = () => {
|
||||
this.connections.delete(peerIdStr);
|
||||
|
||||
// Broadcast LEAVE message to remaining peers
|
||||
const leaveMsg = new ArrayBuffer(9);
|
||||
const leaveView = new DataView(leaveMsg);
|
||||
leaveView.setUint8(0, 2); // MSG_LEAVE
|
||||
leaveView.setUint8(0, MSG_LEAVE);
|
||||
leaveView.setUint32(1, peerId, true);
|
||||
leaveView.setUint32(5, 0, true); // sequence 0
|
||||
|
||||
@ -100,12 +143,62 @@ export class GameRoom implements DurableObject {
|
||||
// Ignore send errors on cleanup
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
server.addEventListener("error", () => {
|
||||
this.connections.delete(peerIdStr);
|
||||
});
|
||||
// Host migration: if the disconnected peer was the host, assign a new one
|
||||
if (peerId === this.hostPeerId) {
|
||||
this.electNewHost();
|
||||
}
|
||||
};
|
||||
|
||||
server.addEventListener("close", handleDisconnect);
|
||||
server.addEventListener("error", handleDisconnect);
|
||||
|
||||
return new Response(null, { status: 101, webSocket: client });
|
||||
}
|
||||
|
||||
private electNewHost(): void {
|
||||
// Pick the lowest peer ID from remaining connections
|
||||
let lowestId = 0;
|
||||
for (const idStr of this.connections.keys()) {
|
||||
const id = parseInt(idStr, 10);
|
||||
if (lowestId === 0 || id < lowestId) {
|
||||
lowestId = id;
|
||||
}
|
||||
}
|
||||
|
||||
this.hostPeerId = lowestId;
|
||||
if (lowestId > 0) {
|
||||
this.broadcastHostAssign();
|
||||
}
|
||||
}
|
||||
|
||||
private broadcastHostAssign(): void {
|
||||
const msg = this.createHostAssignMsg();
|
||||
for (const [, ws] of this.connections) {
|
||||
try {
|
||||
ws.send(msg);
|
||||
} catch {
|
||||
// Ignore send errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sendHostAssign(ws: WebSocket): void {
|
||||
try {
|
||||
ws.send(this.createHostAssignMsg());
|
||||
} catch {
|
||||
// Ignore send errors
|
||||
}
|
||||
}
|
||||
|
||||
private createHostAssignMsg(): ArrayBuffer {
|
||||
// MessageHeader (9 bytes) + hostPeerId (4 bytes) = 13 bytes
|
||||
const msg = new ArrayBuffer(13);
|
||||
const view = new DataView(msg);
|
||||
view.setUint8(0, MSG_HOST_ASSIGN); // type
|
||||
view.setUint32(1, 0, true); // peerId (server, so 0)
|
||||
view.setUint32(5, 0, true); // sequence
|
||||
view.setUint32(9, this.hostPeerId, true); // hostPeerId
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user