401 lines
15 KiB
C#
401 lines
15 KiB
C#
|
|
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;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// M7.2 — the play screen. Wraps <see cref="WorldRenderNode"/> 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)
|
||
|
|
/// </summary>
|
||
|
|
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<int, NpcMarker> _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<int>();
|
||
|
|
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();
|
||
|
|
}
|
||
|
|
}
|