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;
|
||||
|
||||
// 4. Determine current motion commands.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue