fix(core+app): Phase B.3 — Setup.StepUpHeight + scenery road exclusion

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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-12 18:56:45 +02:00
parent 9dbb2cbd5c
commit 41013ce3e3
5 changed files with 71 additions and 8 deletions

View file

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

View file

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

View file

@ -48,6 +48,13 @@ public sealed class GpuWorldState
/// </summary>
private readonly Dictionary<uint, List<WorldEntity>> _pendingByLandblock = new();
/// <summary>
/// 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.
/// </summary>
private readonly HashSet<uint> _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();
}
/// <summary>
/// Mark a server-GUID as persistent — this entity survives landblock unloads
/// and gets re-parked as pending for its current canonical landblock.
/// </summary>
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<WorldEntity> _persistentRescued = new();
/// <summary>
/// Drain entities rescued from unloaded landblocks. The caller should
/// re-inject each via <see cref="AppendLiveEntity"/> with its current position.
/// </summary>
public List<WorldEntity> DrainRescued()
{
if (_persistentRescued.Count == 0) return _persistentRescued;
var result = new List<WorldEntity>(_persistentRescued);
_persistentRescued.Clear();
return result;
}
/// <summary>
/// Append an entity to a specific landblock's slot. Used by the live
/// CreateObject path where the server spawns entities at a server-side

View file

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

View file

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