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