docs: phase 2b design — atlas textures, neighbors, dual cameras, plugin api
Locks the four decisions from brainstorming: full scope (all 8 sketched tasks in one Phase 2b), GL_TEXTURE_2D_ARRAY for terrain atlas with a per-vertex flat uint layer attribute, raw cursor capture FlyCamera with F toggle and Escape release, and WorldEvents.EntitySpawned with replay-on-subscribe so plugin ordering doesn't matter. Grows AcDream.Plugin.Abstractions by IGameState + IEvents + WorldEntitySnapshot. Host-side WorldGameState + WorldEvents implementations live in AcDream.App.Plugins. AppPluginHost constructor gains two parameters. Program.cs wiring order keeps Phase 2a's Enable-before-Run, relying on replay-on-subscribe to make subscription-before-world-load produce the right observable behavior. Task 1 expands Vertex struct and LandblockMesh signature — this affects StaticMeshRenderer's vertex stride too, so Phase 2a's shader bindings need a matching update. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cc55c3f812
commit
f61f356145
1 changed files with 448 additions and 0 deletions
448
docs/plans/2026-04-10-phase-2b-design.md
Normal file
448
docs/plans/2026-04-10-phase-2b-design.md
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
# acdream Phase 2b design: terrain atlas, neighbor landblocks, dual cameras, plugin API growth
|
||||
|
||||
**Date:** 2026-04-10
|
||||
**Status:** Approved — ready for implementation planning.
|
||||
**Builds on:** Phase 2a (merged as `1d1e668` + fix-up `cc55c3f` on `main`).
|
||||
|
||||
## Goal
|
||||
|
||||
Turn Phase 2a's textured-but-single Holtburg landblock into a 3×3 neighbor grid rendered with real terrain textures from the retail dats, explorable with a first-person fly camera (F to toggle), and expose the loaded world entity list to plugins via a minimal but complete `IGameState` + `IEvents` surface that a plugin subscribing late still sees every entity exactly once.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Lighting on terrain or meshes. Flat shading only.
|
||||
- Terrain texture blending at cell boundaries. Sharp edges between grass and dirt are acceptable; the `flat` interpolation qualifier on the terrain layer attribute enforces this.
|
||||
- Frustum culling. 9 landblocks × 126 static entities is small enough the GPU doesn't care.
|
||||
- Collision for the fly camera. Walking through walls is expected.
|
||||
- Dynamic entity spawns. All entities are loaded once at world load; `IGameState.Entities` is built once and doesn't mutate.
|
||||
- Animated doors / windows / NPCs. Phase 3+ concern.
|
||||
- Loading more than one 3×3 grid. The center stays hardcoded at Holtburg 0xA9B4FFFF.
|
||||
|
||||
## Locked decisions (from brainstorming)
|
||||
|
||||
1. **Full scope:** all 8 sketched Phase 2b tasks (11–18 in the original Phase 2 plan) in one session.
|
||||
2. **`GL_TEXTURE_2D_ARRAY`** for terrain textures. Per-vertex `aTerrainLayer` attribute indexes the layer.
|
||||
3. **Raw cursor capture** for `FlyCamera` (`CursorMode.Raw`), WASD + Space/Ctrl movement, F toggles between orbit and fly, Escape releases cursor (fly → orbit; orbit → close window).
|
||||
4. **EntitySpawned fires during world load**, `WorldEvents` implements **replay-on-subscribe** so a plugin subscribing in `Enable()` sees every already-spawned entity before returning.
|
||||
|
||||
## Architecture overview
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ acdream client │
|
||||
│ │
|
||||
│ dat directory │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ AcDream.Core │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
|
||||
│ │ │ Terrain (P2a │ │ World (P2a) │ │ Textures(P2a)│ │ │
|
||||
│ │ │ updated) │ │ │ │ │ │ │
|
||||
│ │ │ │ │ WorldView │ │ SurfaceDec. │ │ │
|
||||
│ │ │ Vertex gains │ │ LandblockLdr │ │ (no change) │ │ │
|
||||
│ │ │ TerrainLayer │ │ WorldEntity │ │ │ │ │
|
||||
│ │ │ LandblockMesh│ │ │ │ │ │ │
|
||||
│ │ │ takes layer │ │ │ │ │ │ │
|
||||
│ │ │ map │ │ │ │ │ │ │
|
||||
│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │
|
||||
│ └──────────────┬─────────────┬─────────────────┬────────────────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ AcDream.App / Rendering │ │
|
||||
│ │ │ │
|
||||
│ │ TerrainRenderer(updated) StaticMeshRenderer (no change) │ │
|
||||
│ │ ↳ per-landblock uModel │ │
|
||||
│ │ ↳ samples sampler2DArray │ │
|
||||
│ │ │ │
|
||||
│ │ TerrainAtlas(new) │ │
|
||||
│ │ ↳ builds GL_TEXTURE_2D_ARRAY at load │ │
|
||||
│ │ ↳ Dictionary<byte terrainType, uint layer> │ │
|
||||
│ │ │ │
|
||||
│ │ Shader: terrain.vert/frag (rewritten) │ │
|
||||
│ │ ↳ new attribute: flat uint aTerrainLayer │ │
|
||||
│ │ ↳ frag: texture(uAtlas, vec3(uv, float(vLayer))) │ │
|
||||
│ │ │ │
|
||||
│ │ ICamera(new) │ │
|
||||
│ │ ├── OrbitCamera (refactored: implements ICamera) │ │
|
||||
│ │ └── FlyCamera (new: WASD+mouse, raw cursor) │ │
|
||||
│ │ CameraController (F toggle, Escape contextual) │ │
|
||||
│ └────────────────┬─────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌────────────────────────────────────────────────────────┐ │
|
||||
│ │ Plugin pipeline — Phase 2b growth │ │
|
||||
│ │ │ │
|
||||
│ │ abstractions: │ │
|
||||
│ │ IGameState.Entities (snapshot, immutable) │ │
|
||||
│ │ IEvents.EntitySpawned (fires+replays on subscribe) │ │
|
||||
│ │ WorldEntitySnapshot (record struct) │ │
|
||||
│ │ │ │
|
||||
│ │ app-side: │ │
|
||||
│ │ WorldGameState : IGameState │ │
|
||||
│ │ WorldEvents : IEvents + FireEntitySpawned() │ │
|
||||
│ │ AppPluginHost : Log + State + Events │ │
|
||||
│ │ │ │
|
||||
│ │ SmokePlugin subscribes in Enable(), logs replay count │ │
|
||||
│ └────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key principles:**
|
||||
|
||||
1. Phase 2a's architecture stays intact. The only Phase 2a change is `LandblockMesh.Build` emitting a per-vertex terrain type index alongside position/normal/uv, plus `Vertex` growing a `TerrainLayer` field. `StaticMeshRenderer` inherits the new vertex stride for free because the mesh shader ignores `TerrainLayer` for static meshes (layer=0 by convention).
|
||||
2. `WorldView` becomes the entry point for terrain in `GameWindow`. Returns 9 landblocks + their entities.
|
||||
3. Neighbor translation is done via a `uModel` uniform per landblock, not baked into vertex positions. Keeps mesh data compact and shareable.
|
||||
4. Terrain atlas built once at load from the unique set of terrain type bytes seen in any loaded landblock. Cheap to compute (729 checks).
|
||||
5. `ICamera` is a pure interface. Both cameras implement it. `CameraController` owns both and exposes the active one.
|
||||
6. `WorldGameState.Entities` is a frozen `IReadOnlyList<WorldEntitySnapshot>` built once at world load.
|
||||
7. `WorldEvents` provides **replay-on-subscribe** so plugin ordering doesn't matter — any plugin that subscribes at any time sees every entity exactly once.
|
||||
|
||||
## Terrain atlas + updated mesh
|
||||
|
||||
### Terrain-type resolution
|
||||
|
||||
`LandBlock.Terrain[]` is 81 `TerrainInfo` ushorts. The low bits encode terrain type as an enum (grass, dirt, forest, snow, etc., typically 0..31). For Phase 2b we only use the low 5 bits:
|
||||
|
||||
```csharp
|
||||
byte terrainType = (byte)((ushort)block.Terrain[i] & 0x1F);
|
||||
```
|
||||
|
||||
The mapping from terrain type to a texture `SurfaceTexture` id lives in `Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc` — a list where each entry maps a terrain type enum to a `QualifiedDataId<SurfaceTexture>`. At load time we walk this list to build a `Dictionary<byte, uint>` of `terrainType → SurfaceTextureId`, then resolve each surface texture → RenderSurface → decoded RGBA8 → layer in the GL array texture.
|
||||
|
||||
(If the exact DatReaderWriter field path differs from the above at implementation time, the spec is wrong on the path; the algorithm is correct. Implementer should follow the real type shape.)
|
||||
|
||||
### `Vertex` struct grows
|
||||
|
||||
```csharp
|
||||
public readonly record struct Vertex(
|
||||
Vector3 Position,
|
||||
Vector3 Normal,
|
||||
Vector2 TexCoord,
|
||||
uint TerrainLayer);
|
||||
```
|
||||
|
||||
Stride becomes 36 bytes. Both `TerrainRenderer` and `StaticMeshRenderer` update their `VertexAttribPointer` calls to include the new attribute at location 3. For static meshes, `GfxObjMesh.Build` writes `TerrainLayer = 0` (the value is ignored by the mesh shader). For terrain, `LandblockMesh.Build` writes the per-vertex atlas layer index.
|
||||
|
||||
### `LandblockMesh.Build` signature grows
|
||||
|
||||
```csharp
|
||||
public static LandblockMeshData Build(
|
||||
LandBlock block,
|
||||
float[] heightTable,
|
||||
IReadOnlyDictionary<byte, uint> terrainTypeToLayer);
|
||||
```
|
||||
|
||||
The third parameter maps raw terrain-type bytes (extracted from `block.Terrain[i]` low 5 bits) to atlas layer indices. If a terrain type isn't in the map, the mesh uses layer 0 as a fallback.
|
||||
|
||||
The x-major heightmap indexing fix from `cc55c3f` remains.
|
||||
|
||||
UV scaling changes: Phase 1 used `TexCoord = (x, y) / 8` (one texture stretched across the landblock). Phase 2b uses `TexCoord = (x, y) / 2` so one terrain texture tiles ~4× per landblock, which looks more natural at typical camera distances.
|
||||
|
||||
### `TerrainAtlas` class
|
||||
|
||||
```csharp
|
||||
public sealed class TerrainAtlas : IDisposable
|
||||
{
|
||||
public uint GlTexture { get; }
|
||||
public IReadOnlyDictionary<byte, uint> TerrainTypeToLayer { get; }
|
||||
public int LayerCount { get; }
|
||||
|
||||
public static TerrainAtlas Build(
|
||||
GL gl,
|
||||
DatCollection dats,
|
||||
IEnumerable<LandBlock> loadedLandblocks);
|
||||
|
||||
public void Dispose();
|
||||
}
|
||||
```
|
||||
|
||||
`Build` walks all vertices of all loaded landblocks to collect the unique terrain types, resolves each to a `SurfaceTexture` → `RenderSurface` → decoded RGBA8 via `SurfaceDecoder`, allocates a GL_TEXTURE_2D_ARRAY of the max (width, height) at layer 0 and uploads each decoded texture to its own layer. Returns the handle and the layer map.
|
||||
|
||||
### Shader updates
|
||||
|
||||
`terrain.vert`:
|
||||
|
||||
```glsl
|
||||
#version 430 core
|
||||
layout(location = 0) in vec3 aPos;
|
||||
layout(location = 1) in vec3 aNormal;
|
||||
layout(location = 2) in vec2 aTex;
|
||||
layout(location = 3) in uint aTerrainLayer;
|
||||
|
||||
uniform mat4 uModel;
|
||||
uniform mat4 uView;
|
||||
uniform mat4 uProjection;
|
||||
|
||||
out vec2 vTex;
|
||||
out flat uint vLayer;
|
||||
|
||||
void main() {
|
||||
vTex = aTex;
|
||||
vLayer = aTerrainLayer;
|
||||
gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0);
|
||||
}
|
||||
```
|
||||
|
||||
`terrain.frag`:
|
||||
|
||||
```glsl
|
||||
#version 430 core
|
||||
in vec2 vTex;
|
||||
in flat uint vLayer;
|
||||
out vec4 fragColor;
|
||||
|
||||
uniform sampler2DArray uAtlas;
|
||||
|
||||
void main() {
|
||||
fragColor = texture(uAtlas, vec3(vTex, float(vLayer)));
|
||||
}
|
||||
```
|
||||
|
||||
Integer vertex attributes must be `flat`-interpolated in GLSL, so boundaries between cells with different terrain types are sharp. Blending is Phase 3+.
|
||||
|
||||
### `TerrainRenderer` updates
|
||||
|
||||
Now owns a `TerrainAtlas` reference and a list of `(LandblockMeshData mesh, Vector3 worldOrigin)` pairs (one per loaded landblock). `Draw(camera)` binds the atlas once, then for each landblock sets `uModel = Translation(worldOrigin)`, binds that landblock's VAO, and issues the draw call.
|
||||
|
||||
Neighbor landblock world origin calc: for the center at `(cx, cy)`, a neighbor at `(cx + dx, cy + dy)` has origin `(dx * 192, dy * 192, 0)` in world space (the center landblock is at the origin). The orbit camera's default target stays at `(96, 96, 0)` which is the center of the center landblock.
|
||||
|
||||
## Dual cameras
|
||||
|
||||
### `ICamera` interface
|
||||
|
||||
```csharp
|
||||
public interface ICamera
|
||||
{
|
||||
Matrix4x4 View { get; }
|
||||
Matrix4x4 Projection { get; }
|
||||
float Aspect { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### `OrbitCamera` refactor
|
||||
|
||||
Existing class gains `: ICamera`. Zero behavioral change. `Aspect` already has a setter. All Phase 1/2 tests still pass.
|
||||
|
||||
### `FlyCamera`
|
||||
|
||||
```csharp
|
||||
public sealed class FlyCamera : ICamera
|
||||
{
|
||||
public Vector3 Position { get; set; } = new(96, 96, 150);
|
||||
public float Yaw { get; set; } = MathF.PI / 2;
|
||||
public float Pitch { get; set; } = -0.3f;
|
||||
public float FovY { get; set; } = MathF.PI / 3f;
|
||||
public float Aspect { get; set; } = 16f / 9f;
|
||||
|
||||
public float MoveSpeed { get; set; } = 100f;
|
||||
public float MouseSensitivity { get; set; } = 0.003f;
|
||||
|
||||
public Matrix4x4 View { get; }
|
||||
public Matrix4x4 Projection { get; }
|
||||
|
||||
public void Update(double dt, bool w, bool a, bool s, bool d, bool up, bool down);
|
||||
public void Look(float deltaX, float deltaY);
|
||||
}
|
||||
```
|
||||
|
||||
`View` is computed from `Position`, `Yaw`, `Pitch` using `CreateLookAt`. `Projection` is a perspective matrix. `Update` integrates position in the horizontal plane relative to yaw, with space (up) and ctrl (down) adding vertical. `Look` adds to yaw/pitch, clamps pitch to `±89°`.
|
||||
|
||||
### `CameraController`
|
||||
|
||||
```csharp
|
||||
public sealed class CameraController
|
||||
{
|
||||
public OrbitCamera Orbit { get; }
|
||||
public FlyCamera Fly { get; }
|
||||
public ICamera Active { get; private set; }
|
||||
public bool IsFlyMode => Active == Fly;
|
||||
|
||||
public event Action<bool>? ModeChanged;
|
||||
|
||||
public void ToggleFly();
|
||||
public void SetAspect(float aspect);
|
||||
}
|
||||
```
|
||||
|
||||
`ToggleFly` flips `Active` and fires `ModeChanged(IsFlyMode)`. `SetAspect` updates both cameras.
|
||||
|
||||
### `GameWindow` input wiring
|
||||
|
||||
- `_camera` field replaced with `_cameraController`.
|
||||
- `OnLoad` creates the controller, registers a `ModeChanged` handler that toggles cursor mode:
|
||||
- Entering fly → `mouse.Cursor.CursorMode = CursorMode.Raw`
|
||||
- Leaving fly → `CursorMode.Normal`
|
||||
- `Key.F` → `_cameraController.ToggleFly()`
|
||||
- `Key.Escape` — in orbit mode, closes window (existing); in fly mode, calls `ToggleFly()` to return to orbit (and releases cursor).
|
||||
- `mouse.MouseMove` — if orbit, existing orbit drag-look. If fly, route deltas (`pos - lastPos`) to `_cameraController.Fly.Look(...)`.
|
||||
- `mouse.Scroll` — if orbit, existing zoom. If fly, ignored for Phase 2b.
|
||||
- **New `OnUpdate` handler** — Silk.NET's `IWindow` raises `Update += double dt` once per tick (distinct from `Render`). `GameWindow` registers `_window.Update += OnUpdate`. Inside `OnUpdate`, if `_cameraController.IsFlyMode`, read the current state of W/A/S/D/Space/Ctrl from `_input.Keyboards[0].IsKeyPressed(Key.X)` and call `_cameraController.Fly.Update(dt, ...)`. This separates per-frame simulation (Update) from per-frame drawing (Render) in the standard way.
|
||||
- `OnRender` passes `_cameraController.Active` to both renderers.
|
||||
|
||||
## Plugin API growth
|
||||
|
||||
### New types in `AcDream.Plugin.Abstractions`
|
||||
|
||||
**`WorldEntitySnapshot`** — `readonly record struct` with `Id`, `SourceId`, `Position: Vector3`, `Rotation: Quaternion`. No `Scale` (Phase 2a decision — AC `Frame` carries no scale).
|
||||
|
||||
**`IGameState`** — interface with one member: `IReadOnlyList<WorldEntitySnapshot> Entities`. Phase 2b populates this list once at world load; it never mutates after.
|
||||
|
||||
**`IEvents`** — interface with one event: `event Action<WorldEntitySnapshot> EntitySpawned`. Fires once per entity during hydration. **Replay-on-subscribe:** any new handler added via `+=` is immediately invoked for every already-spawned entity before `+=` returns.
|
||||
|
||||
**`IPluginHost`** — gains two properties: `IGameState State { get; }` and `IEvents Events { get; }`.
|
||||
|
||||
### Host-side implementations in `AcDream.App.Plugins`
|
||||
|
||||
**`WorldGameState : IGameState`** — sealed class. Constructor takes `IReadOnlyList<WorldEntitySnapshot> entities`. `Entities` property returns that list.
|
||||
|
||||
**`WorldEvents : IEvents`** — sealed class with:
|
||||
- `FireEntitySpawned(snapshot)` — internal method called by the host during hydration. Adds to `_alreadySpawned`, notifies current subscribers.
|
||||
- `EntitySpawned` event — the `add` accessor takes a lock, snapshots `_alreadySpawned` into a local array, adds to the subscriber list, releases the lock, then invokes the new handler for each entity in the snapshot array (outside the lock to prevent deadlock).
|
||||
- Exceptions in handler invocation during replay are swallowed so one broken plugin's subscribe can't poison the rest.
|
||||
|
||||
**`AppPluginHost`** — sealed class implementing `IPluginHost`. Constructor takes `IPluginLogger`, `IGameState`, `IEvents`. Exposes all three as properties.
|
||||
|
||||
### `Program.cs` wiring order
|
||||
|
||||
```
|
||||
1. Serilog init
|
||||
2. Parse dat dir argument
|
||||
3. Create WorldEvents (empty; will be populated during load)
|
||||
4. Create a mutable list for entity snapshots; create WorldGameState wrapping it
|
||||
5. Create AppPluginHost(log, state, events)
|
||||
6. Scan plugins directory
|
||||
7. For each plugin result: PluginLoader.Load(..., host) — this calls Initialize
|
||||
8. For each loaded plugin: Enable() — plugins subscribe here; replay-on-subscribe
|
||||
handles the fact that no entities exist yet at this point (empty replay is a no-op)
|
||||
9. Build GameWindow with datDir + worldEvents + the entity-snapshot list
|
||||
10. window.Run() — OnLoad hydrates entities. For each hydrated entity:
|
||||
a. Add a WorldEntitySnapshot to the shared list (which WorldGameState exposes)
|
||||
b. Call worldEvents.FireEntitySpawned(snapshot)
|
||||
The live fire reaches plugins that subscribed during step 8.
|
||||
11. When window closes: foreach plugin Disable()
|
||||
```
|
||||
|
||||
Note that Phase 2a's Enable-then-Run ordering is preserved. The replay-on-subscribe mechanism makes it work correctly regardless: when a plugin subscribes in Enable, zero entities have been hydrated yet, so the replay is empty, and the 126 live fires during OnLoad reach the subscriber directly.
|
||||
|
||||
### `SmokePlugin` updated
|
||||
|
||||
```csharp
|
||||
public sealed class SmokePlugin : IAcDreamPlugin
|
||||
{
|
||||
private IPluginHost? _host;
|
||||
private int _entitiesSeen;
|
||||
|
||||
public void Initialize(IPluginHost host)
|
||||
{
|
||||
_host = host;
|
||||
_host.Log.Info("smoke plugin initialized");
|
||||
}
|
||||
|
||||
public void Enable()
|
||||
{
|
||||
_host!.Log.Info("smoke plugin enabled");
|
||||
_host.Events.EntitySpawned += OnEntitySpawned;
|
||||
_host.Log.Info($"smoke plugin sees {_entitiesSeen} entities (replay count)");
|
||||
}
|
||||
|
||||
public void Disable()
|
||||
{
|
||||
if (_host is not null)
|
||||
_host.Events.EntitySpawned -= OnEntitySpawned;
|
||||
_host?.Log.Info("smoke plugin disabled");
|
||||
}
|
||||
|
||||
private void OnEntitySpawned(WorldEntitySnapshot entity) => _entitiesSeen++;
|
||||
}
|
||||
```
|
||||
|
||||
**Expected console output at end of Phase 2b:**
|
||||
|
||||
```
|
||||
[INF] scanning plugins in ...\plugins
|
||||
[INF] smoke plugin initialized
|
||||
[INF] loaded plugin acdream.smoke (Smoke Plugin)
|
||||
[INF] smoke plugin enabled
|
||||
[INF] smoke plugin sees 0 entities (replay count) ← at subscribe time, world is empty
|
||||
loaded landblock 0xA9B4FFFF
|
||||
hydrated 126 entities on landblock 0xA9B4FFFF
|
||||
(window opens with textured 3x3 Holtburg, orbit camera active)
|
||||
(user presses F, cursor captures, WASD flies through world)
|
||||
(user presses F or Escape, cursor releases, back to orbit)
|
||||
(user closes window)
|
||||
[INF] smoke plugin disabled
|
||||
```
|
||||
|
||||
(If we wanted the replay count to show 126, we'd reorder `Enable()` to fire after world load. Phase 2a's ordering keeps it at 0 — that's fine because the live fires still reach the subscriber, so `_entitiesSeen` will have incremented to 126 by the time the smoke plugin logs `disabled`. We could log it again in `Disable` for visible proof.)
|
||||
|
||||
Actually — the spec should be explicit about this. **`SmokePlugin.Disable()` logs the final count** so we can see the event stream worked:
|
||||
|
||||
```csharp
|
||||
public void Disable()
|
||||
{
|
||||
if (_host is not null)
|
||||
_host.Events.EntitySpawned -= OnEntitySpawned;
|
||||
_host?.Log.Info($"smoke plugin disabled (saw {_entitiesSeen} entities total)");
|
||||
}
|
||||
```
|
||||
|
||||
Expected Phase 2b end-of-session output at disable time: `smoke plugin disabled (saw 126 entities total)`.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
**TDD (pure CPU, xUnit):**
|
||||
|
||||
- `LandblockMesh.Build` — updated tests for the new `terrainTypeToLayer` parameter. At least one new test asserting the per-vertex `TerrainLayer` value matches the map.
|
||||
- `FlyCamera` — position integration (WASD moves in the right direction relative to yaw), pitch clamping, view matrix identity when at origin looking down +Y.
|
||||
- `WorldEvents` — `FireEntitySpawned` before any subscriber has zero observable effect but later subscribers get a replay. Subscribing twice causes two replays. Subscribing, firing, unsubscribing, firing — second fire doesn't reach the removed handler. Exceptions in a handler don't propagate out of `+=` or `FireEntitySpawned`.
|
||||
|
||||
**Manual smoke (real dats, visual):**
|
||||
|
||||
- Textured terrain — does Holtburg show real green/dirt/road textures instead of the height ramp?
|
||||
- 3×3 neighbors — does the horizon no longer cliff? Are all 9 landblocks visible as you orbit out?
|
||||
- FlyCamera — does F toggle work? WASD + mouse look feel correct? Escape release?
|
||||
- SmokePlugin — does the console show `saw 126 entities total` on shutdown?
|
||||
|
||||
**Expected test count at end of Phase 2b:** ~50-55 (42 carried from Phase 2a + 8-13 new).
|
||||
|
||||
## Error handling & failure modes
|
||||
|
||||
**Dat-level:**
|
||||
- `Region.LandDefs.LandHeightTable` missing → throw at load (Phase 2a behavior unchanged).
|
||||
- `Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc` missing → log warning, use a single "white" layer 0 as fallback and all terrain renders white.
|
||||
- A terrain type byte not in the map → use layer 0 (white).
|
||||
- A `SurfaceTexture` → `RenderSurface` chain break for a terrain type → that layer uploads as magenta; terrain of that type will be visibly wrong (diagnostic, not a crash).
|
||||
|
||||
**GL-level:**
|
||||
- Shader compile fails on the new integer attribute → throw at load (Phase 1 behavior).
|
||||
- `GL_TEXTURE_2D_ARRAY` allocation fails for implausible layer counts → throw at load.
|
||||
- Mouse capture fails on some driver → log error, stay in orbit mode, ignore `F` key.
|
||||
|
||||
**Plugin-level:**
|
||||
- Replay-on-subscribe handler throws → caught in `WorldEvents.EntitySpawned` add accessor, logged via host logger, replay continues to the next snapshot. Plugin is NOT marked faulted for handler errors.
|
||||
- Live `FireEntitySpawned` handler throws → caught in `WorldEvents.FireEntitySpawned`, logged, dispatch continues to the next subscriber.
|
||||
|
||||
## Task sequence (rough, full plan comes from writing-plans)
|
||||
|
||||
1. **Expand `Vertex` struct + `LandblockMesh.Build` signature** — add `TerrainLayer` field, take `terrainTypeToLayer` map. Update Phase 1 tests. New test for per-vertex layer mapping.
|
||||
2. **`TerrainAtlas` class** — collects unique terrain types from loaded landblocks, resolves via `Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc`, decodes via `SurfaceDecoder`, uploads as `GL_TEXTURE_2D_ARRAY`. Manual smoke.
|
||||
3. **Rewrite `terrain.vert/frag`** — new vertex attribute, `sampler2DArray`, `flat uint vLayer`, per-landblock `uModel`. Update `TerrainRenderer` to bind the atlas and draw per-landblock with model matrix. Manual smoke (single landblock still works).
|
||||
4. **Wire `WorldView.Load` + neighbor rendering in `GameWindow`** — replace single-landblock load with the 9-landblock view. Per-landblock world origin. Manual smoke (horizon stops cliffing).
|
||||
5. **`ICamera` interface + `OrbitCamera` refactor** — pure interface, zero behavioral change. Update `TerrainRenderer.Draw` and `StaticMeshRenderer.Draw` signatures.
|
||||
6. **`FlyCamera`** — new class with Update/Look/View/Projection. TDD the math.
|
||||
7. **`CameraController` + `GameWindow` input wiring** — F toggle, Escape contextual, cursor mode changes, WASD per-frame update hook. Manual smoke.
|
||||
8. **`IGameState` + `IEvents` + `WorldEntitySnapshot` in abstractions** — pure types, no tests needed for contracts alone. `WorldGameState` + `WorldEvents` in `AcDream.App.Plugins`. `WorldEvents` replay-on-subscribe is TDD'd.
|
||||
9. **`AppPluginHost` constructor update + `Program.cs` wiring + `SmokePlugin` subscribes** — end-to-end smoke. Console shows `smoke plugin disabled (saw 126 entities total)`.
|
||||
|
||||
**Stopping point:** Task 9 is the end of Phase 2b.
|
||||
|
||||
## Open questions (deferred, not blocking)
|
||||
|
||||
- Does the `Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc` path in DatReaderWriter match the field shape above? If not, the implementer should match the real type at Task 2 and note it. Algorithm is unchanged either way.
|
||||
- Should `CameraController` also bind a number key (1/2) as hard switches instead of a F toggle? Deferred; F-toggle is fine for Phase 2b.
|
||||
- Should `WorldEvents.EntitySpawned` become `EntityAdded`+`EntityRemoved` in Phase 2c when dynamic spawns matter? Yes, probably, but Phase 2b doesn't need `Removed` since there are no despawns.
|
||||
- Terrain texture blending between adjacent cells — Phase 3+.
|
||||
- `AcDream.Cli` retirement — still deferred from Phase 1. Not Phase 2b.
|
||||
Loading…
Add table
Add a link
Reference in a new issue