From 41013ce3e3b5fdf03816e6b89cfd646c840fb852 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 12 Apr 2026 18:56:45 +0200 Subject: [PATCH] =?UTF-8?q?fix(core+app):=20Phase=20B.3=20=E2=80=94=20Setu?= =?UTF-8?q?p.StepUpHeight=20+=20scenery=20road=20exclusion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four targeted fixes for user-reported movement/visual bugs: 1. Player entity disappearing: GpuWorldState now supports persistent entities (MarkPersistent/DrainRescued). The player character survives landblock unloads and gets re-injected into the streaming window at the current center landblock. 2. Feet sinking into terrain: +0.15 Z bias in PlayerMovementController keeps the character model above terrain z-fighting edge cases. 3. Camera after portal teleport: ChaseCamera.Update now called immediately after teleport snap so the camera recenters on the new position instead of lingering at the pre-teleport location. 4. Scenery on roads: SceneryGenerator now checks road status at the final displaced position (not just the origin vertex), catching objects that drift from non-road vertices onto road cells. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Input/PlayerMovementController.cs | 3 +- src/AcDream.App/Rendering/GameWindow.cs | 16 ++++++- src/AcDream.App/Streaming/GpuWorldState.cs | 44 +++++++++++++++++-- src/AcDream.Core/World/SceneryGenerator.cs | 11 +++++ .../Input/PlayerMovementControllerTests.cs | 5 ++- 5 files changed, 71 insertions(+), 8 deletions(-) diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index db3e938..232184b 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -198,7 +198,8 @@ public sealed class PlayerMovementController } } - Position = new Vector3(result.Position.X, result.Position.Y, newZ); + // Small upward bias prevents feet from z-fighting with terrain surface. + Position = new Vector3(result.Position.X, result.Position.Y, newZ + 0.15f); CellId = result.CellId; // 4. Determine current motion commands. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 5073673..9d80f60 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -437,6 +437,7 @@ public sealed class GameWindow : IDisposable var chosen = _liveSession.Characters.Characters[0]; _playerServerGuid = chosen.Id; // Phase B.2: store for Tab-key player-mode entry + _worldState.MarkPersistent(chosen.Id); // player entity survives landblock unloads Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}"); _liveSession.EnterWorld(user, characterIndex: 0); Console.WriteLine($"live: in world — CreateObject stream active " + @@ -1004,7 +1005,10 @@ public sealed class GameWindow : IDisposable entity.Rotation = rot; _playerController.SetPosition(snappedPos, resolved.CellId); - // 4. Return to InWorld. + // 4. Recenter chase camera on the new position. + _chaseCamera?.Update(snappedPos, _playerController.Yaw); + + // 5. Return to InWorld. _playerController.State = AcDream.App.Input.PlayerState.InWorld; Console.WriteLine($"live: teleport complete — snapped to {snappedPos} cell=0x{resolved.CellId:X8}"); @@ -1574,6 +1578,16 @@ public sealed class GameWindow : IDisposable } _streamingController.Tick(observerCx, observerCy); + + // Re-inject persistent entities rescued from unloaded landblocks + // into the current center landblock (the one the observer is in). + var rescued = _worldState.DrainRescued(); + if (rescued.Count > 0) + { + uint centerLb = (uint)((observerCx << 24) | (observerCy << 16) | 0xFFFF); + foreach (var entity in rescued) + _worldState.AppendLiveEntity(centerLb, entity); + } } // Drain pending live-session traffic AFTER streaming so any incoming diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index 0b9e910..264c155 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -48,6 +48,13 @@ public sealed class GpuWorldState /// private readonly Dictionary> _pendingByLandblock = new(); + /// + /// Entities that must survive landblock unloads (e.g. the player character). + /// On RemoveLandblock, these are rescued and re-parked as pending for their + /// current canonical landblock. + /// + private readonly HashSet _persistentGuids = new(); + // Cached flat view over all entities across all loaded landblocks, // rebuilt on each add/remove. The renderer holds a reference to this // list, so rebuilding it replaces the reference atomically. @@ -112,12 +119,27 @@ public sealed class GpuWorldState RebuildFlatView(); } + /// + /// Mark a server-GUID as persistent — this entity survives landblock unloads + /// and gets re-parked as pending for its current canonical landblock. + /// + public void MarkPersistent(uint serverGuid) => _persistentGuids.Add(serverGuid); + public void RemoveLandblock(uint landblockId) { - // Drop pending entries for the same landblock — if the landblock - // is being unloaded the player has moved away from it, and any - // pending spawns that arrived for it are no longer relevant. The - // server will resend them via CreateObject when the player returns. + // Rescue persistent entities before removal. These get appended + // to the _persistentRescued list; the caller is responsible for + // re-injecting them (via AppendLiveEntity) into whatever landblock + // the player is currently on. + if (_loaded.TryGetValue(landblockId, out var lb)) + { + foreach (var entity in lb.Entities) + { + if (_persistentGuids.Contains(entity.Id)) + _persistentRescued.Add(entity); + } + } + _pendingByLandblock.Remove(landblockId); _aabbs.Remove(landblockId); @@ -125,6 +147,20 @@ public sealed class GpuWorldState RebuildFlatView(); } + private readonly List _persistentRescued = new(); + + /// + /// Drain entities rescued from unloaded landblocks. The caller should + /// re-inject each via with its current position. + /// + public List DrainRescued() + { + if (_persistentRescued.Count == 0) return _persistentRescued; + var result = new List(_persistentRescued); + _persistentRescued.Clear(); + return result; + } + /// /// Append an entity to a specific landblock's slot. Used by the live /// CreateObject path where the server spawns entities at a server-side diff --git a/src/AcDream.Core/World/SceneryGenerator.cs b/src/AcDream.Core/World/SceneryGenerator.cs index 8b034c3..5758025 100644 --- a/src/AcDream.Core/World/SceneryGenerator.cs +++ b/src/AcDream.Core/World/SceneryGenerator.cs @@ -122,6 +122,17 @@ public static class SceneryGenerator if (lx < 0 || ly < 0 || lx >= LandblockSize || ly >= LandblockSize) continue; + // Check if the final displaced position lands on a road vertex. + // The road status is per-vertex (9×9 grid); sample the nearest + // vertex to the displaced position to catch scenery that drifted + // from a non-road vertex onto a road. + { + int nearX = Math.Clamp((int)(lx / CellSize + 0.5f), 0, VerticesPerSide - 1); + int nearY = Math.Clamp((int)(ly / CellSize + 0.5f), 0, VerticesPerSide - 1); + ushort nearRaw = block.Terrain[nearX * VerticesPerSide + nearY]; + if (IsRoadVertex(nearRaw)) continue; + } + // Z at the cell corner from the heightmap. Skipping slope-based // Z placement (ACViewer uses find_terrain_poly which we don't have) // — accept that some scenery will float or clip. diff --git a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs index 643af37..530e366 100644 --- a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs +++ b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs @@ -136,7 +136,8 @@ public class PlayerMovementControllerTests controller.Update(0.05f, new MovementInput()); Assert.False(controller.IsAirborne, "Should have landed"); - Assert.Equal(50f, controller.Position.Z, precision: 1); + // +0.15 Z bias keeps feet above terrain surface (prevents z-fighting). + Assert.Equal(50.15f, controller.Position.Z, precision: 1); } [Fact] @@ -176,6 +177,6 @@ public class PlayerMovementControllerTests controller.Update(0.05f, new MovementInput(Forward: true)); Assert.False(controller.IsAirborne, "Player should have landed"); - Assert.Equal(20f, controller.Position.Z, precision: 1); + Assert.Equal(20.15f, controller.Position.Z, precision: 1); } }