diff --git a/Theriapolis.Godot/GameSession.cs b/Theriapolis.Godot/GameSession.cs new file mode 100644 index 0000000..11b0f1d --- /dev/null +++ b/Theriapolis.Godot/GameSession.cs @@ -0,0 +1,55 @@ +using Godot; +using Theriapolis.Core.Persistence; +using Theriapolis.Core.Rules.Character; +using Theriapolis.Core.World.Generation; + +namespace Theriapolis.GodotHost; + +/// +/// Autoload singleton. Holds the cross-scene state that outlives any +/// single screen: the world seed and (post-worldgen) WorldGenContext, +/// the pending character from the M6 wizard hand-off, and the pending +/// save snapshot from the SaveLoadScreen load hand-off. +/// +/// Per port-plan §M7 §4.3: TitleScreen + Wizard + SaveLoadScreen write +/// pending fields; WorldGenProgressScreen + PlayScreen consume them and +/// clear them. +/// +/// Registered in project.godot under [autoload]; reachable +/// from any scene via . +/// +public partial class GameSession : Node +{ + /// World seed for the next worldgen run. Set by TitleScreen + /// (new game) or by SaveLoadScreen (from the loaded header). + public ulong Seed { get; set; } + + /// Set by WorldGenProgressScreen on completion; consumed by + /// PlayScreen during _Ready. + public WorldGenContext? Ctx { get; set; } + + /// Set by the Wizard hand-off (M6 → M7.1). PlayScreen + /// attaches this to the spawned player actor and clears the field. + public Character? PendingCharacter { get; set; } + public string PendingName { get; set; } = "Wanderer"; + + /// Set by SaveLoadScreen when the player picks a slot. + /// PlayScreen consumes via ApplyRestoredBody in _Ready. + public SaveBody? PendingRestore { get; set; } + public SaveHeader? PendingHeader { get; set; } + + /// Convenience accessor — any node can grab the session via + /// GameSession.From(this) without hard-coding the autoload path. + public static GameSession From(Node anyNode) + => anyNode.GetNode("/root/GameSession"); + + /// Drop the per-run pending fields. Called on quit-to-title + /// so a fresh "New Character" run doesn't see stale handoff data. + public void ClearPending() + { + PendingCharacter = null; + PendingName = "Wanderer"; + PendingRestore = null; + PendingHeader = null; + } +} diff --git a/Theriapolis.Godot/Input/PlayerController.cs b/Theriapolis.Godot/Input/PlayerController.cs new file mode 100644 index 0000000..5d5f176 --- /dev/null +++ b/Theriapolis.Godot/Input/PlayerController.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Generic; +using Theriapolis.Core; +using Theriapolis.Core.Entities; +using Theriapolis.Core.Time; +using Theriapolis.Core.Util; +using Theriapolis.Core.World; + +namespace Theriapolis.GodotHost.Input; + +/// +/// Drives the player. World-map mode: click a destination, A* the path +/// (via ), and animate the player along +/// it while the WorldClock advances. Tactical mode: WASD step with +/// axis-separated motion (wall-sliding) and encumbrance-aware speed. +/// +/// Direct logic port of Theriapolis.Game/Input/PlayerController.cs; +/// the Godot version takes pre-resolved dx/dy from the +/// screen instead of poking InputManager + Camera2D +/// (MonoGame types). Save-format determinism is unaffected — the only +/// output that round-trips through saves is +/// and , both of which are advanced +/// by identical arithmetic in both ports. +/// +public sealed class PlayerController +{ + private readonly PlayerActor _player; + private readonly WorldState _world; + private readonly WorldClock _clock; + private readonly WorldTravelPlanner _planner; + + /// Optional callback installed by the screen once tactical + /// streaming is up. Returns whether the given tactical-tile coord + /// is walkable. + public Func? TacticalIsWalkable { get; set; } + + private List<(int X, int Y)>? _path; + private int _pathIndex; + + // Sub-second carry for the world clock — tactical motion is continuous, + // so a single frame may advance fewer than one in-game second; without + // this carry, slow movement would never tick the clock past 0. + private float _tacticalClockCarry; + + public bool IsTraveling => _path is not null && _pathIndex < _path.Count; + + public PlayerController(PlayerActor player, WorldState world, WorldClock clock) + { + _player = player; + _world = world; + _clock = clock; + _planner = new WorldTravelPlanner(world); + } + + public void CancelTravel() + { + _path = null; + _pathIndex = 0; + } + + /// Queue a click destination as a new travel plan. Returns + /// true if a path was found. + public bool RequestTravelTo(int tileX, int tileY) + { + int sx = (int)MathF.Floor(_player.Position.X / C.WORLD_TILE_PIXELS); + int sy = (int)MathF.Floor(_player.Position.Y / C.WORLD_TILE_PIXELS); + sx = Math.Clamp(sx, 0, C.WORLD_WIDTH_TILES - 1); + sy = Math.Clamp(sy, 0, C.WORLD_HEIGHT_TILES - 1); + + var path = _planner.PlanTilePath(sx, sy, tileX, tileY); + if (path is null || path.Count < 2) return false; + _path = path; + _pathIndex = 1; + return true; + } + + /// Per-frame tick. / + /// are the pre-resolved input direction (e.g. -1/0/+1 each, from WASD); + /// ignored when is false. The screen + /// is responsible for deciding tactical vs. world-map based on camera + /// zoom and for gating input when the window isn't focused. + public void Update(float dt, float dx, float dy, bool isTacticalMode, bool isFocused) + { + if (!isTacticalMode) + UpdateWorldMap(dt); + else + UpdateTactical(dt, dx, dy, isFocused); + } + + private void UpdateWorldMap(float dt) + { + if (_path is null) return; + if (_pathIndex >= _path.Count) { _path = null; return; } + + var (tx, ty) = _path[_pathIndex]; + var target = WorldTravelPlanner.TileCenterToWorldPixel(tx, ty); + var curPos = _player.Position; + var diff = target - curPos; + float dist = diff.Length; + float move = _player.SpeedWorldPxPerSec * dt; + + if (move >= dist) + { + int prevTileX = (int)MathF.Floor(curPos.X / C.WORLD_TILE_PIXELS); + int prevTileY = (int)MathF.Floor(curPos.Y / C.WORLD_TILE_PIXELS); + _player.Position = target; + if (dist > 1e-3f) _player.FacingAngleRad = MathF.Atan2(diff.Y, diff.X); + float legSeconds = _planner.EstimateSecondsForLeg(prevTileX, prevTileY, tx, ty); + _clock.Advance((long)MathF.Round(legSeconds)); + _pathIndex++; + if (_pathIndex >= _path.Count) _path = null; + } + else + { + var step = diff.Normalized * move; + _player.Position = curPos + step; + _player.FacingAngleRad = MathF.Atan2(diff.Y, diff.X); + ref var dst = ref _world.TileAt(tx, ty); + float secondsThisFrame = move * _planner.SecondsPerPixel(dst); + _clock.Advance((long)MathF.Round(secondsThisFrame)); + } + } + + private void UpdateTactical(float dt, float dx, float dy, bool isFocused) + { + if (!isFocused || TacticalIsWalkable is null) return; + if (dx == 0f && dy == 0f) return; + + // Normalize so diagonal isn't √2 faster than cardinal. + float invLen = (dx != 0f && dy != 0f) ? 0.70710678f : 1f; + float vx = dx * invLen; + float vy = dy * invLen; + + // Apply encumbrance multiplier when a Character is attached. + // Carrying ≤ 100% of capacity walks at full speed; >100% is heavy + // (×0.66); >150% is over-encumbered (×0.50). + float encMult = _player.Character is not null + ? Theriapolis.Core.Rules.Stats.DerivedStats.TacticalSpeedMult(_player.Character) + : 1f; + float speed = C.TACTICAL_PLAYER_PX_PER_SEC * encMult; + float moveX = vx * speed * dt; + float moveY = vy * speed * dt; + + var pos = _player.Position; + + // Axis-separated motion gives wall-sliding for free: if X is blocked, + // Y still moves, and vice versa. Each axis tests the destination tile + // with a small body radius so the player doesn't visibly clip walls. + const float BodyRadius = 0.35f; + float newX = pos.X + moveX; + if (CanOccupy(newX, pos.Y, BodyRadius)) pos = new Vec2(newX, pos.Y); + float newY = pos.Y + moveY; + if (CanOccupy(pos.X, newY, BodyRadius)) pos = new Vec2(pos.X, newY); + + _player.Position = pos; + _player.FacingAngleRad = MathF.Atan2(vy, vx); + + // 1 tactical pixel walked = TACTICAL_STEP_SECONDS in-game seconds. + // Sub-second motion accumulates in _tacticalClockCarry so slow walking + // still ticks the clock cumulatively. + float walked = MathF.Sqrt(moveX * moveX + moveY * moveY); + float secondsThisFrame = walked * C.TACTICAL_STEP_SECONDS + _tacticalClockCarry; + long whole = (long)MathF.Floor(secondsThisFrame); + _tacticalClockCarry = secondsThisFrame - whole; + if (whole > 0) _clock.Advance(whole); + } + + private bool CanOccupy(float x, float y, float r) + { + // Sample the four corners of the player's body AABB so we don't slip + // into walls when sliding past corners. + return TacticalIsWalkable!((int)MathF.Floor(x - r), (int)MathF.Floor(y - r)) + && TacticalIsWalkable!((int)MathF.Floor(x + r), (int)MathF.Floor(y - r)) + && TacticalIsWalkable!((int)MathF.Floor(x - r), (int)MathF.Floor(y + r)) + && TacticalIsWalkable!((int)MathF.Floor(x + r), (int)MathF.Floor(y + r)); + } +} diff --git a/Theriapolis.Godot/Rendering/NpcMarker.cs b/Theriapolis.Godot/Rendering/NpcMarker.cs new file mode 100644 index 0000000..e881889 --- /dev/null +++ b/Theriapolis.Godot/Rendering/NpcMarker.cs @@ -0,0 +1,29 @@ +using Godot; +using Theriapolis.Core; +using Theriapolis.Core.Rules.Character; + +namespace Theriapolis.GodotHost.Rendering; + +/// +/// NPC marker — small dot tinted by allegiance. M7.2 stand-in for the +/// MonoGame NpcSprite (which adds walking-cycle animation). Counter-scaled +/// with zoom by the owner so the on-screen size stays constant. +/// +public partial class NpcMarker : Node2D +{ + private const float RadiusWorldPx = C.PLAYER_MARKER_SCREEN_PX * 0.4f; + + public Allegiance Allegiance { get; set; } = Allegiance.Neutral; + + public override void _Draw() + { + var fill = Allegiance switch + { + Allegiance.Hostile => new Color(0.78f, 0.18f, 0.20f), + Allegiance.Friendly => new Color(0.45f, 0.78f, 0.38f), + _ => new Color(0.70f, 0.70f, 0.68f), + }; + DrawCircle(Vector2.Zero, RadiusWorldPx, new Color(0, 0, 0, 0.78f)); + DrawCircle(Vector2.Zero, RadiusWorldPx * 0.80f, fill); + } +} diff --git a/Theriapolis.Godot/Rendering/PlayerMarker.cs b/Theriapolis.Godot/Rendering/PlayerMarker.cs new file mode 100644 index 0000000..b82e529 --- /dev/null +++ b/Theriapolis.Godot/Rendering/PlayerMarker.cs @@ -0,0 +1,42 @@ +using Godot; +using Theriapolis.Core; + +namespace Theriapolis.GodotHost.Rendering; + +/// +/// Player marker — small dot with a thin facing tick. Drawn at +/// /2 wp; the owner sets +/// = 1/zoom every frame so the on-screen size +/// stays constant across the seamless zoom range. +/// +/// Mirrors the MonoGame PlayerSprite's visual vocabulary — dark outline, +/// faction-red fill, optional facing tick. Phase-7-styled `PlayerSprite` +/// proper (walking animation frames) lands later. +/// +public partial class PlayerMarker : Node2D +{ + private const float RadiusWorldPx = C.PLAYER_MARKER_SCREEN_PX * 0.5f; + private const float FacingTickPx = RadiusWorldPx * 1.4f; + + /// Facing direction in radians; 0 = +X. Drives the optional + /// tick rendered on the body's leading edge. + public float FacingAngleRad { get; set; } + + /// When true, draws a small tick at the leading edge so the + /// player can read facing without a full sprite. Hidden at low zoom + /// to avoid clutter. + public bool ShowFacingTick { get; set; } = true; + + public override void _Draw() + { + DrawCircle(Vector2.Zero, RadiusWorldPx, new Color(0, 0, 0, 0.78f)); + DrawCircle(Vector2.Zero, RadiusWorldPx * 0.85f, new Color(0.86f, 0.31f, 0.24f)); + + if (ShowFacingTick) + { + var dir = new Vector2(Mathf.Cos(FacingAngleRad), Mathf.Sin(FacingAngleRad)); + DrawLine(dir * (RadiusWorldPx * 0.4f), dir * FacingTickPx, + new Color(1f, 0.96f, 0.86f), width: RadiusWorldPx * 0.18f, antialiased: false); + } + } +} diff --git a/Theriapolis.Godot/Rendering/SettlementDot.cs b/Theriapolis.Godot/Rendering/SettlementDot.cs new file mode 100644 index 0000000..b55add5 --- /dev/null +++ b/Theriapolis.Godot/Rendering/SettlementDot.cs @@ -0,0 +1,16 @@ +using Godot; + +namespace Theriapolis.GodotHost.Rendering; + +/// +/// Filled circle settlement marker on the world map. Sized in world-pixel +/// space; the parent hides the layer above +/// the tactical-zoom threshold so the dot doesn't clutter close-up views. +/// +public partial class SettlementDot : Node2D +{ + public float Radius { get; set; } = 8f; + public Color FillColor { get; set; } = Colors.White; + + public override void _Draw() => DrawCircle(Vector2.Zero, Radius, FillColor); +} diff --git a/Theriapolis.Godot/Rendering/WorldRenderNode.cs b/Theriapolis.Godot/Rendering/WorldRenderNode.cs new file mode 100644 index 0000000..d4456cf --- /dev/null +++ b/Theriapolis.Godot/Rendering/WorldRenderNode.cs @@ -0,0 +1,346 @@ +using System.Collections.Generic; +using System.Linq; +using Godot; +using Theriapolis.Core; +using Theriapolis.Core.Tactical; +using Theriapolis.Core.Util; +using Theriapolis.Core.World; +using Theriapolis.Core.World.Polylines; + +namespace Theriapolis.GodotHost.Rendering; + +/// +/// Renders a generated across the seamless zoom +/// range — biome backdrop, polylines (rivers / roads / rails), bridges, +/// settlement dots, and the tactical-chunk layer that streams in close-up. +/// Owns its own so callers can read zoom and +/// drive position uniformly. +/// +/// Per M7 plan §6.2: extracted from the M2+M4 demo +/// so PlayScreen and the standalone demo both mount the same renderer. +/// The chunk streamer itself is owned by the *caller* — PlayScreen needs +/// the streamer for NPC lifecycle separately from the visual layer — so +/// the caller subscribes to OnChunkLoaded/OnChunkEvicting +/// and forwards into /. +/// +/// Per-frame: hides/shows the tactical and settlement layers based on +/// camera zoom, and counter-scales every Line2D width so polyline widths +/// stay visually consistent regardless of zoom. +/// +public partial class WorldRenderNode : Node2D +{ + // Zoom thresholds, in Camera2D zoom units (1.0 = 1 world px per screen px, + // 32.0 = sprite-native tactical view, ~0.07 = world fits 1080p). + public const float TacticalRenderZoomMin = 4.0f; + public const float SettlementHideZoom = 2.0f; + + // Polyline base widths in *screen* pixels (counter-scaled to world space + // per frame). Mirrors the differentiation in LineFeatureRenderer.cs. + private const float HighwayScreenPx = 4f; + private const float PostRoadScreenPx = 3f; + private const float DirtRoadScreenPx = 2f; + private const float RiverMajorScreenPx = 4.5f; + private const float RiverScreenPx = 3f; + private const float StreamScreenPx = 2f; + private const float RailTieScreenPx = 4f; + private const float RailLineScreenPx = 2f; + private const float BridgeScreenPx = 6f; + + // Polyline colours mirror LineFeatureRenderer.cs / WorldgenDump.cs. + private static readonly Color RiverMajorColour = ColorByte(40, 100, 200); + private static readonly Color RiverColour = ColorByte(60, 120, 200); + private static readonly Color StreamColour = ColorByte(100, 150, 220); + private static readonly Color HighwayColour = ColorByte(210, 180, 80); + private static readonly Color PostRoadColour = ColorByte(180, 155, 70); + private static readonly Color DirtRoadColour = ColorByte(150, 130, 90); + private static readonly Color RailTieColour = ColorByte(120, 100, 80); + private static readonly Color RailColour = ColorByte(80, 65, 50); + private static readonly Color BridgeColour = ColorByte(160, 140, 100); + + private Node2D? _tacticalLayer; + private Node2D? _polylineLayer; + private Node2D? _bridgeLayer; + private Node2D? _settlementLayer; + private PanZoomCamera? _camera; + private readonly Dictionary _chunkNodes = new(); + private readonly List<(Line2D line, float baseScreenWidth)> _scaledLines = new(); + private bool _initialised; + + /// The camera owned by this node. Caller reads Zoom to + /// pick world-map vs. tactical UI behaviour, and sets Position + /// to follow the player. + public PanZoomCamera Camera => _camera!; + + /// Initialise from a completed . + /// Idempotent on repeat — second call is a no-op. + /// of 0 means "compute fit-to-viewport so the whole world is visible". + public void Initialize(WorldState world, float initialZoom = 0f) + { + if (_initialised) return; + _initialised = true; + + TacticalAtlas.EnsureLoaded(); + + BuildBiomeSprite(world); + _tacticalLayer = AddNamedLayer("TacticalChunks"); + BuildPolylines(world); + BuildBridges(world); + BuildSettlements(world); + AddCamera(initialZoom); + } + + public override void _Process(double delta) + { + if (!_initialised) return; + UpdateLayerVisibility(); + UpdateZoomScaledNodes(); + } + + /// Mount the visual for a freshly-streamed chunk. Caller + /// invokes from a ChunkStreamer.OnChunkLoaded subscription. + public void AddChunkNode(TacticalChunk chunk) + { + if (_tacticalLayer is null) return; + if (_chunkNodes.ContainsKey(chunk.Coord)) return; + + var node = new TacticalChunkNode { Name = $"Chunk{chunk.Coord.X}_{chunk.Coord.Y}" }; + _tacticalLayer.AddChild(node); + node.Bind(chunk); + _chunkNodes[chunk.Coord] = node; + } + + /// Tear down a chunk visual on eviction. Caller invokes from + /// ChunkStreamer.OnChunkEvicting. + public void RemoveChunkNode(TacticalChunk chunk) + { + if (!_chunkNodes.TryGetValue(chunk.Coord, out var node)) return; + node.QueueFree(); + _chunkNodes.Remove(chunk.Coord); + } + + // ────────────────────────────────────────────────────────────────────── + // Layer construction + + private void BuildBiomeSprite(WorldState world) + { + int W = C.WORLD_WIDTH_TILES; + int H = C.WORLD_HEIGHT_TILES; + + var palette = new Color[(int)BiomeId.Mangrove + 1]; + foreach (var def in world.BiomeDefs!) + { + var (r, g, b) = def.ParsedColor(); + int id = (int)ParseBiomeId(def.Id); + if (id >= 0 && id < palette.Length) palette[id] = ColorByte(r, g, b); + } + + var image = Image.CreateEmpty(W, H, false, Image.Format.Rgb8); + for (int y = 0; y < H; y++) + for (int x = 0; x < W; x++) + { + int id = (int)world.Tiles[x, y].Biome; + Color c = (id >= 0 && id < palette.Length && palette[id].A > 0f) + ? palette[id] + : ColorByte(255, 0, 255); + image.SetPixel(x, y, c); + } + + var sprite = new Sprite2D + { + Texture = ImageTexture.CreateFromImage(image), + Centered = false, + Scale = new Vector2(C.WORLD_TILE_PIXELS, C.WORLD_TILE_PIXELS), + TextureFilter = TextureFilterEnum.Nearest, + Name = "Biome", + }; + AddChild(sprite); + } + + private Node2D AddNamedLayer(string name) + { + var n = new Node2D { Name = name }; + AddChild(n); + return n; + } + + private void BuildPolylines(WorldState world) + { + _polylineLayer = AddNamedLayer("Polylines"); + + foreach (var road in world.Roads.OrderBy(RoadDrawRank)) + { + var (color, screenPx) = road.RoadClassification switch + { + RoadType.Highway => (HighwayColour, HighwayScreenPx), + RoadType.PostRoad => (PostRoadColour, PostRoadScreenPx), + _ => (DirtRoadColour, DirtRoadScreenPx), + }; + AddScaledLine(_polylineLayer, road.Points, color, screenPx); + } + + foreach (var river in world.Rivers) + { + var (color, screenPx) = river.RiverClassification switch + { + RiverClass.MajorRiver => (RiverMajorColour, RiverMajorScreenPx), + RiverClass.River => (RiverColour, RiverScreenPx), + _ => (StreamColour, StreamScreenPx), + }; + float flowScale = 1f + (river.FlowAccumulation / (float)C.RIVER_MAJOR_THRESHOLD) * 0.3f; + AddScaledLine(_polylineLayer, river.Points, color, + Mathf.Min(screenPx * flowScale, RiverMajorScreenPx * 1.5f)); + } + + foreach (var rail in world.Rails) + { + AddScaledLine(_polylineLayer, rail.Points, RailTieColour, RailTieScreenPx); + AddScaledLine(_polylineLayer, rail.Points, RailColour, RailLineScreenPx); + } + } + + private void BuildBridges(WorldState world) + { + if (world.Bridges.Count == 0) return; + _bridgeLayer = AddNamedLayer("Bridges"); + + foreach (var bridge in world.Bridges) + { + var line = new Line2D + { + DefaultColor = BridgeColour, + JointMode = Line2D.LineJointMode.Round, + }; + line.AddPoint(new Vector2(bridge.Start.X, bridge.Start.Y)); + line.AddPoint(new Vector2(bridge.End.X, bridge.End.Y)); + _bridgeLayer.AddChild(line); + _scaledLines.Add((line, BridgeScreenPx)); + } + } + + private void BuildSettlements(WorldState world) + { + if (world.Settlements.Count == 0) return; + _settlementLayer = AddNamedLayer("Settlements"); + + foreach (var s in world.Settlements) + { + var (colour, tileRadius) = s.Tier switch + { + 1 => (ColorByte(255, 215, 0), 2.5f), + 2 => (ColorByte(230, 230, 230), 1.8f), + 3 => (ColorByte(150, 200, 255), 1.3f), + 4 => (ColorByte(200, 200, 200), 0.8f), + _ => (ColorByte(200, 60, 60), 0.7f), + }; + float radius = tileRadius * C.WORLD_TILE_PIXELS; + var dot = new SettlementDot + { + Position = new Vector2( + s.TileX * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f, + s.TileY * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f), + Radius = radius, + FillColor = colour, + }; + _settlementLayer.AddChild(dot); + } + } + + private void AddScaledLine(Node2D parent, IReadOnlyList pts, Color colour, float screenPx) + { + var line = new Line2D + { + DefaultColor = colour, + JointMode = Line2D.LineJointMode.Round, + BeginCapMode = Line2D.LineCapMode.Round, + EndCapMode = Line2D.LineCapMode.Round, + Antialiased = false, + }; + for (int i = 0; i < pts.Count; i++) + line.AddPoint(new Vector2(pts[i].X, pts[i].Y)); + parent.AddChild(line); + _scaledLines.Add((line, screenPx)); + } + + private void AddCamera(float initialZoom) + { + Vector2 viewport = GetViewport().GetVisibleRect().Size; + Vector2 worldSize = new( + C.WORLD_WIDTH_TILES * C.WORLD_TILE_PIXELS, + C.WORLD_HEIGHT_TILES * C.WORLD_TILE_PIXELS); + float fitZoom = Mathf.Min(viewport.X / worldSize.X, viewport.Y / worldSize.Y) * 0.95f; + float startZoom = initialZoom > 0f ? initialZoom : fitZoom; + + _camera = new PanZoomCamera + { + Position = worldSize * 0.5f, // caller can reposition immediately after Initialize + Zoom = new Vector2(startZoom, startZoom), + MinZoom = fitZoom * 0.5f, + MaxZoom = 64f, + }; + AddChild(_camera); + _camera.MakeCurrent(); + } + + // ────────────────────────────────────────────────────────────────────── + // Per-frame updates + + private void UpdateLayerVisibility() + { + if (_camera is null) return; + float zoom = _camera.Zoom.X; + if (_tacticalLayer is not null) + _tacticalLayer.Visible = zoom >= TacticalRenderZoomMin; + if (_settlementLayer is not null) + _settlementLayer.Visible = zoom < SettlementHideZoom; + } + + private void UpdateZoomScaledNodes() + { + if (_camera is null) return; + float zoom = _camera.Zoom.X; + if (zoom <= 0f) return; + float invZoom = 1f / zoom; + foreach (var (line, baseScreenPx) in _scaledLines) + line.Width = baseScreenPx * invZoom; + } + + // ────────────────────────────────────────────────────────────────────── + // Helpers + + private static int RoadDrawRank(Polyline r) => r.RoadClassification switch + { + RoadType.Footpath => 0, + RoadType.DirtRoad => 1, + RoadType.PostRoad => 2, + RoadType.Highway => 3, + _ => 1, + }; + + private static Color ColorByte(byte r, byte g, byte b) => + new(r / 255f, g / 255f, b / 255f); + + private static BiomeId ParseBiomeId(string id) => id.ToLowerInvariant() switch + { + "ocean" => BiomeId.Ocean, + "tundra" => BiomeId.Tundra, + "boreal" => BiomeId.Boreal, + "temperate_deciduous" => BiomeId.TemperateDeciduous, + "temperate_grassland" => BiomeId.TemperateGrassland, + "mountain_alpine" => BiomeId.MountainAlpine, + "mountain_forested" => BiomeId.MountainForested, + "subtropical_forest" => BiomeId.SubtropicalForest, + "wetland" => BiomeId.Wetland, + "coastal" => BiomeId.Coastal, + "river_valley" => BiomeId.RiverValley, + "scrubland" => BiomeId.Scrubland, + "desert_cold" => BiomeId.DesertCold, + "forest_edge" => BiomeId.ForestEdge, + "foothills" => BiomeId.Foothills, + "marsh_edge" => BiomeId.MarshEdge, + "beach" => BiomeId.Beach, + "cliff" => BiomeId.Cliff, + "tidal_flat" => BiomeId.TidalFlat, + "mangrove" => BiomeId.Mangrove, + _ => BiomeId.TemperateGrassland, + }; +} diff --git a/Theriapolis.Godot/Rendering/WorldView.cs b/Theriapolis.Godot/Rendering/WorldView.cs index 9d8f414..146ee23 100644 --- a/Theriapolis.Godot/Rendering/WorldView.cs +++ b/Theriapolis.Godot/Rendering/WorldView.cs @@ -1,37 +1,24 @@ -using Godot; -using System.Collections.Generic; using System.IO; -using System.Linq; +using Godot; using Theriapolis.Core; using Theriapolis.Core.Tactical; using Theriapolis.Core.Util; -using Theriapolis.Core.World; using Theriapolis.Core.World.Generation; -using Theriapolis.Core.World.Polylines; using Theriapolis.GodotHost.Platform; namespace Theriapolis.GodotHost.Rendering; /// -/// Unified seamless-zoom view (CLAUDE.md "Seamless Zoom Model"). One scene -/// covers world-map and tactical scales; layers fade in/out at zoom -/// thresholds. Polyline widths and the player marker counter-scale with -/// zoom so they stay visually consistent across the full range. +/// Standalone demo entry point for the M2+M4 unified seamless-zoom view. +/// Runs worldgen inline, spawns a placeholder player, and lets you walk +/// around with WASD. Used by the --world-map and --tactical +/// CLI flags for headless / debug viewing without going through the +/// title → wizard → progress flow. /// -/// Layers, bottom-up: -/// BiomeLayer — 256x256 biome image, scaled by WORLD_TILE_PIXELS; -/// always visible. Acts as the backdrop past the -/// tactical streaming radius. -/// TacticalChunks — TacticalChunkNode children added on chunk load; -/// visible only when zoom > TacticalRenderZoomMin. -/// Polylines/Bridges — Line2D children; always visible. Widths counter- -/// scaled per frame. -/// Settlements — SettlementDot children; visible only when zoom -/// < SettlementHideZoom. -/// Player — Always visible; counter-scaled. -/// -/// Camera follows the player at all zooms; right-drag temporarily pans -/// (PanZoomCamera handles drag input). +/// The actual rendering — biome / polyline / settlement / chunk layers +/// and the camera — lives in , which is +/// shared with M7's . This shell just +/// owns the demo's local player position and streaming loop. /// public partial class WorldView : Node2D { @@ -40,56 +27,22 @@ public partial class WorldView : Node2D private readonly int _startWorldTileY; private readonly float _initialZoom; - // Zoom thresholds, in Camera2D zoom units (1.0 = 1 world px per screen px, - // 32.0 = sprite-native tactical view, ~0.07 = world fits 1080p). - private const float TacticalRenderZoomMin = 4.0f; - private const float SettlementHideZoom = 2.0f; - private const float StreamRadiusZoomMin = 4.0f; - // World-pixel movement speed. 32 wp = 1 world tile, so 96 = ~3 tiles/sec. private const float MoveSpeedWorldPx = 96f; private const int StreamingBufferWorldTiles = 2; - - // Polyline base widths in *screen* pixels (counter-scaled to world space - // per frame). Mirrors the differentiation in LineFeatureRenderer.cs. - private const float HighwayScreenPx = 4f; - private const float PostRoadScreenPx = 3f; - private const float DirtRoadScreenPx = 2f; - private const float RiverMajorScreenPx = 4.5f; - private const float RiverScreenPx = 3f; - private const float StreamScreenPx = 2f; - private const float RailTieScreenPx = 4f; - private const float RailLineScreenPx = 2f; - private const float BridgeScreenPx = 6f; - - // Polyline colours mirror LineFeatureRenderer.cs / WorldgenDump.cs. - private static readonly Color RiverMajorColour = ColorByte(40, 100, 200); - private static readonly Color RiverColour = ColorByte(60, 120, 200); - private static readonly Color StreamColour = ColorByte(100, 150, 220); - private static readonly Color HighwayColour = ColorByte(210, 180, 80); - private static readonly Color PostRoadColour = ColorByte(180, 155, 70); - private static readonly Color DirtRoadColour = ColorByte(150, 130, 90); - private static readonly Color RailTieColour = ColorByte(120, 100, 80); - private static readonly Color RailColour = ColorByte(80, 65, 50); - private static readonly Color BridgeColour = ColorByte(160, 140, 100); + private const float StreamRadiusZoomMin = WorldRenderNode.TacticalRenderZoomMin; private ChunkStreamer? _streamer; + private WorldRenderNode? _render; private Vec2 _playerPos; - private PanZoomCamera? _camera; - private Node2D? _tacticalLayer; - private Node2D? _polylineLayer; - private Node2D? _bridgeLayer; - private Node2D? _settlementLayer; private PlayerMarker? _playerMarker; - private readonly Dictionary _chunkNodes = new(); - private readonly List<(Line2D line, float baseScreenWidth)> _scaledLines = new(); public WorldView(ulong seed, int startWorldTileX = 128, int startWorldTileY = 128, float initialZoom = 0f) { _seed = seed; _startWorldTileX = startWorldTileX; _startWorldTileY = startWorldTileY; - _initialZoom = initialZoom; // 0 = compute fit-to-viewport + _initialZoom = initialZoom; } public override void _Ready() @@ -109,17 +62,13 @@ public partial class WorldView : Node2D $"roads={world.Roads.Count} rails={world.Rails.Count} " + $"settlements={world.Settlements.Count} bridges={world.Bridges.Count}"); + _render = new WorldRenderNode(); + AddChild(_render); + _render.Initialize(world, _initialZoom); + _streamer = new ChunkStreamer(_seed, world, new InMemoryChunkDeltaStore()); - _streamer.OnChunkLoaded += AddChunkNode; - _streamer.OnChunkEvicting += RemoveChunkNode; - - TacticalAtlas.EnsureLoaded(); - - BuildBiomeSprite(world); - _tacticalLayer = AddNamedLayer("TacticalChunks"); - BuildPolylines(world); - BuildBridges(world); - BuildSettlements(world); + _streamer.OnChunkLoaded += _render.AddChunkNode; + _streamer.OnChunkEvicting += _render.RemoveChunkNode; _playerPos = new Vec2( _startWorldTileX * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f, @@ -128,20 +77,19 @@ public partial class WorldView : Node2D _playerMarker = new PlayerMarker { Position = new Vector2(_playerPos.X, _playerPos.Y) }; AddChild(_playerMarker); - AddCamera(); - UpdateLayerVisibility(); + _render.Camera.Position = new Vector2(_playerPos.X, _playerPos.Y); StreamIfTactical(); } public override void _Process(double delta) { - if (_camera is null || _playerMarker is null) return; + if (_render is null || _playerMarker is null) return; Vector2 dir = Vector2.Zero; - if (Input.IsKeyPressed(Key.W) || Input.IsKeyPressed(Key.Up)) dir.Y -= 1; - if (Input.IsKeyPressed(Key.S) || Input.IsKeyPressed(Key.Down)) dir.Y += 1; - if (Input.IsKeyPressed(Key.A) || Input.IsKeyPressed(Key.Left)) dir.X -= 1; - if (Input.IsKeyPressed(Key.D) || Input.IsKeyPressed(Key.Right)) dir.X += 1; + if (Godot.Input.IsKeyPressed(Key.W) || Godot.Input.IsKeyPressed(Key.Up)) dir.Y -= 1; + if (Godot.Input.IsKeyPressed(Key.S) || Godot.Input.IsKeyPressed(Key.Down)) dir.Y += 1; + if (Godot.Input.IsKeyPressed(Key.A) || Godot.Input.IsKeyPressed(Key.Left)) dir.X -= 1; + if (Godot.Input.IsKeyPressed(Key.D) || Godot.Input.IsKeyPressed(Key.Right)) dir.X += 1; if (dir != Vector2.Zero) { @@ -160,309 +108,24 @@ public partial class WorldView : Node2D var pos = new Vector2(_playerPos.X, _playerPos.Y); _playerMarker.Position = pos; - _camera.Position = pos; + _render.Camera.Position = pos; - UpdateLayerVisibility(); - UpdateZoomScaledNodes(); - } - - // ────────────────────────────────────────────────────────────────────── - // Layer construction - - private void BuildBiomeSprite(WorldState world) - { - int W = C.WORLD_WIDTH_TILES; - int H = C.WORLD_HEIGHT_TILES; - - var palette = new Color[(int)BiomeId.Mangrove + 1]; - foreach (var def in world.BiomeDefs!) - { - var (r, g, b) = def.ParsedColor(); - int id = (int)ParseBiomeId(def.Id); - if (id >= 0 && id < palette.Length) palette[id] = ColorByte(r, g, b); - } - - var image = Image.CreateEmpty(W, H, false, Image.Format.Rgb8); - for (int y = 0; y < H; y++) - for (int x = 0; x < W; x++) - { - int id = (int)world.Tiles[x, y].Biome; - Color c = (id >= 0 && id < palette.Length && palette[id].A > 0f) - ? palette[id] - : ColorByte(255, 0, 255); - image.SetPixel(x, y, c); - } - - var sprite = new Sprite2D - { - Texture = ImageTexture.CreateFromImage(image), - Centered = false, - Scale = new Vector2(C.WORLD_TILE_PIXELS, C.WORLD_TILE_PIXELS), - TextureFilter = TextureFilterEnum.Nearest, - Name = "Biome", - }; - AddChild(sprite); - } - - private Node2D AddNamedLayer(string name) - { - var n = new Node2D { Name = name }; - AddChild(n); - return n; - } - - private void BuildPolylines(WorldState world) - { - _polylineLayer = AddNamedLayer("Polylines"); - - foreach (var road in world.Roads.OrderBy(RoadDrawRank)) - { - var (color, screenPx) = road.RoadClassification switch - { - RoadType.Highway => (HighwayColour, HighwayScreenPx), - RoadType.PostRoad => (PostRoadColour, PostRoadScreenPx), - _ => (DirtRoadColour, DirtRoadScreenPx), - }; - AddScaledLine(_polylineLayer, road.Points, color, screenPx); - } - - foreach (var river in world.Rivers) - { - var (color, screenPx) = river.RiverClassification switch - { - RiverClass.MajorRiver => (RiverMajorColour, RiverMajorScreenPx), - RiverClass.River => (RiverColour, RiverScreenPx), - _ => (StreamColour, StreamScreenPx), - }; - float flowScale = 1f + (river.FlowAccumulation / (float)C.RIVER_MAJOR_THRESHOLD) * 0.3f; - AddScaledLine(_polylineLayer, river.Points, color, - Mathf.Min(screenPx * flowScale, RiverMajorScreenPx * 1.5f)); - } - - foreach (var rail in world.Rails) - { - AddScaledLine(_polylineLayer, rail.Points, RailTieColour, RailTieScreenPx); - AddScaledLine(_polylineLayer, rail.Points, RailColour, RailLineScreenPx); - } - } - - private void BuildBridges(WorldState world) - { - if (world.Bridges.Count == 0) return; - _bridgeLayer = AddNamedLayer("Bridges"); - - foreach (var bridge in world.Bridges) - { - var line = new Line2D - { - DefaultColor = BridgeColour, - JointMode = Line2D.LineJointMode.Round, - }; - line.AddPoint(new Vector2(bridge.Start.X, bridge.Start.Y)); - line.AddPoint(new Vector2(bridge.End.X, bridge.End.Y)); - _bridgeLayer.AddChild(line); - _scaledLines.Add((line, BridgeScreenPx)); - } - } - - private void BuildSettlements(WorldState world) - { - if (world.Settlements.Count == 0) return; - _settlementLayer = AddNamedLayer("Settlements"); - - foreach (var s in world.Settlements) - { - var (colour, tileRadius) = s.Tier switch - { - 1 => (ColorByte(255, 215, 0), 2.5f), - 2 => (ColorByte(230, 230, 230), 1.8f), - 3 => (ColorByte(150, 200, 255), 1.3f), - 4 => (ColorByte(200, 200, 200), 0.8f), - _ => (ColorByte(200, 60, 60), 0.7f), - }; - float radius = tileRadius * C.WORLD_TILE_PIXELS; - var dot = new SettlementDot - { - Position = new Vector2( - s.TileX * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f, - s.TileY * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f), - Radius = radius, - FillColor = colour, - }; - _settlementLayer.AddChild(dot); - } - } - - private void AddScaledLine(Node2D parent, IReadOnlyList pts, Color colour, float screenPx) - { - var line = new Line2D - { - DefaultColor = colour, - JointMode = Line2D.LineJointMode.Round, - BeginCapMode = Line2D.LineCapMode.Round, - EndCapMode = Line2D.LineCapMode.Round, - Antialiased = false, - }; - for (int i = 0; i < pts.Count; i++) - line.AddPoint(new Vector2(pts[i].X, pts[i].Y)); - parent.AddChild(line); - _scaledLines.Add((line, screenPx)); - } - - private void AddCamera() - { - Vector2 viewport = GetViewport().GetVisibleRect().Size; - Vector2 worldSize = new( - C.WORLD_WIDTH_TILES * C.WORLD_TILE_PIXELS, - C.WORLD_HEIGHT_TILES * C.WORLD_TILE_PIXELS); - float fitZoom = Mathf.Min(viewport.X / worldSize.X, viewport.Y / worldSize.Y) * 0.95f; - float startZoom = _initialZoom > 0f ? _initialZoom : fitZoom; - - _camera = new PanZoomCamera - { - Position = new Vector2(_playerPos.X, _playerPos.Y), - Zoom = new Vector2(startZoom, startZoom), - MinZoom = fitZoom * 0.5f, - MaxZoom = 64f, - }; - AddChild(_camera); - _camera.MakeCurrent(); - } - - // ────────────────────────────────────────────────────────────────────── - // Per-frame updates - - private void UpdateLayerVisibility() - { - if (_camera is null) return; - float zoom = _camera.Zoom.X; - if (_tacticalLayer is not null) - _tacticalLayer.Visible = zoom >= TacticalRenderZoomMin; - if (_settlementLayer is not null) - _settlementLayer.Visible = zoom < SettlementHideZoom; - } - - private void UpdateZoomScaledNodes() - { - if (_camera is null) return; - float zoom = _camera.Zoom.X; - if (zoom <= 0f) return; - float invZoom = 1f / zoom; - - foreach (var (line, baseScreenPx) in _scaledLines) - line.Width = baseScreenPx * invZoom; - - if (_playerMarker is not null) - _playerMarker.Scale = new Vector2(invZoom, invZoom); + // Counter-scale the marker so its on-screen size stays constant. + float zoom = _render.Camera.Zoom.X; + if (zoom > 0f) + _playerMarker.Scale = new Vector2(1f / zoom, 1f / zoom); } private void StreamIfTactical() { - if (_streamer is null) return; - if (_camera is null || _camera.Zoom.X < StreamRadiusZoomMin) - { - // Optional: evict everything outside a small fallback set so we - // don't keep a stale tactical cache when zoomed out for a long - // time. Skipping for M4 — soft cap in the streamer handles it. - return; - } + if (_streamer is null || _render is null) return; + if (_render.Camera.Zoom.X < StreamRadiusZoomMin) return; Vector2 viewport = GetViewport().GetVisibleRect().Size; - float halfExtentWorldPx = Mathf.Max(viewport.X, viewport.Y) / _camera.Zoom.X * 0.5f; + float halfExtentWorldPx = Mathf.Max(viewport.X, viewport.Y) / _render.Camera.Zoom.X * 0.5f; int halfExtentTiles = Mathf.CeilToInt(halfExtentWorldPx / C.WORLD_TILE_PIXELS); int radius = halfExtentTiles + StreamingBufferWorldTiles; _streamer.EnsureLoadedAround(_playerPos, radius); } - - // ────────────────────────────────────────────────────────────────────── - // Chunk node lifecycle - - private void AddChunkNode(TacticalChunk chunk) - { - if (_tacticalLayer is null) return; - if (_chunkNodes.ContainsKey(chunk.Coord)) return; - - var node = new TacticalChunkNode { Name = $"Chunk{chunk.Coord.X}_{chunk.Coord.Y}" }; - _tacticalLayer.AddChild(node); - node.Bind(chunk); - _chunkNodes[chunk.Coord] = node; - } - - private void RemoveChunkNode(TacticalChunk chunk) - { - if (!_chunkNodes.TryGetValue(chunk.Coord, out var node)) return; - node.QueueFree(); - _chunkNodes.Remove(chunk.Coord); - } - - // ────────────────────────────────────────────────────────────────────── - // Helpers - - private static int RoadDrawRank(Polyline r) => r.RoadClassification switch - { - RoadType.Footpath => 0, - RoadType.DirtRoad => 1, - RoadType.PostRoad => 2, - RoadType.Highway => 3, - _ => 1, - }; - - private static Color ColorByte(byte r, byte g, byte b) => - new(r / 255f, g / 255f, b / 255f); - - private static BiomeId ParseBiomeId(string id) => id.ToLowerInvariant() switch - { - "ocean" => BiomeId.Ocean, - "tundra" => BiomeId.Tundra, - "boreal" => BiomeId.Boreal, - "temperate_deciduous" => BiomeId.TemperateDeciduous, - "temperate_grassland" => BiomeId.TemperateGrassland, - "mountain_alpine" => BiomeId.MountainAlpine, - "mountain_forested" => BiomeId.MountainForested, - "subtropical_forest" => BiomeId.SubtropicalForest, - "wetland" => BiomeId.Wetland, - "coastal" => BiomeId.Coastal, - "river_valley" => BiomeId.RiverValley, - "scrubland" => BiomeId.Scrubland, - "desert_cold" => BiomeId.DesertCold, - "forest_edge" => BiomeId.ForestEdge, - "foothills" => BiomeId.Foothills, - "marsh_edge" => BiomeId.MarshEdge, - "beach" => BiomeId.Beach, - "cliff" => BiomeId.Cliff, - "tidal_flat" => BiomeId.TidalFlat, - "mangrove" => BiomeId.Mangrove, - _ => BiomeId.TemperateGrassland, - }; -} - -/// -/// Filled circle settlement marker on the world map. Sized in world-pixel -/// space (parent layer's visibility flag handles the world-vs-tactical -/// hide threshold). -/// -public partial class SettlementDot : Node2D -{ - public float Radius { get; set; } = 8f; - public Color FillColor { get; set; } = Colors.White; - - public override void _Draw() => DrawCircle(Vector2.Zero, Radius, FillColor); -} - -/// -/// Player marker. Drawn at /2 wp; -/// parent WorldView sets = 1/zoom every frame -/// so the on-screen size stays constant (~24 px radius / 48 px diameter, -/// matching MonoGame's PlayerSprite) across the seamless zoom range. -/// -public partial class PlayerMarker : Node2D -{ - private const float RadiusWorldPx = C.PLAYER_MARKER_SCREEN_PX * 0.5f; - - public override void _Draw() - { - DrawCircle(Vector2.Zero, RadiusWorldPx, new Color(0, 0, 0, 0.78f)); - DrawCircle(Vector2.Zero, RadiusWorldPx * 0.85f, new Color(0.86f, 0.31f, 0.24f)); - } } diff --git a/Theriapolis.Godot/Scenes/PlayScreen.cs b/Theriapolis.Godot/Scenes/PlayScreen.cs new file mode 100644 index 0000000..80656fd --- /dev/null +++ b/Theriapolis.Godot/Scenes/PlayScreen.cs @@ -0,0 +1,400 @@ +using System.Collections.Generic; +using System.Linq; +using Godot; +using Theriapolis.Core; +using Theriapolis.Core.Data; +using Theriapolis.Core.Entities; +using Theriapolis.Core.Rules.Combat; +using Theriapolis.Core.Tactical; +using Theriapolis.Core.Time; +using Theriapolis.Core.Util; +using Theriapolis.Core.World; +using Theriapolis.Core.World.Generation; +using Theriapolis.Core.World.Settlements; +using Theriapolis.GodotHost.Input; +using Theriapolis.GodotHost.Platform; +using Theriapolis.GodotHost.Rendering; +using Theriapolis.GodotHost.UI; + +namespace Theriapolis.GodotHost.Scenes; + +/// +/// M7.2 — the play screen. Wraps with the +/// game-state layer: player actor, world clock, chunk streamer, NPC +/// markers, player controller, and a top-left HUD overlay. Click on the +/// world map to travel; WASD to step at tactical zoom. +/// +/// Per the M7 plan §6: PlayScreen owns the chunk streamer (so M7.3 save +/// can serialise its delta store) and the actor manager (so M7.5 can +/// drive interact prompts). The world-map view and the tactical view +/// are the same scene at different zoom levels — there is no separate +/// WorldMapScreen, by design. +/// +/// M7.2 omissions (deferred to later sub-milestones): +/// - Save / load round-trip (M7.3) +/// - Pause menu (M7.4) +/// - Interact prompt + dialogue push (M7.5) +/// - Encounter detection stub + autosave toast (M7.6) +/// +public partial class PlayScreen : Control +{ + private const float ClickSlopPixels = 4f; + + // Composed Core systems + private WorldGenContext _ctx = null!; + private ContentResolver _content = null!; + private InMemoryChunkDeltaStore _deltas = null!; + private ChunkStreamer _streamer = null!; + private ActorManager _actors = null!; + private WorldClock _clock = null!; + private PlayerController _controller = null!; + private AnchorRegistry _anchorRegistry = null!; + + // Godot tree + private WorldRenderNode _render = null!; + private PlayerMarker _playerMarker = null!; + private readonly Dictionary _npcMarkers = new(); + private Label _hudLabel = null!; + private PanelContainer _hudPanel = null!; + + // Click-vs-drag state (left-click only; PanZoomCamera handles + // middle/right-drag pan independently). + private Vector2 _mouseDownPos; + private int _mouseDownTileX, _mouseDownTileY; + private bool _mouseDownTracked; + + public override void _Ready() + { + var session = GameSession.From(this); + if (session.Ctx is null) + { + GD.PushError("[play] No WorldGenContext on session — falling back to title."); + BackToTitle(); + return; + } + _ctx = session.Ctx; + + Theme = CodexTheme.Build(); + SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); + + // World render layer — biome + polylines + settlements + camera. + _render = new WorldRenderNode(); + AddChild(_render); + _render.Initialize(_ctx.World); + + // Core systems. + _content = new ContentResolver( + new Theriapolis.Core.Data.ContentLoader(ContentPaths.DataDir)); + _deltas = new InMemoryChunkDeltaStore(); + _streamer = new ChunkStreamer( + _ctx.World.WorldSeed, _ctx.World, _deltas, _content.Settlements); + _streamer.OnChunkLoaded += _render.AddChunkNode; + _streamer.OnChunkEvicting += _render.RemoveChunkNode; + _streamer.OnChunkLoaded += HandleChunkLoaded; + _streamer.OnChunkEvicting += HandleChunkEvicting; + + _clock = new WorldClock(); + _actors = new ActorManager(); + _anchorRegistry = new AnchorRegistry(); + _anchorRegistry.RegisterAllAnchors(_ctx.World); + + // Spawn player at the Tier-1 anchor (Millhaven), or the centre of + // the world if no inhabited settlement exists. + var spawn = ChooseSpawn(_ctx.World); + if (session.PendingCharacter is not null) + { + var p = _actors.SpawnPlayer(spawn, session.PendingCharacter); + if (!string.IsNullOrWhiteSpace(session.PendingName)) + p.Name = session.PendingName; + } + else + { + _actors.SpawnPlayer(spawn); + } + + _controller = new PlayerController(_actors.Player!, _ctx.World, _clock); + _controller.TacticalIsWalkable = (tx, ty) => _streamer.SampleTile(tx, ty).IsWalkable; + + // Player marker. + _playerMarker = new PlayerMarker + { + Position = new Vector2(_actors.Player!.Position.X, _actors.Player.Position.Y), + FacingAngleRad = _actors.Player.FacingAngleRad, + }; + AddChild(_playerMarker); + + _render.Camera.Position = _playerMarker.Position; + SetInitialZoom(); + BuildHud(); + + // Clear pending so a quit-to-title doesn't see stale data. + session.PendingCharacter = null; + session.PendingName = "Wanderer"; + session.PendingRestore = null; + session.PendingHeader = null; + } + + public override void _Process(double delta) + { + if (_actors?.Player is null || _render is null) return; + float dt = (float)delta; + + bool tactical = _render.Camera.Zoom.X >= WorldRenderNode.TacticalRenderZoomMin; + + // Tactical WASD direction (world-map mode ignores keys — middle-drag + // pans, click-to-travel sets the destination). + float dx = 0f, dy = 0f; + if (tactical) + { + if (Godot.Input.IsKeyPressed(Key.W) || Godot.Input.IsKeyPressed(Key.Up)) dy -= 1f; + if (Godot.Input.IsKeyPressed(Key.S) || Godot.Input.IsKeyPressed(Key.Down)) dy += 1f; + if (Godot.Input.IsKeyPressed(Key.A) || Godot.Input.IsKeyPressed(Key.Left)) dx -= 1f; + if (Godot.Input.IsKeyPressed(Key.D) || Godot.Input.IsKeyPressed(Key.Right)) dx += 1f; + } + + _controller.Update(dt, dx, dy, tactical, isFocused: true); + + // Sync the player marker from Core state. + var p = _actors.Player; + _playerMarker.Position = new Vector2(p.Position.X, p.Position.Y); + _playerMarker.FacingAngleRad = p.FacingAngleRad; + + // Camera follow when traveling or in tactical (matches MonoGame). + if (_controller.IsTraveling || tactical) + _render.Camera.Position = _playerMarker.Position; + + // Stream tactical chunks around the player when at tactical zoom. + if (tactical) + _streamer.EnsureLoadedAround(p.Position, C.TACTICAL_WINDOW_WORLD_TILES); + + // Counter-scale markers so on-screen size stays constant. + float zoom = _render.Camera.Zoom.X; + if (zoom > 0f) + { + float inv = 1f / zoom; + _playerMarker.Scale = new Vector2(inv, inv); + _playerMarker.ShowFacingTick = tactical; + foreach (var marker in _npcMarkers.Values) + marker.Scale = new Vector2(inv, inv); + } + + UpdateHud(tactical); + } + + public override void _UnhandledInput(InputEvent @event) + { + if (@event is not InputEventMouseButton mb || mb.ButtonIndex != MouseButton.Left) + return; + + if (mb.Pressed) + { + _mouseDownPos = mb.Position; + var worldPos = ScreenToWorld(mb.Position); + _mouseDownTileX = (int)Mathf.Floor(worldPos.X / C.WORLD_TILE_PIXELS); + _mouseDownTileY = (int)Mathf.Floor(worldPos.Y / C.WORLD_TILE_PIXELS); + _mouseDownTracked = true; + return; + } + + // Release. + if (!_mouseDownTracked) return; + _mouseDownTracked = false; + bool wasClick = mb.Position.DistanceTo(_mouseDownPos) <= ClickSlopPixels; + if (!wasClick) return; + + bool tactical = _render.Camera.Zoom.X >= WorldRenderNode.TacticalRenderZoomMin; + if (!tactical && InBounds(_mouseDownTileX, _mouseDownTileY)) + { + _controller.RequestTravelTo(_mouseDownTileX, _mouseDownTileY); + GetViewport().SetInputAsHandled(); + } + } + + private Vector2 ScreenToWorld(Vector2 screenPos) + => _render.Camera.GetCanvasTransform().AffineInverse() * screenPos; + + private static bool InBounds(int x, int y) + => (uint)x < C.WORLD_WIDTH_TILES && (uint)y < C.WORLD_HEIGHT_TILES; + + // ────────────────────────────────────────────────────────────────────── + // Chunk → NPC lifecycle (Phase 5 M5) + + private void HandleChunkLoaded(TacticalChunk chunk) + { + if (_content is null) return; + for (int i = 0; i < chunk.Spawns.Count; i++) + { + var spawn = chunk.Spawns[i]; + if (_actors.FindNpcBySource(chunk.Coord, i) is not null) continue; + + if (spawn.Kind == SpawnKind.Resident) + { + var resident = ResidentInstantiator.Spawn( + _ctx.World.WorldSeed, chunk, i, spawn, + _ctx.World, _content, _actors, _anchorRegistry); + if (resident is not null) MountNpcMarker(resident); + continue; + } + + var template = NpcInstantiator.PickTemplate(spawn.Kind, chunk.DangerZone, _content.Npcs); + if (template is null) continue; + + int tx = chunk.OriginX + spawn.LocalX; + int ty = chunk.OriginY + spawn.LocalY; + var npc = _actors.SpawnNpc(template, new Vec2(tx, ty), chunk.Coord, i); + MountNpcMarker(npc); + } + } + + private void HandleChunkEvicting(TacticalChunk chunk) + { + var toRemove = new List(); + foreach (var npc in _actors.Npcs) + { + if (npc.SourceChunk is { } src && src.Equals(chunk.Coord)) + { + toRemove.Add(npc.Id); + if (!string.IsNullOrEmpty(npc.RoleTag)) + _anchorRegistry.UnregisterRole(npc.RoleTag); + } + } + foreach (int id in toRemove) + { + _actors.RemoveActor(id); + if (_npcMarkers.Remove(id, out var marker)) + marker.QueueFree(); + } + } + + private void MountNpcMarker(NpcActor npc) + { + var marker = new NpcMarker + { + Position = new Vector2(npc.Position.X, npc.Position.Y), + Allegiance = npc.Allegiance, + }; + AddChild(marker); + _npcMarkers[npc.Id] = marker; + } + + // ────────────────────────────────────────────────────────────────────── + // Spawn + initial-zoom helpers + + private static Vec2 ChooseSpawn(WorldState w) + { + var tier1 = w.Settlements.FirstOrDefault(s => s.Tier == 1 && !s.IsPoi); + if (tier1 is not null) return new Vec2(tier1.WorldPixelX, tier1.WorldPixelY); + var anyInhabited = w.Settlements.FirstOrDefault(s => !s.IsPoi); + if (anyInhabited is not null) return new Vec2(anyInhabited.WorldPixelX, anyInhabited.WorldPixelY); + return new Vec2( + C.WORLD_WIDTH_TILES * C.WORLD_TILE_PIXELS * 0.5f, + C.WORLD_HEIGHT_TILES * C.WORLD_TILE_PIXELS * 0.5f); + } + + private void SetInitialZoom() + { + // Frame ~24 tiles across the screen — comfortable overland zoom. + Vector2 viewport = GetViewport().GetVisibleRect().Size; + float targetZoom = viewport.X / (24f * C.WORLD_TILE_PIXELS); + targetZoom = Mathf.Clamp(targetZoom, + _render.Camera.MinZoom, + WorldRenderNode.TacticalRenderZoomMin * 0.95f); + _render.Camera.Zoom = new Vector2(targetZoom, targetZoom); + } + + // ────────────────────────────────────────────────────────────────────── + // HUD overlay (top-left panel, codex-styled) + + private void BuildHud() + { + var hudLayer = new CanvasLayer { Layer = 50, Name = "Hud" }; + AddChild(hudLayer); + + _hudPanel = new PanelContainer + { + ThemeTypeVariation = "Card", + MouseFilter = MouseFilterEnum.Ignore, + OffsetLeft = 12, OffsetTop = 12, + OffsetRight = 420, OffsetBottom = 220, + }; + hudLayer.AddChild(_hudPanel); + + var margin = new MarginContainer { MouseFilter = MouseFilterEnum.Ignore }; + margin.AddThemeConstantOverride("margin_left", 12); + margin.AddThemeConstantOverride("margin_top", 8); + margin.AddThemeConstantOverride("margin_right", 12); + margin.AddThemeConstantOverride("margin_bottom", 8); + _hudPanel.AddChild(margin); + + _hudLabel = new Label + { + Text = "…", + ThemeTypeVariation = "CardBody", + AutowrapMode = TextServer.AutowrapMode.WordSmart, + MouseFilter = MouseFilterEnum.Ignore, + }; + margin.AddChild(_hudLabel); + } + + private void UpdateHud(bool tactical) + { + var p = _actors.Player!; + int ptx = (int)Mathf.Floor(p.Position.X / C.WORLD_TILE_PIXELS); + int pty = (int)Mathf.Floor(p.Position.Y / C.WORLD_TILE_PIXELS); + int cx = Mathf.Clamp(ptx, 0, C.WORLD_WIDTH_TILES - 1); + int cy = Mathf.Clamp(pty, 0, C.WORLD_HEIGHT_TILES - 1); + ref var t = ref _ctx.World.TileAt(cx, cy); + + string charBlock = ""; + if (p.Character is { } pc) + { + int ac = Theriapolis.Core.Rules.Stats.DerivedStats.ArmorClass(pc); + charBlock = $"{p.Name} HP {pc.CurrentHp}/{pc.MaxHp} AC {ac}\n"; + } + + string viewBlock = tactical + ? "View: Tactical (WASD to step)" + : "View: World Map (click a tile to travel)"; + + string status = _controller.IsTraveling + ? "Traveling…" + : tactical + ? "Mouse-wheel out to leave tactical." + : "Mouse-wheel in for tactical."; + + _hudLabel.Text = + charBlock + + $"Seed: 0x{_ctx.World.WorldSeed:X}\n" + + $"Player: ({ptx}, {pty}) {t.Biome}\n" + + $"{viewBlock}\n" + + $"Time: Day {_clock.Day}, {_clock.Hour:D2}:{_clock.Minute:D2}\n" + + $"{status}\n" + + "M7.3 brings save/load. ESC pause arrives M7.4."; + } + + // ────────────────────────────────────────────────────────────────────── + // Quit path (M7.4 will wire ESC → pause; for now Esc returns to title) + + public override void _Input(InputEvent @event) + { + if (@event is InputEventKey { Pressed: true, Keycode: Key.Escape }) + { + GetViewport().SetInputAsHandled(); + BackToTitle(); + } + } + + private void BackToTitle() + { + var session = GameSession.From(this); + session.ClearPending(); + session.Ctx = null; + + var parent = GetParent(); + if (parent is null) return; + foreach (Node sibling in parent.GetChildren()) + if (sibling != this) sibling.QueueFree(); + parent.AddChild(new TitleScreen()); + QueueFree(); + } +} diff --git a/Theriapolis.Godot/Scenes/PlayScreenStub.cs b/Theriapolis.Godot/Scenes/PlayScreenStub.cs new file mode 100644 index 0000000..4904a39 --- /dev/null +++ b/Theriapolis.Godot/Scenes/PlayScreenStub.cs @@ -0,0 +1,126 @@ +using Godot; +using Theriapolis.GodotHost.UI; + +namespace Theriapolis.GodotHost.Scenes; + +/// +/// M7.1 placeholder for the play screen. WorldGenProgressScreen swaps +/// here on success; M7.2 will replace this with the real PlayScreen +/// (walking character, chunk-streamed tactical view, HUD, save layer). +/// +/// Reads and +/// so the play-test confirms the M7.1 hand-off chain end-to-end: +/// Title → Wizard → CharacterAssembler → WorldGenProgress → here. +/// +public partial class PlayScreenStub : Control +{ + public override void _Ready() + { + Theme = CodexTheme.Build(); + SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); + + var bg = new Panel { MouseFilter = MouseFilterEnum.Ignore }; + AddChild(bg); + bg.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); + MoveChild(bg, 0); + + var center = new CenterContainer { MouseFilter = MouseFilterEnum.Ignore }; + AddChild(center); + center.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); + + var col = new VBoxContainer { CustomMinimumSize = new Vector2(640, 0) }; + col.AddThemeConstantOverride("separation", 14); + center.AddChild(col); + + var session = GameSession.From(this); + + col.AddChild(new Label + { + Text = "PLAYSCREEN STUB · M7.1", + ThemeTypeVariation = "Eyebrow", + HorizontalAlignment = HorizontalAlignment.Center, + }); + col.AddChild(new Label + { + Text = "World generation complete.", + ThemeTypeVariation = "H2", + HorizontalAlignment = HorizontalAlignment.Center, + }); + + var ctx = session.Ctx; + if (ctx is not null) + { + var w = ctx.World; + col.AddChild(new Label + { + Text = $"Seed 0x{w.WorldSeed:X} · rivers {w.Rivers.Count} " + + $"roads {w.Roads.Count} rails {w.Rails.Count} " + + $"settlements {w.Settlements.Count} bridges {w.Bridges.Count}", + HorizontalAlignment = HorizontalAlignment.Center, + AutowrapMode = TextServer.AutowrapMode.WordSmart, + }); + } + else + { + col.AddChild(new Label + { + Text = "(No WorldGenContext on session — this stub was entered out-of-band.)", + HorizontalAlignment = HorizontalAlignment.Center, + ThemeTypeVariation = "Eyebrow", + }); + } + + var character = session.PendingCharacter; + if (character is not null) + { + string hybridTag = character.Hybrid is not null ? "yes" : "no"; + col.AddChild(new Label + { + Text = $"Character: {session.PendingName} · HP {character.MaxHp} " + + $"· class {character.ClassDef.Id} · hybrid: {hybridTag} " + + $"· skills: {character.SkillProficiencies.Count}", + HorizontalAlignment = HorizontalAlignment.Center, + AutowrapMode = TextServer.AutowrapMode.WordSmart, + }); + } + else + { + col.AddChild(new Label + { + Text = "(No character attached — load path will fill this in once M7.3 ships.)", + HorizontalAlignment = HorizontalAlignment.Center, + ThemeTypeVariation = "Eyebrow", + }); + } + + col.AddChild(new Label + { + Text = "PlayScreen with walking character + chunk-streamed tactical view lands in M7.2.", + HorizontalAlignment = HorizontalAlignment.Center, + AutowrapMode = TextServer.AutowrapMode.WordSmart, + }); + + var titleBtn = new Button + { + Text = "← Title", + CustomMinimumSize = new Vector2(220, 44), + SizeFlagsHorizontal = SizeFlags.ShrinkCenter, + }; + titleBtn.Pressed += BackToTitle; + col.AddChild(titleBtn); + } + + private void BackToTitle() + { + var session = GameSession.From(this); + session.ClearPending(); + session.Ctx = null; + + var parent = GetParent(); + if (parent is null) return; + foreach (Node sibling in parent.GetChildren()) + if (sibling != this) sibling.QueueFree(); + parent.AddChild(new TitleScreen()); + QueueFree(); + } +} diff --git a/Theriapolis.Godot/Scenes/TitleScreen.cs b/Theriapolis.Godot/Scenes/TitleScreen.cs index 48c46d8..b88fc62 100644 --- a/Theriapolis.Godot/Scenes/TitleScreen.cs +++ b/Theriapolis.Godot/Scenes/TitleScreen.cs @@ -18,7 +18,7 @@ namespace Theriapolis.GodotHost.Scenes; /// public partial class TitleScreen : Control { - private const string VersionLabel = "PORT / GODOT · M6.20"; + private const string VersionLabel = "PORT / GODOT · M7.2"; private const string WizardScenePath = "res://Scenes/Wizard.tscn"; public override void _Ready() @@ -121,10 +121,15 @@ public partial class TitleScreen : Control if (sibling != this) sibling.QueueFree(); var wizardNode = packed.Instantiate(); parent.AddChild(wizardNode); - // The wizard's "← Title" back-button (visible on step 0) emits - // BackToTitle; reinstate this title screen when that fires. if (wizardNode is Wizard wizard) + { + // "← Title" back-button (visible on step 0) emits BackToTitle. wizard.BackToTitle += () => SwapBackToTitle(parent); + // M7.1 — Confirm & Begin in StepReview is forwarded by the + // wizard as CharacterConfirmed. Stash the built character on + // GameSession and hand off to WorldGenProgressScreen. + wizard.CharacterConfirmed += draft => SwapToWorldGen(parent, draft); + } QueueFree(); } @@ -134,6 +139,27 @@ public partial class TitleScreen : Control parent.AddChild(new TitleScreen()); } + /// M7.1 hand-off: snapshot the built character + chosen + /// name onto , default the seed (a seed-entry + /// UI lands later), and swap to . + private static void SwapToWorldGen(Node parent, UI.CharacterDraft draft) + { + var session = GameSession.From(parent); + // CharacterAssembler.LastBuilt is populated by StepReview's + // OnConfirmPressed → TryBuild call immediately before the + // CharacterConfirmed signal fires. + session.PendingCharacter = CharacterAssembler.LastBuilt; + session.PendingName = string.IsNullOrWhiteSpace(draft.CharacterName) + ? "Wanderer" + : draft.CharacterName; + session.Seed = 12345UL; // default for M7; seed-entry UI is M8+. + session.PendingRestore = null; + session.PendingHeader = null; + + foreach (Node child in parent.GetChildren()) child.QueueFree(); + parent.AddChild(new WorldGenProgressScreen()); + } + private void OnContinue() { // M7 territory — the play-loop screens that consume the persisted diff --git a/Theriapolis.Godot/Scenes/Wizard.cs b/Theriapolis.Godot/Scenes/Wizard.cs index f54a132..dd784db 100644 --- a/Theriapolis.Godot/Scenes/Wizard.cs +++ b/Theriapolis.Godot/Scenes/Wizard.cs @@ -15,6 +15,11 @@ public partial class Wizard : Control { [Signal] public delegate void BackToTitleEventHandler(); + /// Forwarded from StepReview.CharacterConfirmed so + /// the wizard's owner (TitleScreen / Main) can hand off to the + /// WorldGenProgressScreen without reaching into the step tree. + [Signal] public delegate void CharacterConfirmedEventHandler(UI.CharacterDraft draft); + private static readonly string[] StepKeys = { "clade", "species", "class", "subclass", "background", "stats", "skills", "review" }; private static readonly string[] StepNames = @@ -117,6 +122,13 @@ public partial class Wizard : Control _activeStep = instance; instance.Bind(Character); _stepHost.AddChild((Control)instance); + + // Forward the final-step confirmation upward so TitleScreen + // (or whatever shell owns the wizard) can swap to M7.1's + // WorldGenProgressScreen without coupling to the step tree. + if (instance is Steps.StepReview review) + review.CharacterConfirmed += draft => + EmitSignal(SignalName.CharacterConfirmed, draft); } UpdateChrome(); diff --git a/Theriapolis.Godot/Scenes/WorldGenProgressScreen.cs b/Theriapolis.Godot/Scenes/WorldGenProgressScreen.cs new file mode 100644 index 0000000..60f3c77 --- /dev/null +++ b/Theriapolis.Godot/Scenes/WorldGenProgressScreen.cs @@ -0,0 +1,251 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Godot; +using Theriapolis.Core.Persistence; +using Theriapolis.Core.World.Generation; +using Theriapolis.GodotHost.Platform; +using Theriapolis.GodotHost.UI; + +namespace Theriapolis.GodotHost.Scenes; + +/// +/// M7.1 — runs the 23-stage worldgen pipeline on a background thread +/// and shows per-stage progress. Transitions to +/// (which M7.2 will replace with the real PlayScreen) on completion. +/// +/// Mirrors Theriapolis.Game/Screens/WorldGenProgressScreen.cs: +/// same volatile-field hand-off between the worker and the UI thread, +/// same soft stage-hash warning when restoring from a saved header. +/// +/// Inputs (from ): +/// - Seed — required. +/// - PendingHeader — present when restoring from save; triggers +/// the post-gen stage-hash diff against WorldState.StageHashes. +/// +/// Outputs: +/// - session.Ctx set on success; consumed by the next screen. +/// +/// Escape during generation: cancel the worker (honoured at the next +/// stage boundary), return to Title. +/// +public partial class WorldGenProgressScreen : Control +{ + private WorldGenContext? _ctx; + private Task? _genTask; + private CancellationTokenSource? _cts; + private volatile float _progress; + private volatile string _stageName = "Initialising…"; + private volatile bool _complete; + private volatile string? _error; + + private Label _titleLabel = null!; + private ProgressBar _progressBar = null!; + private Label _stageLabel = null!; + private bool _transitioned; + + public override void _Ready() + { + Theme = CodexTheme.Build(); + SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); + + // Backing panel so the dark palette Bg fills the viewport (the + // Control itself paints nothing). Mirrors TitleScreen.cs. + var bg = new Panel { MouseFilter = MouseFilterEnum.Ignore }; + AddChild(bg); + bg.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); + MoveChild(bg, 0); + + BuildUI(); + StartGeneration(); + } + + private void BuildUI() + { + var center = new CenterContainer { MouseFilter = MouseFilterEnum.Ignore }; + AddChild(center); + center.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); + + var col = new VBoxContainer { CustomMinimumSize = new Vector2(480, 0) }; + col.AddThemeConstantOverride("separation", 14); + center.AddChild(col); + + var session = GameSession.From(this); + col.AddChild(new Label + { + Text = "FORGING THE WORLD", + ThemeTypeVariation = "Eyebrow", + HorizontalAlignment = HorizontalAlignment.Center, + }); + _titleLabel = new Label + { + Text = $"Seed 0x{session.Seed:X}", + ThemeTypeVariation = "H2", + HorizontalAlignment = HorizontalAlignment.Center, + }; + col.AddChild(_titleLabel); + + _progressBar = new ProgressBar + { + MinValue = 0, + MaxValue = 1, + Step = 0.001, + ShowPercentage = true, + CustomMinimumSize = new Vector2(0, 22), + }; + col.AddChild(_progressBar); + + _stageLabel = new Label + { + Text = "Starting…", + HorizontalAlignment = HorizontalAlignment.Center, + AutowrapMode = TextServer.AutowrapMode.WordSmart, + }; + col.AddChild(_stageLabel); + + col.AddChild(new Label + { + Text = "Esc to cancel · returns to title.", + ThemeTypeVariation = "Eyebrow", + HorizontalAlignment = HorizontalAlignment.Center, + }); + } + + private void StartGeneration() + { + _cts = new CancellationTokenSource(); + var token = _cts.Token; + var session = GameSession.From(this); + ulong seed = session.Seed; + string dataDir = ContentPaths.DataDir; + + _genTask = Task.Run(() => + { + try + { + var ctx = new WorldGenContext(seed, dataDir) + { + ProgressCallback = (name, frac) => + { + _stageName = name; + _progress = frac; + }, + Log = msg => GD.Print($"[worldgen] {msg}"), + }; + WorldGenerator.RunAll(ctx); + if (token.IsCancellationRequested) return; + _ctx = ctx; + _complete = true; + } + catch (Exception ex) + { + var inner = ex is AggregateException ae ? ae.Flatten().InnerException ?? ex : ex; + _error = inner.ToString(); + } + }, token); + } + + public override void _Process(double delta) + { + if (_transitioned) return; + if (_error is not null) + { + ShowError(_error); + return; + } + if (_complete && _ctx is not null) + { + _transitioned = true; + Transition(); + return; + } + _progressBar.Value = _progress; + _stageLabel.Text = _stageName; + } + + private void Transition() + { + var session = GameSession.From(this); + if (session.PendingHeader is not null) + CompareStageHashes(session.PendingHeader); + + session.Ctx = _ctx; + + // M7.2 — the real PlayScreen. PlayScreenStub is kept around as + // a fallback for any future code path that hasn't been wired up + // (e.g. mid-development load flows), but the live hand-off lands + // in the play view. + SwapTo(new PlayScreen()); + } + + private void SwapTo(Node next) + { + var parent = GetParent(); + if (parent is null) return; + foreach (Node sibling in parent.GetChildren()) + if (sibling != this) sibling.QueueFree(); + parent.AddChild(next); + QueueFree(); + } + + private void ShowError(string error) + { + _stageLabel.Text = "ERROR — press Escape to return to title"; + _progressBar.Value = 0; + // Crop to the first line + 100 chars so the title label stays legible. + int newline = error.IndexOf('\n'); + string headline = newline > 0 ? error[..newline] : error; + _titleLabel.Text = headline.Length > 100 ? headline[..100] + "…" : headline; + + try + { + string logPath = ProjectSettings.GlobalizePath("user://worldgen_error.log"); + File.WriteAllText(logPath, $"[{DateTime.Now:u}] WorldGen ERROR\n{error}\n"); + GD.PushError($"[worldgen] Wrote {logPath}"); + } + catch { /* best-effort */ } + } + + public override void _UnhandledInput(InputEvent @event) + { + if (@event is InputEventKey { Pressed: true, Keycode: Key.Escape }) + { + _cts?.Cancel(); + BackToTitle(); + } + } + + public override void _ExitTree() + { + _cts?.Cancel(); + _cts?.Dispose(); + _cts = null; + } + + private void BackToTitle() + { + var session = GameSession.From(this); + session.ClearPending(); + session.Ctx = null; + SwapTo(new TitleScreen()); + } + + private void CompareStageHashes(SaveHeader savedHeader) + { + if (_ctx is null) return; + int mismatches = 0; + foreach (var kv in _ctx.World.StageHashes) + { + if (!savedHeader.StageHashes.TryGetValue(kv.Key, out var sv)) continue; + string current = $"0x{kv.Value:X}"; + if (!string.Equals(sv, current, StringComparison.OrdinalIgnoreCase)) + { + mismatches++; + GD.PushWarning($"[save-migration] Stage '{kv.Key}' hash drift: saved={sv}, current={current}"); + } + } + if (mismatches > 0) + GD.PushWarning($"[save-migration] {mismatches} stage(s) drifted; loading anyway (soft)."); + } +} diff --git a/Theriapolis.Godot/project.godot b/Theriapolis.Godot/project.godot index 4139a08..65e4501 100644 --- a/Theriapolis.Godot/project.godot +++ b/Theriapolis.Godot/project.godot @@ -21,6 +21,12 @@ window/size/mode=3 window/stretch/mode="canvas_items" window/stretch/aspect="expand" +[autoload] + +; M7.1 — cross-scene state (seed, post-worldgen ctx, pending character, +; pending save snapshot). See GameSession.cs and the M7 plan §4.3. +GameSession="*res://GameSession.cs" + [dotnet] project/assembly_name="Theriapolis.Godot" diff --git a/theriapolis-rpg-implementation-plan-godot-port-m7.md b/theriapolis-rpg-implementation-plan-godot-port-m7.md new file mode 100644 index 0000000..bec762b --- /dev/null +++ b/theriapolis-rpg-implementation-plan-godot-port-m7.md @@ -0,0 +1,1054 @@ +# Theriapolis — Godot Port — M7 — Design & Implementation Plan +## Play loop screens: WorldGen progress, PlayScreen (seamless world+tactical), Pause, Save/Load, Interaction + +**Status:** Proposed (drafted 2026-05-10). +Targets the codebase state at the close of **M6.21** on the `port/godot` +branch: + +- M6 (Title + character creation) shipped through M6.21 — TitleScreen, + Wizard (8 steps), CodexTheme (dark only), CodexCard / CodexStepper / + CodexPopover widgets, `CharacterAssembler.TryBuild` producing a + runtime `Character` and dumping `user://character.json`. +- M2 + M4 (world + tactical render) shipped as the `WorldView` demo + scene (`Theriapolis.Godot/Rendering/WorldView.cs`), runnable via the + `--world-map ` and `--tactical ` CLI flags. It + generates the world inline in `_Ready`, holds the player position as + a plain `Vec2`, and has no save / NPC / encounter / interact wiring. +- `Theriapolis.Game/Screens/*` is still authoritative for play-loop + *behaviour* (see §3) — every screen we port wraps Core APIs that the + MonoGame screens already proved. + +**Audience:** the agent who will land M7. Read §3 (the per-screen +behaviour table) and §6 (PlayScreen architecture) before writing code; +PlayScreen is ~60% of the milestone and the only screen that needs new +plumbing rather than a port. Read §10 (risks) before committing to a +sub-milestone date. + +**Governing docs:** + +- `theriapolis-rpg-implementation-plan-godot-port.md` §5.M7 — the + one-page outline this document expands. The six screens listed there + (WorldGenProgress, WorldMap, Play, PauseMenu, SaveLoad, Interaction) + set the scope; the exit criterion (load-walk-save-reload bytes-identical + against the MonoGame build) is binding. +- `theriapolis-rpg-implementation-plan.md` §12 — the binding hard rules. + No engine code in `Theriapolis.Core`, all RNG via `SeededRng`, all + magic numbers in `Constants.cs`, determinism contract, linear-feature + exclusion. The port changes the rendering host, not the rules. +- `theriapolis-rpg-implementation-plan-phase4.md` §3.1 (coordinate model), + §3.4 (chunk streaming), §4 (camera + view-mode swap). PlayScreen + inherits this contract verbatim from `Theriapolis.Game/Screens/PlayScreen.cs`. +- `theriapolis-rpg-implementation-plan-phase5.md` §3.4 (encounter + lifecycle + mid-combat save), §4.4 (Resolver), §4.6 (DangerZone). M7 + must round-trip the encounter snapshot through save/load even though + combat HUD is M8 territory — a save written mid-fight by the MonoGame + build has to load in the Godot build. +- `theriapolis-rpg-implementation-plan-phase6.md` §4.4 (quest engine), + §3.2 (no-scene-swap doctrine for buildings — preserved here), + §11 (deviations). +- `theriapolis-rpg-implementation-plan-phase6-5.md` §3 (level-up flow, + SubclassId stamping, feature pool refills), §11 (deviation table). + PlayScreen needs to surface "Level Up" affordances from the pause menu + exactly as MonoGame's `PauseMenuScreen` does. +- `theriapolis-rpg-implementation-plan-phase7.md` §3.1 (dialogue + runner contract — `start_quest` / `open_shop` / `set_flag` effects), + §3.4 (mid-combat save). InteractionScreen is the only M7 screen that + touches Phase-7 systems; shop + combat HUD slip to M8. +- `CLAUDE.md` "Seamless Zoom Model" — the world-map vs. tactical + distinction is a *zoom range*, not a screen swap. This is the reason + §6 collapses §M7's six screens to **four scenes** plus one camera + mode. + +**All hard rules from the original plan §12 remain in force.** + +--- + +## 1. Goals & non-goals + +### Goals + +1. **Wire `Theriapolis.Core` into a playable loop inside Godot.** A + character built by M6 reaches a generated world, walks around at + both zoom scales, opens dialogue with an NPC, saves, loads, and + quits to title — without any MonoGame artefact in the chain. +2. **Save-format parity with the MonoGame build.** `SaveCodec` is in + Core and untouched. A save written by `Theriapolis.Desktop` loads + in the Godot build with byte-identical replay (player position, + clock, killed-spawn-indices, quest state, reputation, mid-combat + encounter). Vice versa. SAVE_SCHEMA_VERSION does not bump. +3. **Adopt the codex design system established in M5–M6 across every + M7 screen.** HUD, pause overlay, save-slot picker, dialogue history + all draw from `CodexTheme.Build()` + the dark palette + the M5 + widgets. No bespoke styling per screen. +4. **Refactor the M2+M4 `WorldView` into a *re-usable* world node.** + The demo scene exists; the play screen wraps the same node with + the character/clock/streamer/save layer added on top. Worldgen + moves out of `WorldView._Ready` so the progress screen can drive + it on a background thread. +5. **Behavioural parity for every shipped MonoGame screen this + milestone covers** — see §3 for the per-screen contract. +6. **All tests still green.** `dotnet test` runs unchanged; the + architecture test continues to forbid `Microsoft.Xna` *and* + `Godot.*` namespaces inside `Theriapolis.Core`. + +### Non-goals + +- **No combat HUD.** `CombatHUDScreen` is M8. M7's encounter handling + is limited to *detection* (a hostile entering the trigger ring) and + *save-snapshot round-trip*. When a fight would start, M7 either + pushes a stub "TODO M8" panel (preferred) or refuses to start and + prints a console diagnostic — the user's call during the milestone. +- **No shop screen.** `open_shop` dialogue effects are swallowed by + InteractionScreen with a "TODO M8" toast. +- **No inventory / level-up / quest log / reputation / defeated + screens.** All M8. Pause-menu "★ Level Up" affordance is rendered + *disabled* (with eligible-state tooltip) until M8 lands the + LevelUpScreen. +- **No new gameplay.** No tuning, no balance, no new dialogue trees, + no new world content. If a play-test reveals a bug in Core, file it; + don't fix it under M7's hood unless it blocks the load/save parity + test. +- **No isometric tactical view.** Captured in + `memory/project_isometric_tactical_view.md` as a future exploration; + M7 ships orthographic tactical because that's what M4 ships. + +--- + +## 2. Why this is the longest M-milestone + +§M7 is budgeted 5 days in the godot-port plan, but it is the first +milestone where: + +- Worldgen runs *as part of the user's session* (not from a CLI + oneshot or a demo entry point); +- A live `Character` from M6 has to be attached to a spawned actor; +- The Save system round-trips for the first time on the Godot side; +- The dialogue runner (Phase 6 M3) runs against live Core state. + +Each of those is independently low-risk because the Core API is +already proven by `Theriapolis.Game/Screens/*`. The risk is in their +*composition* — the order PlayScreen calls them and the lifecycle of +the actor/streamer/encounter trio. That risk is concentrated in §6. + +--- + +## 3. Per-screen behavioural contract + +Each row is a one-line restatement of what the MonoGame screen does; +the Godot port preserves the *behaviour*, not the implementation. + +| MonoGame screen | LOC | M7 Godot equivalent | Notes | +|---------------------------------|-----:|----------------------------------|-------| +| `WorldGenProgressScreen.cs` | 196 | `Scenes/WorldGenProgressScreen.cs` | Background-thread worldgen + per-stage progress bar. Transitions to `PlayScreen` on completion. | +| `WorldMapScreen.cs` | 188 | **Folded into `PlayScreen` as the zoomed-out camera mode** — see §4.2 below. Not a separate scene. | +| `PlayScreen.cs` | 908 | `Scenes/PlayScreen.cs` (the big one) | Wraps the M2+M4 `WorldView`; adds player actor, clock, controller, chunk streamer, save layer, HUD, save-toast, F-to-talk, autosave hooks. | +| `PauseMenuScreen.cs` | 195 | `Scenes/PauseMenuScreen.cs` | Popup overlay; Resume / Save / Quicksave / Quit. Level-Up entry stays disabled until M8. | +| `SaveLoadScreen.cs` | 143 | `Scenes/SaveLoadScreen.cs` | Slot picker. Read-only header parse. Pushed by Title (load) and by Pause (save-as). | +| `InteractionScreen.cs` | 395 | `Scenes/InteractionScreen.cs` | Dialogue history + numbered options + scent-literacy overlay. Phase-6 `DialogueRunner` unchanged. | +| `ScreenManager.cs` | 68 | **Replaced by Godot scene tree** — see §4.1 below. | +| `IScreen.cs` | 26 | **Deleted**. The Initialize/Deactivate/Reactivate lifecycle is subsumed by Godot's `_Ready` / `_ExitTree` / `_EnterTree` + signal wiring. | +| `Platform/SavePaths.cs` | 52 | `Platform/SavePaths.cs` (port verbatim, no MonoGame deps in it today) | +| `Platform/Clipboard.cs` | ~30 | `Platform/Clipboard.cs` — replace TextCopy calls with `DisplayServer.ClipboardSet` / `ClipboardGet`. | + +**Out of M7 scope (deferred to M8 per godot-port §M7 vs §M8 split):** +`CombatHUDScreen`, `InventoryScreen`, `LevelUpScreen`, `ShopScreen`, +`QuestLogScreen`, `ReputationScreen`, `DefeatedScreen`, +`DungeonScreen` (Phase-7 surface). + +--- + +## 4. Architecture + +### 4.1 No ScreenManager — Godot's Control tree replaces it + +`Theriapolis.Game.Screens.ScreenManager` is a Push/Pop stack of +`IScreen` instances with deferred mutation. Godot's scene tree is +already a stack-like hierarchy with proper input/process pause +semantics, so M7 deletes the abstraction outright. + +**Replacement model:** + +- **Top-level swap** (e.g. Title → WorldGenProgress → Play): the + outgoing scene `QueueFree`s itself; the incoming scene is added to + `Main`. TitleScreen already uses this idiom (see + `Scenes/TitleScreen.cs:OnNewCharacter` and `SwapBackToTitle`). +- **Overlay** (Pause, SaveLoad, Interaction): added as a *child* + `CanvasLayer` of the current scene at `Layer = 50` (UI, above world + layers but below `PopoverLayer.Layer = 100`). Overlays set + `process_mode = WhenPaused` on themselves and call + `GetTree().Paused = true` on enter, `false` on exit. PlayScreen's + `_Process` and `_PhysicsProcess` halt automatically while paused. +- **Popup-within-overlay** (slot picker inside Pause): the second + overlay is a *child* of the first; closing it just `QueueFree`s + the child. No global "stack" state is needed. + +**Tree-paused doctrine:** Pause/SaveLoad/Interaction all run under +`process_mode = WhenPaused` so they keep responding to input while +the game clock halts. Sub-controls inside them inherit `Inherit` +(the default), so the rule cascades automatically. + +### 4.2 The seamless-zoom collapse + +`CLAUDE.md`'s "Seamless Zoom Model" — one `Camera2D` covers world and +tactical via continuous zoom — means there is no observable transition +between "the world map" and "the play view". The MonoGame split into +`WorldMapScreen` + `PlayScreen` predated the seamless model and now +exists only because MonoGame's `Game1` couldn't easily mode-swap the +input handler. + +**M7 collapses the two.** PlayScreen owns the whole zoom range. The +zoomed-out view is the same scene with the camera at a low zoom +factor; the HUD overlay adapts (the "tactical cursor read-out" block +hides at low zoom; the "click-to-travel" hint shows). This is the +contract the godot-port plan §4.4 already lays out — the M7 doc just +makes it explicit that there is no `WorldMapScreen.tscn`. + +`WorldView` (the M2+M4 demo) stays as a *standalone* scene for +debugging — it generates a world and lets you fly around without +character or save state. PlayScreen does *not* extend or compose +WorldView; instead, the layer-building code (biome image, polylines, +bridges, settlements, chunk streamer wiring) is **extracted into a +reusable `Rendering/WorldRenderNode.cs`** that both PlayScreen and +WorldView mount. See §6.2. + +### 4.3 GameSession autoload + +Game-wide state that outlives any single scene goes into an autoload +singleton, registered in `project.godot` as `GameSession`: + +```csharp +namespace Theriapolis.GodotHost; + +public partial class GameSession : Node +{ + public ulong Seed; + public WorldGenContext? Ctx; // post-worldgen + public Character? PendingCharacter; // M6 hand-off + public string PendingName = "Wanderer"; + public SaveBody? PendingRestore; // load-from-slot hand-off + public SaveHeader? PendingHeader; +} +``` + +**Why an autoload, not a static class?** Godot autoloads participate +in the scene tree lifecycle — `_Ready` runs once at engine start, the +node is reachable from any scene via `GetNode("/root/GameSession")`, +and the engine guarantees teardown order. `CharacterAssembler.LastBuilt` +(a static) works for M6 because nothing else owns the character; once +PlayScreen exists, the live `Character` flips between "the M6 draft" +and "the actor's component", so an autoload that *both* read from is +the cleaner cut. + +**Hand-off contract:** + +- TitleScreen → WorldGenProgressScreen: `Seed` set (12345 default for + M7; a seed-entry UI is M8 territory). +- M6 wizard → WorldGenProgressScreen: `PendingCharacter`, `PendingName` + set after `CharacterAssembler.TryBuild`. The wizard pushes + `WorldGenProgressScreen` instead of returning to Title. +- SaveLoadScreen (load) → WorldGenProgressScreen: `PendingRestore`, + `PendingHeader`, `Seed` set from the deserialised header. +- WorldGenProgressScreen → PlayScreen: `Ctx` set, plus whichever of + `PendingCharacter` / `PendingRestore` was used. PlayScreen consumes + the pending* fields and clears them. + +The static `CharacterAssembler.LastBuilt` continues to exist for +diagnostic / unit-test access; the wizard writes both. + +### 4.4 The architecture test + +No change required from M6 — the M0/M1 update to +`Architecture/CoreNoDependencyTests.cs` (forbid `Godot.*` in addition +to `Microsoft.Xna`) already covers M7. The test must remain green at +every commit in this milestone. + +### 4.5 Determinism + +`Theriapolis.Core` is the deterministic boundary. M7 must not +introduce a single `System.Random()`, `DateTime.Now`-seeded RNG, or +ad-hoc `XorShift` in the Godot project. All RNG (e.g. for the +save-flash toast colour cycle if we add one) goes through +`Theriapolis.Core.Util.SeededRng` with a sub-stream declared in +`Constants.cs`, **even though presentation RNG doesn't have to be +deterministic** — keeping the discipline removes the "is this RNG +load-bearing?" judgment call from every future PR. + +Per-frame `_Process` jitter, animation timing, and input-driven +camera pan are non-deterministic by definition and don't go through +SeededRng — those affect rendering only and never feed back into +Core state. + +--- + +## 5. WorldGenProgressScreen + +### 5.1 Behaviour + +Direct port of `Theriapolis.Game/Screens/WorldGenProgressScreen.cs`. +Three states: + +1. **Generating:** progress bar + "Stage N of 23: HydrologyGenStage" + label, updated from `ctx.ProgressCallback` on a background thread. +2. **Complete:** transitions to `PlayScreen`. If `_savedHeader` is set, + compare stage hashes (soft warning, not a hard fail — same as the + MonoGame source). +3. **Error:** display the exception, halt on Escape. Write the full + trace to `user://worldgen_error.log` for post-mortem. + +### 5.2 Layout + +Centre-screen `VBoxContainer` mounted in a `Control` filling the +viewport. Three labels: + +- Title: `"Generating world... (seed: 0x{seed:X})"` — `CodexTitle` + variation. +- Progress bar: a `ProgressBar` with `MinValue=0`, `MaxValue=1` and + custom theme stylebox to match the codex (parchment-rule outline + + gild fill). A textual "[#### ] 40%" fallback is not needed + because `ProgressBar` is Godot-native. +- Stage label: the active stage name, body-text size. + +Dark theme only (M5 contract); no theme switcher. + +### 5.3 Threading + +```csharp +public override void _Ready() +{ + BuildUI(); + StartGeneration(); // spawns System.Threading.Tasks.Task +} + +public override void _Process(double delta) +{ + // Pump progress + completion check from the worker into UI. + if (_error is not null) { ShowError(); return; } + if (_complete) { Transition(); return; } + _progressBar.Value = _progress; + _stageLabel.Text = _stageName; +} +``` + +Same volatile-field hand-off pattern as the MonoGame source. Godot's +`_Process` runs on the main thread; the worker only mutates +`volatile float _progress` / `volatile string _stageName` / `volatile +bool _complete` / `volatile string? _error`. No locks needed. + +### 5.4 Transition + +```csharp +private void Transition() +{ + var session = GetNode("/root/GameSession"); + session.Ctx = _ctx; + SwapTo(new PlayScreen()); +} +``` + +`SwapTo` is the same idiom TitleScreen uses: clear sibling nodes, +add the new scene, `QueueFree` self. + +### 5.5 Edge cases + +- **Escape during generation:** queue a cancellation token and check + it from the worker. Worldgen stages are non-cancellable mid-stage, + so the cancel is honoured at the next stage boundary (≤ 2s in + practice). On cancel, return to Title. +- **App quit during generation:** Godot calls `_ExitTree` before + shutdown; pass the cancellation token through `_ExitTree` so the + worker exits cleanly. Worldgen drops its half-built `WorldState` + for GC; no on-disk state to clean up. +- **Stage-hash mismatch:** log via `GD.PushWarning` for the same + "soft warning" semantics as the MonoGame source. Do not block load. + +--- + +## 6. PlayScreen + +The hinge of M7. ~60% of the milestone's effort lives here. + +### 6.1 Scope + +PlayScreen owns: + +- The Camera2D + zoom range (seamless world ↔ tactical). +- The biome backdrop, polylines, bridges, settlements (M2+M4 layers + via `WorldRenderNode`). +- The chunk streamer + tactical chunk nodes (M4 work, currently in + `WorldView`). +- The player actor (from M6's `CharacterAssembler` or from a save). +- NPC actors (spawned by chunk-load events; despawned on chunk + eviction). +- The world clock. +- The player controller (mouse-click travel, WASD step). +- The HUD overlay (codex-styled; see §6.7). +- The interact prompt + dialogue push (F). +- The autosave / quicksave / save-as plumbing. +- The encounter trigger detector (Phase 5 M5) — but the *push to + CombatHUD* is M8. + +### 6.2 WorldRenderNode extraction + +Move from `WorldView` to a new `Rendering/WorldRenderNode.cs` the +following purely-rendering work: + +- `BuildBiomeSprite` (the 256×256 biome image) +- `BuildPolylines` (rivers/roads/rails) +- `BuildBridges` +- `BuildSettlements` +- The `_scaledLines` list + `UpdateZoomScaledNodes` per-frame width + recalc +- `UpdateLayerVisibility` (tactical vs settlements hide thresholds) +- Chunk streamer ownership + `AddChunkNode` / `RemoveChunkNode` +- `StreamIfTactical` (driven by an external "current zoom" reading) + +What stays in `WorldView` (the demo): the standalone-mode entry +point, the demo player marker, the standalone movement code. + +What stays *outside* `WorldRenderNode` and lives in PlayScreen +instead: the `ActorManager` driving live player + NPC sprites, the +`PlayerController`, the clock tick, the save layer, the HUD. + +**Signal surface** of `WorldRenderNode` (the cuts between rendering +and game state): + +```csharp +[Signal] public delegate void ChunkLoadedEventHandler(int cx, int cy); +[Signal] public delegate void ChunkEvictingEventHandler(int cx, int cy); + +public void Initialize(WorldGenContext ctx, ChunkStreamer streamer); +public void SetPlayerPosition(Vec2 worldPx); // for streamer follow +public void SetZoomTier(ZoomTier tier); // controls layer visibility +public Camera2D Camera { get; } // PlayScreen reads this +``` + +`ChunkStreamer` ownership moves *out* of `WorldView` and into +PlayScreen — PlayScreen needs to subscribe to chunk events to know +when to spawn/despawn NPCs (per Phase 5 M5), so the streamer is +better owned at the game-state layer and *injected* into +`WorldRenderNode`. + +### 6.3 Initialisation order + +This is the order that matters; deviating from it breaks restore- +from-save in subtle ways. The MonoGame source (`PlayScreen.Initialize`, +lines 145–233) is the reference contract. + +``` +1. ctx = GameSession.Ctx (already worldgen-complete) +2. Build Camera2D node, attach to scene tree +3. Build WorldRenderNode, hand it ctx +4. Build content resolver: ContentResolver(ContentLoader(dataDir)) +5. Build chunk streamer: new ChunkStreamer(seed, world, deltas, + content.Settlements) +6. Build ActorManager + WorldClock + InMemoryChunkDeltaStore +7. Build AnchorRegistry; RegisterAllAnchors(world) +8. Build QuestContext (Phase 6 M4 — wraps content/actors/rep/flags/...) +9. Wire chunk events: streamer.OnChunkLoaded += HandleChunkLoaded; + streamer.OnChunkEvicting += HandleChunkEvicting +10. If restore: + ApplyRestoredBody(GameSession.PendingRestore) + else if new game from M6: + actor = ActorManager.SpawnPlayer(spawn, PendingCharacter) + actor.Name = PendingName + else: + actor = ActorManager.SpawnPlayer(ChooseSpawn(world)) +11. Build PlayerController; wire TacticalIsWalkable to streamer.SampleTile +12. Centre camera on player; pick a comfortable initial zoom +13. Build HUD overlay (§6.7) +14. If restore had a pending encounter: + streamer.EnsureLoadedAround(player.Position, TACTICAL_WINDOW_WORLD_TILES) + RestoreEncounter(...) — pushes the M8 combat overlay; M7 stubs +``` + +Steps 1–13 are deterministic and stage-ordered exactly as MonoGame's +`Initialize`. Step 14 is the deferred mid-combat restore that Phase 5 +M5 introduced; M7 wires it but the M8 CombatHUD push is the stub. + +### 6.4 PlayerController port + +`Theriapolis.Game/Input/PlayerController.cs` — verify it has no +MonoGame deps (it shouldn't; movement is in world-pixel space and the +input adapter feeds it `Vec2` deltas). Port the small wrapper that +reads `Input.IsKeyPressed(Key.W)` etc. into a `PlayerInputAdapter` and +hand it to the controller. Click-to-travel uses Godot's +`InputEventMouseButton` events routed through `_UnhandledInput` — +PlayScreen converts screen→world via the camera (the same +`ScreenToWorld` math as MonoGame, just `camera.GetCanvasTransform()` +on the Godot side). + +**Key bindings (provisional; final `InputMap.tres` lands in M9):** + +- W A S D / Arrows: pan camera at low zoom, step actor at tactical zoom. +- Left-drag: pan camera. +- Mouse-wheel: zoom (towards cursor) — already implemented in + `PanZoomCamera`. +- Left-click on tile: world-map mode → request travel to tile. +- F: talk to interact candidate (push InteractionScreen). +- Tab: open inventory — **disabled in M7** (toast "Inventory ships + with M8") so the binding is reserved but not dead. +- R: open reputation — **disabled in M7**. +- J: open quest journal — **disabled in M7**. +- F5: quicksave to autosave slot. +- Esc: push PauseMenuScreen. + +### 6.5 Save / load round-trip + +`SaveCodec` / `SaveHeader` / `SaveBody` are in +`Theriapolis.Core.Persistence` and untouched. `SavePaths` is the only +piece that ports — move from `Theriapolis.Game/Platform/SavePaths.cs` +to `Theriapolis.Godot/Platform/SavePaths.cs`, no API changes. The +default save directory (`%LOCALAPPDATA%/Theriapolis/Saves` on Windows, +`~/Library/Application Support/Theriapolis/Saves` on macOS, +`$XDG_DATA_HOME/Theriapolis/saves` on Linux) is **deliberately +identical to MonoGame's**, so a save written by either build is +discoverable by the other. This is the binding constraint behind the +M7 exit criterion (load-walk-save-reload bytes-identical). + +**Save body capture** (`CaptureBody`, PlayScreen.cs:351–392) ports +verbatim — every line is Core API. + +**Save body restore** (`ApplyRestoredBody`, PlayScreen.cs:297–348) +likewise verbatim. The one wrinkle is the deferred encounter restore: +`_pendingEncounterRestore` is set in step 10, but the actual call to +`RestoreEncounter` happens after `EnsureLoadedAround` so the NPC +actors that the encounter references exist. The MonoGame source does +this at the end of `Initialize`; M7 does the same. + +**Save flash toast** — when a save completes (or fails), surface a +2.5-second floating label at the bottom-centre of the screen. M7 uses +a `Label` + `Tween` to fade alpha; sized to the codex body font. The +toast text mirrors the MonoGame source exactly: + +- "Saved to slot_03.trps" on slot save +- "Quicksaved." on F5 +- "Save failed: {message}" on exception + +### 6.6 Encounter / interact tick + +Per-tick logic (`TickEncounterAndInteract`, PlayScreen.cs:455–489) +runs only when `camera.zoom >= TacticalRenderZoomMin` (the M4 +threshold), per the MonoGame source's `_camera.Mode == ViewMode.Tactical` +guard. PlayScreen reads the current zoom from `WorldRenderNode.Camera` +each tick. + +- **Quest engine tick.** `_questEngine.Tick(_questCtx)`. Cheap, runs + every frame in tactical mode. +- **Faction aggression update.** + `FactionAggression.UpdateAllegiances(...)`. Same. +- **Hostile detection.** `EncounterTrigger.FindHostileTrigger(actors)` + — returns the closest hostile in the trigger ring, or null. +- **Interact candidate.** `EncounterTrigger.FindInteractCandidate(actors)` + — friendly/neutral in the interact ring. + +On hostile detection, M7's **stub**: + +```csharp +GD.Print($"[encounter] Would start fight with {hostile.DisplayName}"); +ShowToast("Combat HUD lands with M8 — encounter logged."); +// Save the autosave anyway so M8 testing has fresh combat starts. +SaveTo(SavePaths.AutosavePath()); +``` + +(The exact stub form — whether to halt the actor, prevent further +movement, or just log — is a user call at milestone kickoff. Default +proposal: log + autosave + allow movement to continue, so M7 testing +isn't blocked by every wolf encounter freezing the screen.) + +On interact-candidate F-press: push InteractionScreen as a +`CanvasLayer` overlay. The play tree pauses; the dialogue handles its +own input. + +### 6.7 HUD overlay + +The MonoGame HUD is a single `Label` with a black-180-alpha background +at the top-left: + +``` +{PlayerName} HP {hp}/{max} AC {ac} [encumbered] +Seed: 0x{seed} +Player: ({tx},{ty}) {biome} +Cursor: ({cx},{cy}) ... +View: WorldMap zoom=0.5 +Time: Day 12, 14:32:08 +Click a tile to travel. Mouse-wheel in for tactical. +F5 = Quicksave · TAB = Inventory · ESC = Pause Menu +[F] Talk to Innkeeper Marra (Friendly +6) + clade +2 size 0 faction +1 personal +3 +[ Saved to slot_03.trps ] +``` + +The Godot port preserves the same content but applies codex styling: + +- Mount as a child `CanvasLayer` (`Layer = 50`) so it floats above + the world but below popovers and pause overlays. +- Anchor a `PanelContainer` to the top-left, `MarginContainer 12px`, + `StyleBoxFlat` from the codex theme's `Card` variation but with the + dark palette's `Bg2` colour at 0.78 alpha (mirrors the MonoGame + black-180 background). +- Body text uses the `CardBody` variation (Crimson Pro). Lines that + show key bindings use `Eyebrow` (smaller, ink-mute). +- The interact prompt block animates in/out via alpha tween when the + candidate changes — same data, just less jarring than instant. + +Save-flash toast is a *separate* `Label` mounted at bottom-centre, +NOT inside the HUD panel — keeps the HUD's bounds stable when the +toast appears. + +### 6.8 Zoom-mode UI changes + +At zoomed-out (world-map) zoom levels: +- Cursor read-out shows `Tile (tx, ty)`, no tactical surface info. +- "Click a tile to travel" hint visible. +- Tactical chunk nodes hidden by `WorldRenderNode.UpdateLayerVisibility`. + +At zoomed-in (tactical) zoom levels: +- Cursor read-out shows `Tile (cx, cy) Surface: grass (v2) Deco: shrub Move: walkable`. +- "WASD to step" hint visible. +- Settlement dots hidden; tactical chunks visible. +- Encounter/interact tick runs. + +The transition is continuous (the camera's `Zoom.X` is a float, no +threshold flip); only the *hint text* and the *streamer activity* +gate on the zoom range. The MonoGame `ViewMode` enum can be ported +verbatim or replaced with `float Camera.Zoom >= THRESHOLD` direct +reads; M7 chooses **direct reads** because there is no Godot-side +abstraction to maintain. + +### 6.9 What PlayScreen does NOT do + +For clarity (and to keep the §6 surface bounded): + +- It does not own the inventory UI — `Tab` opens nothing in M7. +- It does not run level-up — Pause menu's "★ Level Up" button is + rendered disabled with a "Available in M8" tooltip. +- It does not push CombatHUD — see §6.6 stub. +- It does not own the world-gen pipeline — that lives in + WorldGenProgressScreen. +- It does not own the dialogue runner — that lives in + InteractionScreen. PlayScreen only pushes the overlay; the overlay + reads back via the same `GetParent() as PlayScreen` pattern the + MonoGame source uses. + +--- + +## 7. PauseMenuScreen + +### 7.1 Behaviour + +Direct port of `Theriapolis.Game/Screens/PauseMenuScreen.cs`. Two +sub-states: main menu and slot picker. ESC backs out of slot picker +to main, then closes the overlay. + +### 7.2 Layout + +Mount as a `CanvasLayer` child of PlayScreen with `Layer = 50` and +`process_mode = WhenPaused`. On enter: `GetTree().Paused = true`. +On exit: `GetTree().Paused = false`. + +The main panel is a centred `PanelContainer` over a half-opaque +black backdrop (so the world is still legibly visible behind it). +Button stack reuses M6's `MakeMenuButton(text, primary)` from +`TitleScreen.cs:96`. + +Button rows: + +1. **Resume** (primary) — pops the overlay. +2. **★ Level Up (N → N+1)** — visible only when + `LevelUpFlow.CanLevelUp(pc)` returns true. **Disabled in M7** with + a tooltip "Level-up screen ships with M8". +3. **Save Game** — flips to the slot picker sub-state. +4. **Quicksave (autosave slot)** — calls `playScreen.SaveTo(AutosavePath())`. +5. **Quit to Title** — autosaves first (matches MonoGame), then + double-pop (Pause → Play → Title). + +Status label below the button stack displays the most recent +"Quicksaved." / "Save failed." string for 2.5 seconds (same timer +as the in-HUD flash). + +### 7.3 Slot picker sub-state + +Replace the panel's `VBoxContainer` contents with the slot list: + +- `for i in 1..C.SAVE_SLOT_COUNT`: row label = "Slot 02 — Folio II, + Hightown, Day 12 14:32". Reads the slot's header via + `SaveCodec.DeserializeHeaderOnly(bytes)`; failing reads label + "Slot 02 — ". Empty slots label "Slot 02 — ". +- Click writes via `playScreen.SaveTo(SavePaths.SlotPath(i))`, shows + the toast, returns to the main panel. +- Back button restores the main panel. + +### 7.4 Edge cases + +- **ESC during slot picker:** back to main panel, not close. +- **ESC during main:** close overlay (resume). +- **Pause-while-paused:** PlayScreen's Esc handler is gated on + `GetTree().Paused == false`; pause-while-paused is impossible. +- **Quit-to-Title autosave failure:** still proceed to Title (the + MonoGame source does this) — the user's intent is "leave" and a + blocked exit is worse than a blocked save. + +--- + +## 8. SaveLoadScreen + +### 8.1 Behaviour + +Read-only slot picker for *load* (M7 scope). Pushed by TitleScreen's +"Continue" entry point — which today is a `GD.Print` stub +(`TitleScreen.cs:OnContinue`) and gates on +`FileAccess.FileExists(CharacterAssembler.PersistedStatePath)`. M7 +replaces both: + +- "Continue" enables when *any* slot under `SavesDir` has a + compatible header — `Directory.EnumerateFiles(savesDir, "*.trps")` + + `SaveCodec.IsCompatible(header)`. +- "Continue" push goes to SaveLoadScreen, not to a stub print. + +### 8.2 Layout + +Same `CanvasLayer` overlay pattern as Pause. Heading "LOAD GAME" +(`CodexTitle`), then a `VBoxContainer` with one row per slot: + +- Autosave row first. +- `Slot 01..C.SAVE_SLOT_COUNT` after. +- Each row: a `Button` (Card variation) with the slot label, disabled + when empty/unreadable/incompatible. +- Footer: Back button → pop overlay (back to Title). + +### 8.3 Slot label format + +Exactly mirrors MonoGame: `header.SlotLabel()` is a Core method that +formats `"{PlayerName} · Folio {Tier}, Day {DayN} {Time}"`. M7 calls +it unchanged. + +### 8.4 Load flow + +```csharp +private void LoadSlot(string path) +{ + var bytes = File.ReadAllBytes(path); + var (header, body) = SaveCodec.Deserialize(bytes); + if (!SaveCodec.IsCompatible(header)) { /* error label */ return; } + + var session = GetNode("/root/GameSession"); + session.Seed = header.ParseSeed(); + session.PendingRestore = body; + session.PendingHeader = header; + + // Swap Title → WorldGenProgress → PlayScreen. + var main = GetTree().Root.GetNode("Main"); + foreach (Node c in main.GetChildren()) c.QueueFree(); + main.AddChild(new WorldGenProgressScreen()); + QueueFree(); +} +``` + +### 8.5 Save-from-game + +Out of scope here — that path lives inside Pause (§7.3). Splitting +save-from-title and save-from-game keeps each picker single-purpose. + +--- + +## 9. InteractionScreen + +### 9.1 Behaviour + +Direct port of `Theriapolis.Game/Screens/InteractionScreen.cs` (395 +lines, biggest text-rendering surface after character creation). + +### 9.2 Layout + +`CanvasLayer` overlay (`Layer = 50`, `process_mode = WhenPaused`). +Centre panel ~760 px wide; three vertical zones: + +1. **Header** — NPC name (`CodexTitle`-mid), role line ("Innkeeper of + Millhaven"), bias-profile + disposition tag, optional Scent + Literacy overlay (`⊙ Scent: ...`). +2. **History** — last `C.DIALOGUE_HISTORY_LINES` entries from + `_runner.History`, each `Label` with `AutowrapMode = WordSmart` + and a per-speaker text colour: + - NPC: `palette.Ink` + - PC: pale blue (matches MonoGame `Color(170, 200, 220)`) + - Narration: muted green (matches `Color(160, 180, 140)`) +3. **Options** — numbered buttons (`1. ...`, `2. ...`), one per + visible option from `_runner.VisibleOptions()`. Skill-check + options render `[STR DC 12] ...` prefix. Capped at + `C.DIALOGUE_MAX_OPTIONS_PER_NODE` (the Core constant). + +Footer: `"(1-9 to choose · Esc to leave · F also closes)"`. + +### 9.3 DialogueRunner construction + +Same as MonoGame (`TryBuildRunner` at `InteractionScreen.cs:59`): + +```csharp +var ctx = new DialogueContext(npc, pc, playScreen.Reputation, + playScreen.Flags, content) +{ + PlayerWorldTileX = (int)(playerPos.X / C.WORLD_TILE_PIXELS), + PlayerWorldTileY = (int)(playerPos.Y / C.WORLD_TILE_PIXELS), + WorldClockSeconds = playScreen.ClockSeconds(), +}; +return new DialogueRunner(tree, ctx, playScreen.WorldSeed()); +``` + +PlayScreen exposes `Reputation`, `Flags`, `World`, `WorldSeed`, etc. +via internal properties — port the same accessor pattern from the +MonoGame source (lines 62–89). + +### 9.4 Effect routing + +`DialogueRunner.ChooseOption(idx)` mutates the runner's context. M7 +must drain three effect channels after each choice: + +1. **`start_quest`** — `context.StartQuestRequests` holds quest ids to + start. Loop, calling `playScreen.QuestEngine.Start(qid, qctx)` for + each; clear the list. +2. **`open_shop`** — `context.ShopRequested == true` means push the + ShopScreen. **M7 stub:** show a toast "Shop opens with M8 — Marra + waits patiently" and clear the flag. +3. **`set_flag`** / others — already applied to `playScreen.Flags` by + the runner; no work for the screen. + +### 9.5 Input + +- Number keys (1-9, both top-row and numpad): pick option N. +- Enter: dismiss when `_runner.IsOver`. +- ESC or F: close overlay (matches MonoGame). + +Edge-detect ALL key presses — a held key must not fire twice. M7 +mirrors the MonoGame `_numWasDown[10]` array pattern. + +### 9.6 Stub NPCs + +When `_runner is null` (NPC has no dialogue tree), the panel renders +the MonoGame fallback verbatim: "(They have nothing to say yet.)" + +"— No dialogue tree authored for this NPC yet." + a single "1. Goodbye" +button. + +--- + +## 10. Risks + +### High + +- **Save-format parity break.** A subtle Godot-side reorder (e.g. + capturing actor position *after* a Tween animation lerp completes + vs. at tick boundary) could shift a save by one tick and break the + exit-criterion bytes-identical test. *Mitigation:* always capture + from Core data, never from Godot transforms. The MonoGame source + reads `_actors.Player.Position` (a Core `Vec2`), not the sprite's + on-screen position. Port that discipline. +- **Tree-paused gotchas.** Setting `GetTree().Paused = true` halts + `_Process` on every non-`WhenPaused` node, *including* `_Tween` + animations on the HUD. If the save-flash toast tweens its alpha + while paused, it'll freeze mid-fade. *Mitigation:* the toast lives + on the pause overlay's `CanvasLayer` and inherits `WhenPaused`, OR + the toast is implemented via `_Process(delta)` decay on PlayScreen + itself with `process_mode = Always` set only on the toast. Pick one + during M7.1 prototype and stick with it. +- **Mid-combat save restore.** Phase 5 M5's deferred encounter + rehydration (PlayScreen.cs:227–232) only works if the chunk-load + signal fires synchronously when `EnsureLoadedAround` is called from + Initialize. Godot's signal dispatch is synchronous for direct + `EmitSignal` calls within the same frame, so this should work — but + *verify* with a unit-test save covering the encounter-in-progress + case before declaring the exit criterion met. + +### Medium + +- **Multiple input adapters.** PlayerController, the camera pan/zoom + logic, and the overlay screens all read input differently. Without + a single adapter, F5/ESC handling can race the pause overlay's own + ESC handler. *Mitigation:* PlayScreen consumes input only via + `_UnhandledInput` (events propagate from leaf to root, so an open + overlay's `_GuiInput` consumes first). Overlays must `AcceptEvent()` + on the input event they handle. +- **Chunk streamer ownership.** Today the streamer lives in + `WorldView`. Moving it to PlayScreen for M7 means `WorldView` either + (a) still has its own streamer for the demo path or (b) gets + retired in favour of running PlayScreen with a stub character. + Option (a) preserves the demo entry points; option (b) is cleaner + but means `--world-map`/`--tactical` CLI flags lose their meaning. + *Recommendation:* (a) — `WorldView` keeps its own streamer; the + shared code is `WorldRenderNode`, not the streamer. + +### Low + +- **Codex theme parity at low zoom.** The HUD overlay was designed + against tactical-zoom screenshots; at very low zoom, the world is + visually dominated by parchment-ish biome colours that may clash + with the dark palette HUD. *Mitigation:* the HUD's `Bg2` background + is already at 0.78 alpha so the world bleeds through; that should + be enough. + +--- + +## 11. Verification + +### 11.1 Manual exit criterion + +The godot-port plan's binding criterion (§5.M7): + +> "Load a save from the MonoGame build, walk around, save, reload — +> bytes identical." + +Reproduce as follows: + +1. Build `Theriapolis.Desktop` (MonoGame) and `Theriapolis.Godot`. +2. In the MonoGame build, start a new game (seed 12345), name the + character "M7Test", walk three tiles east, F5 quicksave, quit. +3. Verify `autosave.trps` exists in the shared `SavesDir`. +4. Launch the Godot build, click Continue, pick the autosave row, + confirm the character spawns at the expected tile. +5. Walk one tile north in the Godot build, F5 quicksave again. +6. Quit. Launch the MonoGame build. Continue → autosave. Confirm + character is at the expected tile (3E, 1N from spawn). +7. Diff the autosave bytes from step 5 with a fresh autosave taken + from MonoGame after the same input sequence. *They must be + identical.* Any mismatch is a regression. + +A scripted version of this test (driving each build via its CLI +smoke-test flags) would be ideal but is **not** an M7 deliverable — +add to the M9 platform-layer milestone if it's needed for CI. + +### 11.2 Per-sub-milestone tests + +For each sub-milestone in §12, the agent must run `dotnet test` +before committing. The architecture test, every determinism test, +and every existing save-round-trip test must remain green. M7 does +not add new tests to `Theriapolis.Tests` because every screen behaviour +is already covered by the underlying Core tests; the Godot-side +*scene wiring* tests live in the manual exit criterion above. + +If `dotnet test` runs >7 minutes, that's expected — the test suite is +expensive and that's an M7 unrelated concern. + +### 11.3 Smoke tests to add + +`Main.cs` already has a `--smoke-test ` flag (`SmokeTest.Run`, +M0-vintage). Extend it for M7: + +- `--smoke-play ` — boot through TitleScreen → New Character + → all-default wizard → PlayScreen with a 5-second walk; verify no + exception; quit 0. +- `--smoke-load ` — load the given save, walk five tiles, + re-save, verify byte-equality with an oracle run. + +Both are optional — useful for CI but not blocking M7's exit. + +--- + +## 12. Sub-milestones + +5 calendar days at the godot-port plan's pace. Sub-milestones are +incrementally demoable; each ends at a usable state. + +### M7.1 — WorldGenProgressScreen (½ day) + +- Port the MonoGame screen. +- Wire the autoload `GameSession` (§4.3). +- Replace the wizard's M6 hand-off so "Confirm & Begin" pushes + WorldGenProgressScreen with `GameSession.PendingCharacter` set, + not a debug print. +- Demoable: from the wizard, hitting Confirm produces a working + progress bar that runs the 23-stage pipeline and prints the final + WorldState summary. Transition target is a placeholder + `PlayScreenStub` that just labels "PlayScreen lands in M7.2". + +### M7.2 — PlayScreen skeleton + WorldRenderNode extraction (1½ days) + +- Extract `WorldRenderNode` from `WorldView` (§6.2). +- Build PlayScreen with the M6.X hand-off path (new character, no + restore). Player actor spawns, walks, camera follows. No save, no + NPCs yet. +- Wire chunk streamer at PlayScreen level; NPCs spawn from chunk-load + events. No encounter trigger / interact prompt yet. +- HUD overlay shows the player block, seed, tile coords, time, hints. +- Demoable: full Title → Wizard → WorldGen → Play loop with a + walking character. + +### M7.3 — Save / load (1 day) + +- Port `SavePaths` to `Theriapolis.Godot/Platform/`. +- Implement `PlayScreen.SaveTo(path)` (verbatim from MonoGame). +- Implement `ApplyRestoredBody` (verbatim). +- Build SaveLoadScreen (§8). Wire Title → SaveLoadScreen. +- F5 quicksave works in PlayScreen. +- Demoable: save, restart Godot, load, character is back where it + was. Manual byte-diff against MonoGame save passes. + +### M7.4 — PauseMenuScreen + save-from-pause (½ day) + +- Port PauseMenuScreen (§7). +- Slot picker reuses the SaveLoadScreen layout but in *write* mode. + Decide at this point whether to share code or copy — recommend + *copy* because the read vs. write call sites diverge (load swaps + scenes; save stays in place). +- ★ Level Up button visible-but-disabled. +- Demoable: Esc opens pause, Save Game writes to chosen slot, Quit + to Title autosaves and returns. + +### M7.5 — Interact + dialogue (1 day) + +- Port InteractionScreen (§9). +- Wire `EncounterTrigger.FindInteractCandidate` into PlayScreen's + tactical-mode tick. +- Wire F-press → push overlay. +- Implement the `start_quest` drain and the `open_shop` stub toast. +- Demoable: walk up to an NPC, press F, see their dialogue, choose + options, exit. Quest journal entries land in `_questEngine` even + though the journal UI is M8. + +### M7.6 — Polish + parity test (½ day) + +- Save-flash toast (§6.5). +- Encounter-detection stub for hostiles (§6.6). +- Mid-combat save round-trip (§6.3 step 14) — the encounter is + *captured* on save and *restored* on load; the push to CombatHUD + is the M8 stub. +- Run the §11.1 exit-criterion test against the MonoGame build. +- Decide with the user: do we accept the §6.6 hostile stub + (default), or block the milestone on it? + +--- + +## 13. Open questions + +These need a user decision before or during M7 kickoff; M7 ships +under the *default* if no decision is made. + +1. **Hostile-encounter stub form.** Log + autosave + allow movement + (default), or hard-halt the actor at the trigger? +2. **WorldView demo retention.** Keep `--world-map`/`--tactical` + flags as standalone scenes (default), or fold them into + PlayScreen with a `--demo` flag that mounts a stub character? +3. **HUD typography density.** Match MonoGame's tight 7-line block + (default), or break into a top bar + right-rail layout? The + right-rail would echo M6's Aside but costs ~½ day. Recommend + default. +4. **Save-slot picker code sharing.** Share between Title-load and + Pause-save (one widget, two modes), or copy. Recommend *copy* + (see M7.4). + +--- + +## 14. What lands at the end of M7 + +- **One playable session** from Title → Wizard → World → Play, with + save/load round-trip against MonoGame. +- **Four new Godot scenes:** WorldGenProgressScreen, PlayScreen, + PauseMenuScreen, SaveLoadScreen, InteractionScreen. (Five, if you + count InteractionScreen separately from PlayScreen — which §3 does.) +- **One refactor:** `WorldView` → `WorldRenderNode` + a thin demo + shell. +- **One autoload:** `GameSession`. +- **One platform port:** `SavePaths.cs`. +- **No new Core code** beyond what's already in main. + +When this milestone closes, the only screens missing from a complete +play-loop port are the M8 set (Combat HUD, Inventory, Level Up, +Shop, Quest Log, Reputation, Defeated, Dungeon) — each of which has +a working stub or disabled affordance after M7.