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:
parent
9dbb2cbd5c
commit
41013ce3e3
5 changed files with 71 additions and 8 deletions
|
|
@ -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;
|
CellId = result.CellId;
|
||||||
|
|
||||||
// 4. Determine current motion commands.
|
// 4. Determine current motion commands.
|
||||||
|
|
|
||||||
|
|
@ -437,6 +437,7 @@ public sealed class GameWindow : IDisposable
|
||||||
|
|
||||||
var chosen = _liveSession.Characters.Characters[0];
|
var chosen = _liveSession.Characters.Characters[0];
|
||||||
_playerServerGuid = chosen.Id; // Phase B.2: store for Tab-key player-mode entry
|
_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}");
|
Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}");
|
||||||
_liveSession.EnterWorld(user, characterIndex: 0);
|
_liveSession.EnterWorld(user, characterIndex: 0);
|
||||||
Console.WriteLine($"live: in world — CreateObject stream active " +
|
Console.WriteLine($"live: in world — CreateObject stream active " +
|
||||||
|
|
@ -1004,7 +1005,10 @@ public sealed class GameWindow : IDisposable
|
||||||
entity.Rotation = rot;
|
entity.Rotation = rot;
|
||||||
_playerController.SetPosition(snappedPos, resolved.CellId);
|
_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;
|
_playerController.State = AcDream.App.Input.PlayerState.InWorld;
|
||||||
Console.WriteLine($"live: teleport complete — snapped to {snappedPos} cell=0x{resolved.CellId:X8}");
|
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);
|
_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
|
// Drain pending live-session traffic AFTER streaming so any incoming
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,13 @@ public sealed class GpuWorldState
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly Dictionary<uint, List<WorldEntity>> _pendingByLandblock = new();
|
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,
|
// Cached flat view over all entities across all loaded landblocks,
|
||||||
// rebuilt on each add/remove. The renderer holds a reference to this
|
// rebuilt on each add/remove. The renderer holds a reference to this
|
||||||
// list, so rebuilding it replaces the reference atomically.
|
// list, so rebuilding it replaces the reference atomically.
|
||||||
|
|
@ -112,12 +119,27 @@ public sealed class GpuWorldState
|
||||||
RebuildFlatView();
|
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)
|
public void RemoveLandblock(uint landblockId)
|
||||||
{
|
{
|
||||||
// Drop pending entries for the same landblock — if the landblock
|
// Rescue persistent entities before removal. These get appended
|
||||||
// is being unloaded the player has moved away from it, and any
|
// to the _persistentRescued list; the caller is responsible for
|
||||||
// pending spawns that arrived for it are no longer relevant. The
|
// re-injecting them (via AppendLiveEntity) into whatever landblock
|
||||||
// server will resend them via CreateObject when the player returns.
|
// 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);
|
_pendingByLandblock.Remove(landblockId);
|
||||||
_aabbs.Remove(landblockId);
|
_aabbs.Remove(landblockId);
|
||||||
|
|
||||||
|
|
@ -125,6 +147,20 @@ public sealed class GpuWorldState
|
||||||
RebuildFlatView();
|
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>
|
/// <summary>
|
||||||
/// Append an entity to a specific landblock's slot. Used by the live
|
/// Append an entity to a specific landblock's slot. Used by the live
|
||||||
/// CreateObject path where the server spawns entities at a server-side
|
/// CreateObject path where the server spawns entities at a server-side
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,17 @@ public static class SceneryGenerator
|
||||||
if (lx < 0 || ly < 0 || lx >= LandblockSize || ly >= LandblockSize)
|
if (lx < 0 || ly < 0 || lx >= LandblockSize || ly >= LandblockSize)
|
||||||
continue;
|
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 at the cell corner from the heightmap. Skipping slope-based
|
||||||
// Z placement (ACViewer uses find_terrain_poly which we don't have)
|
// Z placement (ACViewer uses find_terrain_poly which we don't have)
|
||||||
// — accept that some scenery will float or clip.
|
// — accept that some scenery will float or clip.
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,8 @@ public class PlayerMovementControllerTests
|
||||||
controller.Update(0.05f, new MovementInput());
|
controller.Update(0.05f, new MovementInput());
|
||||||
|
|
||||||
Assert.False(controller.IsAirborne, "Should have landed");
|
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]
|
[Fact]
|
||||||
|
|
@ -176,6 +177,6 @@ public class PlayerMovementControllerTests
|
||||||
controller.Update(0.05f, new MovementInput(Forward: true));
|
controller.Update(0.05f, new MovementInput(Forward: true));
|
||||||
|
|
||||||
Assert.False(controller.IsAirborne, "Player should have landed");
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue