Compare commits

...

7 Commits

Author SHA1 Message Date
Christopher Wiebe a802fb318f M7.6: Hostile encounter stub + marker-scale fix — closes M7
EncounterTrigger.FindHostileTrigger now polls each tactical-mode
tick. Edge-detected by NPC actor id: a fresh hostile entering
ENCOUNTER_TRIGGER_TILES range fires the stub exactly once — console
log with name/allegiance/template, save-flash toast "Combat HUD
lands with M8 — encounter logged: {name}". Player keeps moving;
the same hostile won't re-fire until you've left the trigger ring
and come back.

Deviation from the M7 plan §6.6: the plan proposed autosaving on
hostile detect so M8 testing would have fresh combat starts. Wired
that, then walked into a wolf and got a respawn loop — SaveTo →
CaptureBody → _streamer.FlushAll evicts every loaded chunk → NPCs
respawn with fresh actor ids on the next tactical tick → fresh id
breaks edge detection → stub re-fires → autosave again → loop. The
visible symptom was grey untiled chunks and a screen-filling red
blob (an unscaled NPC marker on the spawn frame at zoom 32 renders
at ~307 screen px radius). M8 owns combat-start autosave anyway:
at that point CombatHUDScreen captures combatant state before
FlushAll, so the loop can't form. Removed the SaveTo here; comment
in code records the reason.

Marker scale stamped at construction. NPCs spawn inside _Process
(EnsureLoadedAround → OnChunkLoaded → MountNpcMarker) *after* the
per-frame counter-scale loop has already iterated _npcMarkers, so
a new marker would render at Scale=(1,1) for one frame. New
CounterScaleVec() helper reads the current camera zoom and is
stamped into Scale at marker construction (both PlayerMarker on
spawn/restore and every NpcMarker). The player path also reorders
SetInitialZoom to run BEFORE the player marker constructs so the
counter-scale picks the post-zoom value rather than the fit-zoom
default.

That closes the M7 milestone — title → wizard → worldgen → play →
pause / save / load / dialogue / hostile-stub all wired, only the
M7.4 polish items left were the bugs surfaced in play-testing.
Next: §11.1 cross-build save-bytes parity test (user-driven), then
M8 (combat HUD, inventory, level-up, shop, quest log, dungeon).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 21:39:01 -07:00
Christopher Wiebe 289c918d6c M7.5: Interact prompt + dialogue overlay
InteractionScreen — CanvasLayer overlay (Layer=50, WhenPaused) that
pauses the tree on open and runs a Core DialogueRunner against
playScreen-owned aggregates (Reputation, Flags, ContentResolver,
QuestEngine). Codex-themed centered panel ~760 px with header
(NPC name, role line "Innkeeper of Millhaven" via
LastIndexOf('.')-split TitleCase, bias profile · disposition tag
from EffectiveDisposition.Breakdown, optional Scent Literacy line
for scent_broker / scent_literacy / master_nose feature holders),
history scrollback (last C.DIALOGUE_HISTORY_LINES, per-speaker
text colour for NPC / PC / Narration), numbered option list
(skill checks prefixed [SKILL DC N]), footer hint. Input: 1-9
top-row + numpad, Enter to dismiss when over, Esc / F to leave.
Godot's Key enum is long-backed for unicode + modifier bits, so
the arithmetic cast through int is explicit.

Effect routing on each ChooseOption: drains
runner.Context.StartQuestRequests into _playScreen.QuestEngine.Start
with a freshly-rebound QuestContext (PlayerCharacter pinned each
call) — quest journal UI is M8 but the engine fires immediately.
runner.Context.ShopRequested raises a "Shop ships with M8" toast
via PlayScreen.Toast and clears the flag so re-entry doesn't loop.
Stub NPCs (no dialogue_id, missing tree, or null content) get the
"(They have nothing to say yet.)" panel + Goodbye button — same
fallback the MonoGame source ships.

PlayScreen interact tick. Tactical-mode _Process now polls
EncounterTrigger.FindInteractCandidate(_actors) and caches the
result in _interactCandidate (cleared at world-map zoom). HUD
appends "[F] Talk to {DisplayName}" when non-null. Edge-detected
F press → AddChild(new InteractionScreen(npc, this)), candidate
cleared immediately so a held F can't stack overlays. M8 will
wire real LOS into the FindInteractCandidate losBlocked callback;
M7.5 ships with the default AlwaysClear.

Added internal accessors PlayScreen now surfaces so the overlay
doesn't poke private state: Reputation, Flags, QuestEngine, World,
WorldSeed, ClockSeconds, PlayerPosition, Content,
BuildQuestContextForDialogue(), Toast(text). All scoped internal —
not part of any public API.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 21:19:13 -07:00
Christopher Wiebe b1fc3f244b M7.4b: Top-right cursor-debug panel for play-testing
Mirror of the player-status panel, anchored top-right. Per-frame
sample of GetViewport().GetMousePosition() converted to world coords
via the camera transform; surfaces:

  - World-pixel and world-tile coords under the cursor
  - Biome at that tile
  - Feature flag bitmask (HasRoad / HasRiver / HasRail / IsSettlement
    / IsPoi / IsCoast / RiverAdjacent / RailroadAdjacent)
  - Settlement on the tile via WorldTile.SettlementId lookup —
    name, tier, and economy/governance/pop (or PoI type)
  - Bridge under cursor via point-on-segment hit test against
    world.Bridges (6 px hit radius, ≤ a few dozen bridges so cheap)
  - Tactical block when zoomed in: tactical-tile coords, surface +
    variant, deco, walkability tag (walkable / slow / blocked)
  - NPC under cursor within 12 px hit radius — display name, role
    tag or template id, allegiance, HP

Cached StringBuilder field on PlayScreen (Clear() each frame instead
of new'ing) to keep per-frame GC pressure low. Held keys produce
auto-repeat InputEventKey instances that Godot 4 mono's GC must
collect before engine shutdown; reducing per-frame garbage buys
that collection more headroom and avoided a shutdown-assertion
race observed on the first launch with the panel mounted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 20:49:44 -07:00
Christopher Wiebe 6f47700820 M7.4a: PlayScreen polish — facing tick, road overlap, WASD pan
Facing tick stuck at the initial angle. PlayerMarker._Draw was
computing the tick direction from a FacingAngleRad auto-property,
but Godot caches CanvasItem draw commands and only re-runs _Draw on
QueueRedraw. Setter never called QueueRedraw → tick never rotated.
Fixed by leaning on the Node2D transform instead: tick is drawn
along the local +X axis, PlayScreen sets marker.Rotation = facing
each frame. The transform rotation applies to the cached commands
without re-invoking _Draw — efficient and correct. FacingAngleRad
property removed; ShowFacingTick became a property with QueueRedraw
on change (visibility toggle still needs to invalidate the cache).

Tactical view double-drew roads. TacticalChunkGen.Pass2_Polylines
already bakes roads + rivers + bridges into the surface tiles of
each chunk. WorldRenderNode's Line2D overlay was still visible at
tactical zoom, stroking the same path on top of the rasterised
version — showed as a brown line over every road. Ported the
MonoGame "suppress polyline overlay in tactical" rule into
UpdateLayerVisibility: _polylineLayer and _bridgeLayer hide when
zoom >= TacticalRenderZoomMin.

WASD now pans the world map. Previously WASD did nothing in
world-map mode — only right-drag / middle-drag / mouse-wheel worked.
WASD is now context-sensitive: tactical mode steps the player
(unchanged), world-map mode pans the camera at 400 screen px/sec
(world-pixel speed scales as 1/zoom so the perceived rate stays
constant). Diagonal motion is √2-normalised to match tactical step.
Suppressed during click-to-travel since the camera-follow would
clobber any pan input anyway. HUD hint updated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 19:51:44 -07:00
Christopher Wiebe 116193c1e3 M7.4: Pause menu + save-from-pause, slot rows show wall-clock time
PauseMenuScreen — CanvasLayer overlay on PlayScreen, Layer=50 with
ProcessMode=WhenPaused so it stays responsive while the tree is
paused. _Ready sets GetTree().Paused=true; Resume/Close/QuitToTitle
unpause first. Two sub-states share one VBoxContainer-and-status-label
panel: main menu (Resume / ★ Level Up / Save Game / Quicksave / Quit
to Title) and slot picker. Save Game flips to the picker, click a
slot to write, back returns to main. Esc backs out of picker to main
on first press, closes the overlay on a second. CodexTheme applied
at the panel root since overlays mount outside PlayScreen's Control
tree and theme cascade doesn't cross CanvasLayer boundaries.

Level-up button surfaces only when LevelUpFlow.CanLevelUp(pc)
returns true (matches MonoGame), and is rendered disabled with a
"ships with M8" tooltip — fresh L1 characters won't see it in M7
play-tests.

Quit to Title autosaves first (matches MonoGame). A failed autosave
doesn't block the quit; better to let the user leave than trap them.

Save-from-pause writes to an internal status label inside the panel
rather than PlayScreen's save-flash toast — the toast lives on the
paused tree branch and would freeze mid-fade.

PlayScreen Esc now AddChild(new PauseMenuScreen(this)) instead of
BackToTitle. Added paused-guard + Echo filter in _Input. New public
PlayerCharacter() accessor lets the pause panel call CanLevelUp.
HUD hint updated to "F5 quicksaves · Esc opens pause".

SaveSlotFormat — shared helper between SaveLoadScreen (load picker)
and PauseMenuScreen (save picker) so both surfaces render rows with
the same prefix + in-game time + wall-clock time. Parses
SavedAtUtc, converts to local time, renders relative
(today/yesterday), short (MMM d, HH:mm within year), or full
(yyyy-MM-dd HH:mm), with "<unknown>" fallback for empty headers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 19:14:24 -07:00
Christopher Wiebe 8e2efdd878 M7.3: Save/load round-trip — F5 quicksave, Continue → slot picker
SavePaths ported verbatim from Theriapolis.Game/Platform/. Same OS
directories as MonoGame (%LOCALAPPDATA%\Theriapolis\Saves on
Windows, ~/Library/Application Support/Theriapolis/Saves on macOS,
$XDG_DATA_HOME/Theriapolis/saves on Linux) so saves round-trip
across the two builds without migration.

PlayScreen save layer. Wired PlayerReputation + Flags + QuestEngine
+ QuestContext + _killedByChunk + _pendingEncounterRestore in
_Ready, even though M7.3 doesn't actively drive any of those —
they're round-trip-required, so a save written by the MonoGame
build with non-empty rep/flags/quest state loads here and re-saves
without data loss. SaveTo/BuildHeader/CaptureBody/ApplyRestoredBody
are field-for-field ports of the MonoGame methods (Phase 5 M3 + M5,
Phase 6 M2 + M4); CaptureBody flushes the streamer first so chunk
deltas land in the store before serialisation. HandleChunkLoaded
now honours _killedByChunk so a killed spawn stays dead across
chunk reload + save round-trip.

F5 quicksaves to the autosave slot. Save-flash toast (bottom-center
Label, fade-out via Modulate.A) confirms each write.

_Ready branches on session.PendingRestore: when set (load path),
calls ApplyRestoredBody and skips the new-game spawn; otherwise
spawns at the Tier-1 anchor with the M6 character. The
mid-combat encounter snapshot is captured on save but the push to
CombatHUDScreen is the M8 stub (logs a console diagnostic).

SaveLoadScreen — load-only slot picker. Header-only deserialise
per row (SaveCodec.DeserializeHeaderOnly reads just the JSON
prefix, body untouched), so opening the picker is cheap even with
many large saves. Slot label matches MonoGame's SlotLabel() format
exactly. Incompatible / unreadable rows render disabled with the
reason inline.

TitleScreen Continue. Enable-gate replaced — was "user://character.json
exists" (M7.1 placeholder), now scans SavesDir for *.trps + checks
SaveCodec.IsCompatible. OnContinue swaps to SaveLoadScreen instead
of the print stub. Manual play-test loop confirmed: F5 in run #1,
quit, relaunch, Continue → Autosave row → progress bar → PlayScreen
with character restored at saved tile.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 19:03:18 -07:00
Christopher Wiebe bf0041605f M7.1-7.2: Play-loop hand-off — Wizard → WorldGen → PlayScreen
Lands the M7 plan's first two sub-milestones on port/godot.
theriapolis-rpg-implementation-plan-godot-port-m7.md is the design
doc (six screens collapse to four scenes + a camera mode, with
per-screen behavioural contracts and a six-step sub-milestone
breakdown).

M7.1 — WorldGenProgressScreen + GameSession autoload + wizard
hand-off rewrite. GameSession holds the cross-scene state that
outlives any single screen: seed, post-worldgen Ctx, pending
character (from the M6 wizard) and pending save snapshot (for
M7.3's load path). Wizard forwards StepReview.CharacterConfirmed
upward, and TitleScreen swaps to the progress screen instead of
just printing the build summary. The progress screen runs the
23-stage pipeline on a background thread, drives a ProgressBar
from ctx.ProgressCallback, and writes the full exception trace to
user://worldgen_error.log on failure. Escape cancels at the next
stage boundary and returns to title.

M7.2 — PlayScreen with a walking character. Extracted
WorldRenderNode from the M2+M4 WorldView demo so PlayScreen and
WorldView mount the same renderer (biome image + polylines +
bridges + settlement dots + tactical chunk lifecycle + PanZoomCamera
+ per-frame layer visibility + line-width counter-scaling).
PlayScreen owns the streamer (M7.3 save needs it), composes
ContentResolver + ActorManager + WorldClock + AnchorRegistry +
PlayerController, spawns the player at the Tier-1 anchor, and
wires resident + non-resident NPC spawning from chunk-load events
with allegiance-tinted markers.

PlayerController ported engine-agnostic to Theriapolis.Godot/Input/.
Takes pre-resolved dx/dy/dt/isTactical/isFocused instead of poking
MonoGame InputManager + Camera2D, so the arithmetic that advances
PlayerActor.Position and WorldClock.InGameSeconds is bit-identical
to the MonoGame version — saves round-trip cleanly.

Click-to-travel in world-map mode (camera zoom <
TacticalRenderZoomMin), WASD step in tactical mode with axis-
separated motion + encumbrance + sub-second clock carry. HUD
overlay top-left shows HP/AC/seed/tile/biome/view-mode/time. Esc
returns to title (M7.4 replaces this with a pause menu).

Namespace gotcha: Theriapolis.GodotHost.Input shadows the engine's
Godot.Input static class for any file under the GodotHost
namespace tree. Files needing keyboard polls (WorldView,
PlayScreen) fully qualify as Godot.Input.IsKeyPressed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 18:07:28 -07:00
19 changed files with 4178 additions and 379 deletions
+55
View File
@@ -0,0 +1,55 @@
using Godot;
using Theriapolis.Core.Persistence;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.World.Generation;
namespace Theriapolis.GodotHost;
/// <summary>
/// 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 <c>project.godot</c> under <c>[autoload]</c>; reachable
/// from any scene via <see cref="From"/>.
/// </summary>
public partial class GameSession : Node
{
/// <summary>World seed for the next worldgen run. Set by TitleScreen
/// (new game) or by SaveLoadScreen (from the loaded header).</summary>
public ulong Seed { get; set; }
/// <summary>Set by WorldGenProgressScreen on completion; consumed by
/// PlayScreen during <c>_Ready</c>.</summary>
public WorldGenContext? Ctx { get; set; }
/// <summary>Set by the Wizard hand-off (M6 → M7.1). PlayScreen
/// attaches this to the spawned player actor and clears the field.</summary>
public Character? PendingCharacter { get; set; }
public string PendingName { get; set; } = "Wanderer";
/// <summary>Set by SaveLoadScreen when the player picks a slot.
/// PlayScreen consumes via <c>ApplyRestoredBody</c> in <c>_Ready</c>.</summary>
public SaveBody? PendingRestore { get; set; }
public SaveHeader? PendingHeader { get; set; }
/// <summary>Convenience accessor — any node can grab the session via
/// <c>GameSession.From(this)</c> without hard-coding the autoload path.</summary>
public static GameSession From(Node anyNode)
=> anyNode.GetNode<GameSession>("/root/GameSession");
/// <summary>Drop the per-run pending fields. Called on quit-to-title
/// so a fresh "New Character" run doesn't see stale handoff data.</summary>
public void ClearPending()
{
PendingCharacter = null;
PendingName = "Wanderer";
PendingRestore = null;
PendingHeader = null;
}
}
+177
View File
@@ -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;
/// <summary>
/// Drives the player. World-map mode: click a destination, A* the path
/// (via <see cref="WorldTravelPlanner"/>), 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 <c>Theriapolis.Game/Input/PlayerController.cs</c>;
/// the Godot version takes pre-resolved <c>dx</c>/<c>dy</c> from the
/// screen instead of poking <c>InputManager</c> + <c>Camera2D</c>
/// (MonoGame types). Save-format determinism is unaffected — the only
/// output that round-trips through saves is <see cref="PlayerActor.Position"/>
/// and <see cref="WorldClock.InGameSeconds"/>, both of which are advanced
/// by identical arithmetic in both ports.
/// </summary>
public sealed class PlayerController
{
private readonly PlayerActor _player;
private readonly WorldState _world;
private readonly WorldClock _clock;
private readonly WorldTravelPlanner _planner;
/// <summary>Optional callback installed by the screen once tactical
/// streaming is up. Returns whether the given tactical-tile coord
/// is walkable.</summary>
public Func<int, int, bool>? 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;
}
/// <summary>Queue a click destination as a new travel plan. Returns
/// true if a path was found.</summary>
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;
}
/// <summary>Per-frame tick. <paramref name="dx"/>/<paramref name="dy"/>
/// are the pre-resolved input direction (e.g. -1/0/+1 each, from WASD);
/// ignored when <paramref name="isTacticalMode"/> 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.</summary>
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));
}
}
+63
View File
@@ -0,0 +1,63 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
namespace Theriapolis.GodotHost.Platform;
/// <summary>
/// OS-aware save directory resolution. Direct port of
/// <c>Theriapolis.Game/Platform/SavePaths.cs</c>; deliberately uses the
/// same directories as the MonoGame build so saves are interoperable
/// across the two ports.
///
/// Locations:
/// Windows: <c>%LOCALAPPDATA%\Theriapolis\Saves\</c>
/// macOS: <c>~/Library/Application Support/Theriapolis/Saves/</c>
/// Linux: <c>$XDG_DATA_HOME/Theriapolis/saves/</c> (default
/// <c>~/.local/share/Theriapolis/saves/</c>)
/// </summary>
public static class SavePaths
{
/// <summary>Top-level Theriapolis save directory. Created on first
/// call if missing.</summary>
public static string SavesDir
{
get
{
string dir = ResolveBase();
Directory.CreateDirectory(dir);
return dir;
}
}
public static string SlotPath(int slot) => Path.Combine(SavesDir, $"slot_{slot:D2}.trps");
public static string AutosavePath() => Path.Combine(SavesDir, "autosave.trps");
private static string ResolveBase()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Theriapolis", "Saves");
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Library", "Application Support", "Theriapolis", "Saves");
// Linux + others: respect XDG_DATA_HOME, fall back to ~/.local/share.
string xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME") ?? "";
if (string.IsNullOrEmpty(xdg))
xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".local", "share");
return Path.Combine(xdg, "Theriapolis", "saves");
}
/// <summary>Atomic-rename file write so a crash mid-save can't
/// corrupt the slot.</summary>
public static void WriteAtomic(string path, byte[] bytes)
{
string dir = Path.GetDirectoryName(path)!;
Directory.CreateDirectory(dir);
string tmp = path + ".tmp";
File.WriteAllBytes(tmp, bytes);
if (File.Exists(path)) File.Replace(tmp, path, destinationBackupFileName: null);
else File.Move(tmp, path);
}
}
@@ -0,0 +1,46 @@
using System;
using System.Globalization;
using Theriapolis.Core.Persistence;
namespace Theriapolis.GodotHost.Platform;
/// <summary>
/// Slot-picker label formatting. Pulls the in-game time from
/// <see cref="SaveHeader.SlotLabel"/> (e.g. "Howlwind — Y0 Spring D5
/// (Tier 1)") and appends the wall-clock saved-at time parsed from
/// <see cref="SaveHeader.SavedAtUtc"/>, rendered in the player's local
/// timezone with a relative label when recent.
///
/// Shared between <see cref="Scenes.SaveLoadScreen"/> (load picker
/// from Title) and <see cref="Scenes.PauseMenuScreen"/>'s save picker
/// so both surfaces present the same row format.
/// </summary>
public static class SaveSlotFormat
{
/// <summary>Composed row label: "{slot} — {in-game} · saved {when}".</summary>
public static string FormatRow(string slotPrefix, SaveHeader header)
=> $"{slotPrefix} — {header.SlotLabel()} · saved {FormatSavedAt(header.SavedAtUtc)}";
/// <summary>Parses the SaveHeader's UTC saved-at timestamp and
/// renders it relative to now, in local time. Returns "&lt;unknown&gt;"
/// for empty / unparseable inputs so the row still shows something.</summary>
public static string FormatSavedAt(string savedAtUtc)
{
if (string.IsNullOrWhiteSpace(savedAtUtc)) return "<unknown>";
if (!DateTime.TryParse(
savedAtUtc, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var utc))
return savedAtUtc;
DateTime local = utc.ToLocalTime();
DateTime now = DateTime.Now;
if (local.Date == now.Date)
return $"today, {local:HH:mm}";
if (local.Date == now.Date.AddDays(-1))
return $"yesterday, {local:HH:mm}";
if (local.Year == now.Year)
return local.ToString("MMM d, HH:mm", CultureInfo.InvariantCulture);
return local.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
}
}
+29
View File
@@ -0,0 +1,29 @@
using Godot;
using Theriapolis.Core;
using Theriapolis.Core.Rules.Character;
namespace Theriapolis.GodotHost.Rendering;
/// <summary>
/// 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.
/// </summary>
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);
}
}
@@ -0,0 +1,52 @@
using Godot;
using Theriapolis.Core;
namespace Theriapolis.GodotHost.Rendering;
/// <summary>
/// Player marker — small dot with a thin facing tick. Drawn at
/// <see cref="C.PLAYER_MARKER_SCREEN_PX"/>/2 wp; the owner sets
/// <see cref="Node2D.Scale"/> = 1/zoom every frame so the on-screen size
/// stays constant across the seamless zoom range. Facing is driven by
/// the owner via <see cref="Node2D.Rotation"/>; the tick is drawn along
/// the local +X axis so rotating the node rotates the tick without
/// invalidating the cached <c>_Draw</c> commands.
/// </summary>
public partial class PlayerMarker : Node2D
{
private const float RadiusWorldPx = C.PLAYER_MARKER_SCREEN_PX * 0.5f;
private const float FacingTickPx = RadiusWorldPx * 1.4f;
private bool _showFacingTick = true;
/// <summary>When true, draws a small tick along the local +X axis
/// so the player can read facing without a full sprite. Hidden at
/// low zoom to avoid clutter. Triggers <see cref="CanvasItem.QueueRedraw"/>
/// on change.</summary>
public bool ShowFacingTick
{
get => _showFacingTick;
set
{
if (_showFacingTick == value) return;
_showFacingTick = value;
QueueRedraw();
}
}
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)
{
DrawLine(
new Vector2(RadiusWorldPx * 0.4f, 0),
new Vector2(FacingTickPx, 0),
new Color(1f, 0.96f, 0.86f),
width: RadiusWorldPx * 0.18f,
antialiased: false);
}
}
}
@@ -0,0 +1,16 @@
using Godot;
namespace Theriapolis.GodotHost.Rendering;
/// <summary>
/// Filled circle settlement marker on the world map. Sized in world-pixel
/// space; the parent <see cref="WorldRenderNode"/> hides the layer above
/// the tactical-zoom threshold so the dot doesn't clutter close-up views.
/// </summary>
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);
}
@@ -0,0 +1,358 @@
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;
/// <summary>
/// Renders a generated <see cref="WorldState"/> 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 <see cref="PanZoomCamera"/> so callers can read zoom and
/// drive position uniformly.
///
/// Per M7 plan §6.2: extracted from the M2+M4 <see cref="WorldView"/> 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 <c>OnChunkLoaded</c>/<c>OnChunkEvicting</c>
/// and forwards into <see cref="AddChunkNode"/>/<see cref="RemoveChunkNode"/>.
///
/// 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.
/// </summary>
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<ChunkCoord, TacticalChunkNode> _chunkNodes = new();
private readonly List<(Line2D line, float baseScreenWidth)> _scaledLines = new();
private bool _initialised;
/// <summary>The camera owned by this node. Caller reads <c>Zoom</c> to
/// pick world-map vs. tactical UI behaviour, and sets <c>Position</c>
/// to follow the player.</summary>
public PanZoomCamera Camera => _camera!;
/// <summary>Initialise from a completed <see cref="WorldGenContext"/>.
/// Idempotent on repeat — second call is a no-op. <paramref name="initialZoom"/>
/// of 0 means "compute fit-to-viewport so the whole world is visible".</summary>
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();
}
/// <summary>Mount the visual for a freshly-streamed chunk. Caller
/// invokes from a <c>ChunkStreamer.OnChunkLoaded</c> subscription.</summary>
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;
}
/// <summary>Tear down a chunk visual on eviction. Caller invokes from
/// <c>ChunkStreamer.OnChunkEvicting</c>.</summary>
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<Vec2> 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;
bool tactical = zoom >= TacticalRenderZoomMin;
if (_tacticalLayer is not null)
_tacticalLayer.Visible = tactical;
if (_settlementLayer is not null)
_settlementLayer.Visible = zoom < SettlementHideZoom;
// Polylines and bridges are baked into the tactical chunk surface
// tiles by TacticalChunkGen.Pass2_Polylines, so re-stroking the
// Line2D overlay at tactical zoom double-draws the road and shows
// as a brown line over top of the rasterised one. Hide the line
// overlay when tactical is active.
if (_polylineLayer is not null)
_polylineLayer.Visible = !tactical;
if (_bridgeLayer is not null)
_bridgeLayer.Visible = !tactical;
}
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,
};
}
+33 -370
View File
@@ -1,37 +1,24 @@
using Godot;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using Godot;
using Theriapolis.Core; using Theriapolis.Core;
using Theriapolis.Core.Tactical; using Theriapolis.Core.Tactical;
using Theriapolis.Core.Util; using Theriapolis.Core.Util;
using Theriapolis.Core.World;
using Theriapolis.Core.World.Generation; using Theriapolis.Core.World.Generation;
using Theriapolis.Core.World.Polylines;
using Theriapolis.GodotHost.Platform; using Theriapolis.GodotHost.Platform;
namespace Theriapolis.GodotHost.Rendering; namespace Theriapolis.GodotHost.Rendering;
/// <summary> /// <summary>
/// Unified seamless-zoom view (CLAUDE.md "Seamless Zoom Model"). One scene /// Standalone demo entry point for the M2+M4 unified seamless-zoom view.
/// covers world-map and tactical scales; layers fade in/out at zoom /// Runs worldgen inline, spawns a placeholder player, and lets you walk
/// thresholds. Polyline widths and the player marker counter-scale with /// around with WASD. Used by the <c>--world-map</c> and <c>--tactical</c>
/// zoom so they stay visually consistent across the full range. /// CLI flags for headless / debug viewing without going through the
/// title → wizard → progress flow.
/// ///
/// Layers, bottom-up: /// The actual rendering — biome / polyline / settlement / chunk layers
/// BiomeLayer — 256x256 biome image, scaled by WORLD_TILE_PIXELS; /// and the camera — lives in <see cref="WorldRenderNode"/>, which is
/// always visible. Acts as the backdrop past the /// shared with M7's <see cref="Scenes.PlayScreen"/>. This shell just
/// tactical streaming radius. /// owns the demo's local player position and streaming loop.
/// TacticalChunks — TacticalChunkNode children added on chunk load;
/// visible only when zoom &gt; TacticalRenderZoomMin.
/// Polylines/Bridges — Line2D children; always visible. Widths counter-
/// scaled per frame.
/// Settlements — SettlementDot children; visible only when zoom
/// &lt; SettlementHideZoom.
/// Player — Always visible; counter-scaled.
///
/// Camera follows the player at all zooms; right-drag temporarily pans
/// (PanZoomCamera handles drag input).
/// </summary> /// </summary>
public partial class WorldView : Node2D public partial class WorldView : Node2D
{ {
@@ -40,56 +27,22 @@ public partial class WorldView : Node2D
private readonly int _startWorldTileY; private readonly int _startWorldTileY;
private readonly float _initialZoom; 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. // World-pixel movement speed. 32 wp = 1 world tile, so 96 = ~3 tiles/sec.
private const float MoveSpeedWorldPx = 96f; private const float MoveSpeedWorldPx = 96f;
private const int StreamingBufferWorldTiles = 2; private const int StreamingBufferWorldTiles = 2;
private const float StreamRadiusZoomMin = WorldRenderNode.TacticalRenderZoomMin;
// 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 ChunkStreamer? _streamer; private ChunkStreamer? _streamer;
private WorldRenderNode? _render;
private Vec2 _playerPos; private Vec2 _playerPos;
private PanZoomCamera? _camera;
private Node2D? _tacticalLayer;
private Node2D? _polylineLayer;
private Node2D? _bridgeLayer;
private Node2D? _settlementLayer;
private PlayerMarker? _playerMarker; private PlayerMarker? _playerMarker;
private readonly Dictionary<ChunkCoord, TacticalChunkNode> _chunkNodes = new();
private readonly List<(Line2D line, float baseScreenWidth)> _scaledLines = new();
public WorldView(ulong seed, int startWorldTileX = 128, int startWorldTileY = 128, float initialZoom = 0f) public WorldView(ulong seed, int startWorldTileX = 128, int startWorldTileY = 128, float initialZoom = 0f)
{ {
_seed = seed; _seed = seed;
_startWorldTileX = startWorldTileX; _startWorldTileX = startWorldTileX;
_startWorldTileY = startWorldTileY; _startWorldTileY = startWorldTileY;
_initialZoom = initialZoom; // 0 = compute fit-to-viewport _initialZoom = initialZoom;
} }
public override void _Ready() public override void _Ready()
@@ -109,17 +62,13 @@ public partial class WorldView : Node2D
$"roads={world.Roads.Count} rails={world.Rails.Count} " + $"roads={world.Roads.Count} rails={world.Rails.Count} " +
$"settlements={world.Settlements.Count} bridges={world.Bridges.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 = new ChunkStreamer(_seed, world, new InMemoryChunkDeltaStore());
_streamer.OnChunkLoaded += AddChunkNode; _streamer.OnChunkLoaded += _render.AddChunkNode;
_streamer.OnChunkEvicting += RemoveChunkNode; _streamer.OnChunkEvicting += _render.RemoveChunkNode;
TacticalAtlas.EnsureLoaded();
BuildBiomeSprite(world);
_tacticalLayer = AddNamedLayer("TacticalChunks");
BuildPolylines(world);
BuildBridges(world);
BuildSettlements(world);
_playerPos = new Vec2( _playerPos = new Vec2(
_startWorldTileX * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f, _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) }; _playerMarker = new PlayerMarker { Position = new Vector2(_playerPos.X, _playerPos.Y) };
AddChild(_playerMarker); AddChild(_playerMarker);
AddCamera(); _render.Camera.Position = new Vector2(_playerPos.X, _playerPos.Y);
UpdateLayerVisibility();
StreamIfTactical(); StreamIfTactical();
} }
public override void _Process(double delta) 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; Vector2 dir = Vector2.Zero;
if (Input.IsKeyPressed(Key.W) || Input.IsKeyPressed(Key.Up)) dir.Y -= 1; if (Godot.Input.IsKeyPressed(Key.W) || Godot.Input.IsKeyPressed(Key.Up)) dir.Y -= 1;
if (Input.IsKeyPressed(Key.S) || Input.IsKeyPressed(Key.Down)) dir.Y += 1; if (Godot.Input.IsKeyPressed(Key.S) || Godot.Input.IsKeyPressed(Key.Down)) dir.Y += 1;
if (Input.IsKeyPressed(Key.A) || Input.IsKeyPressed(Key.Left)) dir.X -= 1; if (Godot.Input.IsKeyPressed(Key.A) || Godot.Input.IsKeyPressed(Key.Left)) dir.X -= 1;
if (Input.IsKeyPressed(Key.D) || Input.IsKeyPressed(Key.Right)) dir.X += 1; if (Godot.Input.IsKeyPressed(Key.D) || Godot.Input.IsKeyPressed(Key.Right)) dir.X += 1;
if (dir != Vector2.Zero) if (dir != Vector2.Zero)
{ {
@@ -160,309 +108,24 @@ public partial class WorldView : Node2D
var pos = new Vector2(_playerPos.X, _playerPos.Y); var pos = new Vector2(_playerPos.X, _playerPos.Y);
_playerMarker.Position = pos; _playerMarker.Position = pos;
_camera.Position = pos; _render.Camera.Position = pos;
UpdateLayerVisibility(); // Counter-scale the marker so its on-screen size stays constant.
UpdateZoomScaledNodes(); float zoom = _render.Camera.Zoom.X;
} if (zoom > 0f)
_playerMarker.Scale = new Vector2(1f / zoom, 1f / zoom);
// ──────────────────────────────────────────────────────────────────────
// 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<Vec2> 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);
} }
private void StreamIfTactical() private void StreamIfTactical()
{ {
if (_streamer is null) return; if (_streamer is null || _render is null) return;
if (_camera is null || _camera.Zoom.X < StreamRadiusZoomMin) if (_render.Camera.Zoom.X < StreamRadiusZoomMin) return;
{
// 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;
}
Vector2 viewport = GetViewport().GetVisibleRect().Size; 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 halfExtentTiles = Mathf.CeilToInt(halfExtentWorldPx / C.WORLD_TILE_PIXELS);
int radius = halfExtentTiles + StreamingBufferWorldTiles; int radius = halfExtentTiles + StreamingBufferWorldTiles;
_streamer.EnsureLoadedAround(_playerPos, radius); _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,
};
}
/// <summary>
/// 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).
/// </summary>
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);
}
/// <summary>
/// Player marker. Drawn at <see cref="C.PLAYER_MARKER_SCREEN_PX"/>/2 wp;
/// parent WorldView sets <see cref="Node2D.Scale"/> = 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.
/// </summary>
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));
}
} }
@@ -0,0 +1,450 @@
using System;
using System.Collections.Generic;
using Godot;
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Rules.Dialogue;
using Theriapolis.Core.Rules.Reputation;
using Theriapolis.GodotHost.UI;
namespace Theriapolis.GodotHost.Scenes;
/// <summary>
/// M7.5 — dialogue overlay. Pushed by <see cref="PlayScreen"/> when the
/// player presses F next to a friendly / neutral NPC. Mirrors
/// <c>Theriapolis.Game/Screens/InteractionScreen.cs</c> from the MonoGame
/// build: speaker header + bias / disposition tag + optional Scent Literacy
/// overlay; scrollback of the last <see cref="C.DIALOGUE_HISTORY_LINES"/>
/// entries; numbered option list; number-key + Esc / F input.
///
/// Effect routing after each <c>ChooseOption</c>:
/// - Drains <see cref="DialogueContext.StartQuestRequests"/> into
/// <c>playScreen.QuestEngine.Start</c> so quest journal entries
/// land in the right order (the journal UI is M8 territory but the
/// engine fires immediately).
/// - When <see cref="DialogueContext.ShopRequested"/> flips true, M7
/// surfaces a toast — the real ShopScreen lands with M8.
/// </summary>
public partial class InteractionScreen : CanvasLayer
{
private readonly NpcActor _npc;
private readonly PlayScreen _playScreen;
private DialogueRunner? _runner;
private Control _root = null!;
private VBoxContainer _historyPanel = null!;
private VBoxContainer _optionsPanel = null!;
private bool _consumedOpeningKeys;
public InteractionScreen(NpcActor npc, PlayScreen playScreen)
{
_npc = npc ?? throw new ArgumentNullException(nameof(npc));
_playScreen = playScreen ?? throw new ArgumentNullException(nameof(playScreen));
}
public override void _Ready()
{
Layer = 50;
ProcessMode = ProcessModeEnum.WhenPaused;
GetTree().Paused = true;
_runner = TryBuildRunner();
BuildLayout();
}
private DialogueRunner? TryBuildRunner()
{
var content = _playScreen.Content;
var pc = _playScreen.PlayerCharacter();
if (content is null || pc is null) return null;
if (string.IsNullOrEmpty(_npc.DialogueId)) return null;
if (!content.Dialogues.TryGetValue(_npc.DialogueId, out var tree)) return null;
var pos = _playScreen.PlayerPosition;
var ctx = new DialogueContext(_npc, pc, _playScreen.Reputation, _playScreen.Flags, content)
{
PlayerWorldTileX = (int)(pos.X / C.WORLD_TILE_PIXELS),
PlayerWorldTileY = (int)(pos.Y / C.WORLD_TILE_PIXELS),
WorldClockSeconds = _playScreen.ClockSeconds,
};
return new DialogueRunner(tree, ctx, _playScreen.WorldSeed);
}
private void BuildLayout()
{
_root = new Control
{
MouseFilter = Control.MouseFilterEnum.Stop,
ProcessMode = ProcessModeEnum.WhenPaused,
};
_root.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);
AddChild(_root);
var scrim = new ColorRect
{
Color = new Color(0, 0, 0, 0.55f),
MouseFilter = Control.MouseFilterEnum.Ignore,
};
scrim.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);
_root.AddChild(scrim);
var center = new CenterContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
center.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);
_root.AddChild(center);
var panel = new PanelContainer
{
ThemeTypeVariation = "Card",
Theme = CodexTheme.Build(),
CustomMinimumSize = new Vector2(760, 0),
};
center.AddChild(panel);
var margin = new MarginContainer();
margin.AddThemeConstantOverride("margin_left", 28);
margin.AddThemeConstantOverride("margin_right", 28);
margin.AddThemeConstantOverride("margin_top", 22);
margin.AddThemeConstantOverride("margin_bottom", 22);
panel.AddChild(margin);
var col = new VBoxContainer();
col.AddThemeConstantOverride("separation", 10);
margin.AddChild(col);
// Header
col.AddChild(BuildHeader());
// Spacer
col.AddChild(new Control { CustomMinimumSize = new Vector2(0, 4) });
// History
_historyPanel = new VBoxContainer { CustomMinimumSize = new Vector2(680, 0) };
_historyPanel.AddThemeConstantOverride("separation", 4);
col.AddChild(_historyPanel);
// Options
_optionsPanel = new VBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
_optionsPanel.AddThemeConstantOverride("separation", 4);
col.AddChild(_optionsPanel);
// Footer
col.AddChild(new Label
{
Text = "(1-9 to choose · Esc to leave · F also closes)",
ThemeTypeVariation = "Eyebrow",
HorizontalAlignment = HorizontalAlignment.Center,
});
Refresh();
}
private Control BuildHeader()
{
var header = new VBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ShrinkCenter };
header.AddThemeConstantOverride("separation", 2);
header.AddChild(new Label
{
Text = _npc.DisplayName,
ThemeTypeVariation = "H2",
HorizontalAlignment = HorizontalAlignment.Center,
});
string roleLine = FormatRoleLine(_npc.RoleTag);
if (!string.IsNullOrEmpty(roleLine))
{
header.AddChild(new Label
{
Text = roleLine,
ThemeTypeVariation = "Eyebrow",
HorizontalAlignment = HorizontalAlignment.Center,
});
}
var content = _playScreen.Content;
var pc = _playScreen.PlayerCharacter();
if (content is not null && pc is not null)
{
var br = EffectiveDisposition.Breakdown(
_npc, pc, _playScreen.Reputation, content,
_playScreen.World, _playScreen.WorldSeed);
string profile = content.BiasProfiles.TryGetValue(_npc.BiasProfileId, out var bp)
? bp.Name : _npc.BiasProfileId;
header.AddChild(new Label
{
Text = $"[{profile}] · {DispositionLabels.DisplayName(br.Label)} {br.Total:+#;-#;0}",
ThemeTypeVariation = "Eyebrow",
HorizontalAlignment = HorizontalAlignment.Center,
});
string? scentLine = ScentReadingFor(_npc, pc);
if (scentLine is not null)
{
header.AddChild(new Label
{
Text = scentLine,
ThemeTypeVariation = "Eyebrow",
HorizontalAlignment = HorizontalAlignment.Center,
});
}
}
return header;
}
private void Refresh()
{
ClearChildren(_historyPanel);
ClearChildren(_optionsPanel);
if (_runner is null)
{
_historyPanel.AddChild(new Label
{
Text = "(They have nothing to say yet.)",
AutowrapMode = TextServer.AutowrapMode.WordSmart,
});
_historyPanel.AddChild(new Label
{
Text = "— no dialogue tree authored for this NPC. "
+ "Stock trees ship as content fills in.",
ThemeTypeVariation = "Eyebrow",
AutowrapMode = TextServer.AutowrapMode.WordSmart,
});
var close = MakeOptionButton("1. Goodbye");
close.Pressed += Close;
_optionsPanel.AddChild(close);
return;
}
// Render history — last C.DIALOGUE_HISTORY_LINES entries.
int start = Math.Max(0, _runner.History.Count - C.DIALOGUE_HISTORY_LINES);
for (int i = start; i < _runner.History.Count; i++)
{
var entry = _runner.History[i];
string prefix = entry.Speaker switch
{
DialogueSpeaker.Pc => " > ",
DialogueSpeaker.Narration => " ",
_ => "",
};
Color color = entry.Speaker switch
{
DialogueSpeaker.Npc => new Color(0.86f, 0.86f, 0.78f),
DialogueSpeaker.Pc => new Color(0.67f, 0.78f, 0.86f),
DialogueSpeaker.Narration => new Color(0.63f, 0.71f, 0.55f),
_ => Colors.White,
};
var line = new Label
{
Text = prefix + entry.Text,
AutowrapMode = TextServer.AutowrapMode.WordSmart,
CustomMinimumSize = new Vector2(680, 0),
};
line.AddThemeColorOverride("font_color", color);
_historyPanel.AddChild(line);
}
if (_runner.IsOver)
{
var close = MakeOptionButton("1. (close)");
close.Pressed += Close;
_optionsPanel.AddChild(close);
return;
}
// Render options. Number by *display* index (visible options only).
int displayN = 0;
foreach (var (origIdx, opt) in _runner.VisibleOptions())
{
displayN++;
int captured = origIdx;
string label = $"{displayN}. {opt.Text}";
if (opt.SkillCheck is { } sc)
label = $"{displayN}. [{sc.Skill.ToUpperInvariant()} DC {sc.Dc}] {opt.Text}";
var btn = MakeOptionButton(label);
btn.Pressed += () => OnOptionPicked(captured);
_optionsPanel.AddChild(btn);
if (displayN >= C.DIALOGUE_MAX_OPTIONS_PER_NODE) break;
}
}
private void OnOptionPicked(int origIndex)
{
if (_runner is null) return;
_runner.ChooseOption(origIndex);
// M7.6 / Phase 6 M4 — dialogue's start_quest effects buffer quest
// ids on the runner context. Drain them into the live engine
// before refreshing so journal entries print in the right order.
if (_runner.Context.StartQuestRequests.Count > 0)
{
var qctx = _playScreen.BuildQuestContextForDialogue();
if (qctx is not null)
{
foreach (var qid in _runner.Context.StartQuestRequests)
_playScreen.QuestEngine.Start(qid, qctx);
}
_runner.Context.StartQuestRequests.Clear();
}
Refresh();
// open_shop effect — M8 stub. Toast acknowledges the request and
// clears the flag so re-entry doesn't loop on the same node.
if (_runner.Context.ShopRequested)
{
_runner.Context.ShopRequested = false;
_playScreen.Toast($"Shop ships with M8 — {_npc.DisplayName} waits patiently.");
}
}
public override void _UnhandledInput(InputEvent @event)
{
if (@event is not InputEventKey { Pressed: true } key) return;
if (key.Echo) return;
// Belt-and-braces: PlayScreen.AddChild(this) happens during _Process,
// so the F-press that opened this overlay shouldn't reach _Input
// here (different frame). The flag absorbs the rare case where it
// does.
if (!_consumedOpeningKeys)
{
_consumedOpeningKeys = true;
if (key.Keycode is Key.F or Key.Escape) return;
}
switch (key.Keycode)
{
case Key.Escape:
case Key.F:
GetViewport().SetInputAsHandled();
Close();
return;
case Key.Enter:
case Key.KpEnter:
if (_runner is { IsOver: true })
{
GetViewport().SetInputAsHandled();
Close();
}
return;
}
// Number keys 1..9 (top-row and numpad). Godot's Key enum has a
// long underlying type (so it can hold Unicode + modifier bits) —
// cast the arithmetic result to int.
int picked = key.Keycode switch
{
>= Key.Key1 and <= Key.Key9 => (int)(key.Keycode - Key.Key1) + 1,
>= Key.Kp1 and <= Key.Kp9 => (int)(key.Keycode - Key.Kp1) + 1,
_ => 0,
};
if (picked > 0 && _runner is not null && !_runner.IsOver)
{
GetViewport().SetInputAsHandled();
HandleNumberPick(picked);
}
}
private void HandleNumberPick(int displayN)
{
if (_runner is null) return;
int seen = 0;
foreach (var (origIdx, _) in _runner.VisibleOptions())
{
seen++;
if (seen == displayN)
{
OnOptionPicked(origIdx);
return;
}
}
}
private void Close()
{
GetTree().Paused = false;
QueueFree();
}
// ──────────────────────────────────────────────────────────────────────
// Helpers
private static Button MakeOptionButton(string text)
{
return new Button
{
Text = text,
CustomMinimumSize = new Vector2(680, 40),
SizeFlagsHorizontal = Control.SizeFlags.ShrinkCenter,
Alignment = HorizontalAlignment.Left,
FocusMode = Control.FocusModeEnum.None,
};
}
private static void ClearChildren(Node node)
{
foreach (Node child in node.GetChildren()) child.QueueFree();
}
private static string FormatRoleLine(string roleTag)
{
if (string.IsNullOrEmpty(roleTag)) return "";
int dot = roleTag.LastIndexOf('.');
if (dot < 0) return TitleCase(roleTag);
string anchor = roleTag[..dot];
string role = roleTag[(dot + 1)..];
return $"{TitleCase(role)} of {TitleCase(anchor)}";
}
private static string TitleCase(string raw)
{
if (string.IsNullOrEmpty(raw)) return "";
Span<char> buf = stackalloc char[raw.Length];
bool capNext = true;
for (int i = 0; i < raw.Length; i++)
{
char c = raw[i];
if (c == '_' || c == '.') { buf[i] = ' '; capNext = true; continue; }
buf[i] = capNext ? char.ToUpperInvariant(c) : c;
capNext = false;
}
return new string(buf);
}
/// <summary>Phase 6.5 M1 — Scent Literacy overlay. Returns null when
/// the PC doesn't have the feature, so the header skips the line.</summary>
private static string? ScentReadingFor(NpcActor npc, Theriapolis.Core.Rules.Character.Character pc)
{
bool hasFeature = pc.LearnedFeatureIds.Contains("scent_literacy")
|| pc.ClassDef.Id == "scent_broker";
if (!hasFeature) return null;
string clade = npc.Resident?.Clade ?? npc.Template?.Behavior ?? "unknown";
string species = npc.Resident?.Species ?? "—";
int hpPct = npc.MaxHp > 0
? (int)Math.Round(100.0 * npc.CurrentHp / npc.MaxHp)
: 100;
string hp = hpPct == 100 ? "—" : $"{hpPct}%";
int tagCount = pc.LearnedFeatureIds.Contains("master_nose") ? 3 : 1;
var tags = npc.ComputeScentTags(tagCount);
string tagSuffix = "";
if (tags.Count > 0)
{
var rendered = new List<string>(tags.Count);
foreach (var t in tags) rendered.Add("⚠ " + t.DisplayName());
tagSuffix = " · " + string.Join(" · ", rendered);
}
return $"⊙ Scent: {Capitalize(clade)} ({Capitalize(species)}) · HP {hp}{tagSuffix}";
}
private static string Capitalize(string s)
{
if (string.IsNullOrEmpty(s)) return s;
return char.ToUpperInvariant(s[0]) + s[1..].Replace('_', ' ');
}
}
+306
View File
@@ -0,0 +1,306 @@
using System;
using System.IO;
using Godot;
using Theriapolis.Core;
using Theriapolis.Core.Persistence;
using Theriapolis.Core.Rules.Character;
using Theriapolis.GodotHost.Platform;
using Theriapolis.GodotHost.UI;
namespace Theriapolis.GodotHost.Scenes;
/// <summary>
/// M7.4 — pause overlay. Pushed by PlayScreen when Esc is pressed.
/// Halts the game clock via <c>GetTree().Paused = true</c> so the
/// player + streamer + controller all freeze; the overlay itself
/// runs with <c>ProcessModeEnum.WhenPaused</c> so it stays
/// responsive to input.
///
/// Two sub-states: <see cref="BuildMain"/> shows the main menu
/// (Resume / Level Up / Save Game / Quicksave / Quit). Clicking
/// "Save Game" flips to <see cref="BuildSlotPicker"/> — a per-slot
/// row list that calls back into <see cref="PlayScreen.SaveTo"/>.
/// Esc backs out of the slot picker to main, then closes the
/// overlay on a second press.
///
/// Save-from-pause shows a status line inside the panel (separate
/// from PlayScreen's save-flash toast, which is suppressed while
/// the tree is paused).
/// </summary>
public partial class PauseMenuScreen : CanvasLayer
{
private readonly PlayScreen _playScreen;
private Control _root = null!;
private PanelContainer _panel = null!;
private VBoxContainer _content = null!;
private Label _statusLabel = null!;
private bool _showingSlots;
// Edge-detection for the Esc that opened the overlay — without this
// the same press both opens AND closes the menu on the next frame.
private bool _consumedOpeningEsc;
public PauseMenuScreen(PlayScreen playScreen)
{
_playScreen = playScreen ?? throw new ArgumentNullException(nameof(playScreen));
}
public override void _Ready()
{
Layer = 50;
ProcessMode = ProcessModeEnum.WhenPaused;
GetTree().Paused = true;
_root = new Control
{
MouseFilter = Control.MouseFilterEnum.Stop,
ProcessMode = ProcessModeEnum.WhenPaused,
};
_root.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);
AddChild(_root);
// Half-opaque scrim so the world reads as backgrounded.
var scrim = new ColorRect
{
Color = new Color(0, 0, 0, 0.55f),
MouseFilter = Control.MouseFilterEnum.Ignore,
};
scrim.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);
_root.AddChild(scrim);
var center = new CenterContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
center.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);
_root.AddChild(center);
// Apply the codex theme on the panel root so the cascade reaches
// every button (overlays mount outside the PlayScreen Control tree,
// so the parent theme doesn't cascade automatically).
_panel = new PanelContainer
{
ThemeTypeVariation = "Card",
Theme = CodexTheme.Build(),
CustomMinimumSize = new Vector2(360, 0),
};
center.AddChild(_panel);
var margin = new MarginContainer();
margin.AddThemeConstantOverride("margin_left", 28);
margin.AddThemeConstantOverride("margin_right", 28);
margin.AddThemeConstantOverride("margin_top", 20);
margin.AddThemeConstantOverride("margin_bottom", 20);
_panel.AddChild(margin);
_content = new VBoxContainer();
_content.AddThemeConstantOverride("separation", 10);
margin.AddChild(_content);
BuildMain();
}
private void BuildMain()
{
_showingSlots = false;
ClearContent();
_content.AddChild(new Label
{
Text = "PAUSED",
ThemeTypeVariation = "H2",
HorizontalAlignment = HorizontalAlignment.Center,
});
AddSpacer(8);
var resume = MakeMenuButton("Resume", primary: true);
resume.Pressed += Close;
_content.AddChild(resume);
// Level-up affordance — only when eligible. Disabled in M7 with a
// tooltip; the actual LevelUpScreen ships with M8.
var pc = _playScreen.PlayerCharacter();
if (pc is not null && LevelUpFlow.CanLevelUp(pc))
{
var lvlBtn = MakeMenuButton($"★ Level Up ({pc.Level} → {pc.Level + 1})", primary: false);
lvlBtn.Disabled = true;
lvlBtn.TooltipText = "Level-up screen ships with M8.";
_content.AddChild(lvlBtn);
}
var saveBtn = MakeMenuButton("Save Game", primary: false);
saveBtn.Pressed += BuildSlotPicker;
_content.AddChild(saveBtn);
var quickSave = MakeMenuButton("Quicksave (autosave slot)", primary: false);
quickSave.Pressed += OnQuicksave;
_content.AddChild(quickSave);
var quitBtn = MakeMenuButton("Quit to Title", primary: false);
quitBtn.Pressed += OnQuitToTitle;
_content.AddChild(quitBtn);
AddSpacer(6);
_statusLabel = new Label
{
Text = " ",
ThemeTypeVariation = "Eyebrow",
HorizontalAlignment = HorizontalAlignment.Center,
AutowrapMode = TextServer.AutowrapMode.WordSmart,
};
_content.AddChild(_statusLabel);
}
private void BuildSlotPicker()
{
_showingSlots = true;
ClearContent();
_content.AddChild(new Label
{
Text = "SAVE TO SLOT",
ThemeTypeVariation = "Eyebrow",
HorizontalAlignment = HorizontalAlignment.Center,
});
AddSpacer(4);
for (int i = 1; i <= C.SAVE_SLOT_COUNT; i++)
{
int slotNum = i;
string path = SavePaths.SlotPath(slotNum);
string prefix = $"Slot {slotNum:D2}";
string label = prefix;
if (File.Exists(path))
{
try
{
var bytes = File.ReadAllBytes(path);
var header = SaveCodec.DeserializeHeaderOnly(bytes);
label = SaveSlotFormat.FormatRow(prefix, header);
}
catch { label += " — <unreadable>"; }
}
else
{
label += " — <empty>";
}
var btn = new Button
{
Text = label,
CustomMinimumSize = new Vector2(0, 36),
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
Alignment = HorizontalAlignment.Left,
};
btn.Pressed += () => OnSaveToSlot(slotNum, path);
_content.AddChild(btn);
}
AddSpacer(6);
var back = MakeMenuButton("← Back", primary: false);
back.Pressed += BuildMain;
_content.AddChild(back);
_statusLabel = new Label
{
Text = " ",
ThemeTypeVariation = "Eyebrow",
HorizontalAlignment = HorizontalAlignment.Center,
AutowrapMode = TextServer.AutowrapMode.WordSmart,
};
_content.AddChild(_statusLabel);
}
private void OnQuicksave()
{
bool ok = _playScreen.SaveTo(SavePaths.AutosavePath());
ShowStatus(ok ? "Quicksaved." : "Quicksave failed.");
}
private void OnSaveToSlot(int slotNum, string path)
{
bool ok = _playScreen.SaveTo(path);
if (ok)
{
BuildMain();
ShowStatus($"Saved to slot {slotNum:D2}.");
}
else
{
ShowStatus("Save failed.");
}
}
private void OnQuitToTitle()
{
// Autosave on quit-to-title, matching MonoGame behaviour. A failed
// autosave doesn't block the quit — better to let the user leave
// than trap them with an angry dialog.
_playScreen.SaveTo(SavePaths.AutosavePath());
GetTree().Paused = false;
var playParent = _playScreen.GetParent();
if (playParent is not null)
{
foreach (Node child in playParent.GetChildren())
child.QueueFree();
playParent.AddChild(new TitleScreen());
}
QueueFree();
}
private void Close()
{
GetTree().Paused = false;
QueueFree();
}
public override void _UnhandledInput(InputEvent @event)
{
if (@event is not InputEventKey { Pressed: true, Keycode: Key.Escape } key) return;
if (key.Echo) return;
// The same physical Esc press that *opened* this overlay produces
// exactly one event. We're routed via _UnhandledInput on the layer,
// which dispatches AFTER PlayScreen.Input has run + consumed the
// event with SetInputAsHandled — so this branch fires on the NEXT
// Esc press. The _consumedOpeningEsc guard is a belt-and-braces
// for the case where input routing skips the consumed flag.
if (!_consumedOpeningEsc)
{
_consumedOpeningEsc = true;
return;
}
GetViewport().SetInputAsHandled();
if (_showingSlots) BuildMain();
else Close();
}
// ──────────────────────────────────────────────────────────────────────
// Layout helpers
private void ClearContent()
{
foreach (Node child in _content.GetChildren()) child.QueueFree();
}
private void AddSpacer(int height)
=> _content.AddChild(new Control { CustomMinimumSize = new Vector2(0, height) });
private static Button MakeMenuButton(string text, bool primary)
{
var btn = new Button
{
Text = text,
FocusMode = Control.FocusModeEnum.None,
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
CustomMinimumSize = new Vector2(0, 40),
};
if (primary) btn.ThemeTypeVariation = "PrimaryButton";
return btn;
}
private void ShowStatus(string text)
{
if (_statusLabel is not null) _statusLabel.Text = text;
}
}
+912
View File
@@ -0,0 +1,912 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Godot;
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Persistence;
using Theriapolis.Core.Rules.Combat;
using Theriapolis.Core.Rules.Quests;
using Theriapolis.Core.Rules.Reputation;
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 + M7.3 — the play screen. Wraps <see cref="WorldRenderNode"/>
/// with the game-state layer: player actor, world clock, chunk
/// streamer, NPC markers, player controller, save layer, and a
/// top-left HUD overlay. Click on the world map to travel; WASD to
/// step at tactical zoom. F5 quicksaves; Esc returns to title (M7.4
/// will replace this with a pause menu).
///
/// Save round-trip (M7.3): <see cref="SaveTo"/> wraps
/// <see cref="SaveCodec.Serialize"/> + <see cref="SavePaths.WriteAtomic"/>.
/// <see cref="ApplyRestoredBody"/> on init consumes
/// <see cref="GameSession.PendingRestore"/> set by the
/// <see cref="SaveLoadScreen"/> hand-off; replaces the new-game spawn.
/// The save format is owned by Core and untouched — saves written by
/// the MonoGame build load here byte-identically and vice versa.
///
/// M7 sub-milestone status:
/// M7.4 (pause menu) — Esc still does quit-to-title for now.
/// M7.5 (interact prompt) — F-to-talk not yet wired.
/// M7.6 (encounter trigger stub) — hostile detection not yet wired.
/// </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!;
private readonly PlayerReputation _reputation = new();
private readonly Dictionary<string, int> _flags = new();
private readonly QuestEngine _questEngine = new();
private QuestContext? _questCtx;
// M7.5 — interact candidate cached per tick. Cleared when no
// friendly/neutral NPC is in range; the HUD shows "[F] Talk to ..."
// while non-null.
private NpcActor? _interactCandidate;
// Edge-detect F for the talk handler so a held key doesn't fire twice.
private bool _fWasDown;
// M7.6 — most recent hostile NPC id that tripped the encounter trigger.
// Edge-detection: only fires the stub once per *fresh* hostile entering
// range, so walking next to the same wolf doesn't spam autosaves.
private int _lastHostileTriggerId;
// M7.3 — save round-trip plumbing
private readonly Dictionary<ChunkCoord, HashSet<int>> _killedByChunk = new();
// Phase 5 M5: mid-combat encounter snapshot waiting for the CombatHUD
// push. Captured by load, picked up after chunks load; M8 will turn
// this into an actual push to the combat screen.
private EncounterState? _pendingEncounterRestore;
private float _saveFlashTimer;
private string _saveFlashText = "";
// 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!;
private Label _cursorDebugLabel = null!;
private Label? _saveFlashLabel;
// Reused per-frame builders — avoid GC pressure on hot _Process path.
// Holding a key produces auto-repeat InputEventKey objects that the C#
// GC must release before engine shutdown asserts on empty bindings;
// reducing per-frame allocations buys headroom for those collections.
private readonly System.Text.StringBuilder _cursorSb = new(256);
private readonly System.Text.StringBuilder _hudSb = new(256);
// 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);
// Phase 6 M4 — quest context wraps content/actors/rep/flags/clock/
// world for the quest engine. Round-trips through the save body.
_questCtx = new QuestContext(
_content, _actors, _reputation, _flags, _anchorRegistry, _clock, _ctx.World);
// Spawn or restore the player. Restore wins when a load was queued.
if (session.PendingRestore is not null)
{
ApplyRestoredBody(session.PendingRestore);
}
else
{
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;
// Set the initial zoom BEFORE building the player marker so the
// counter-scale below picks a sane scale on the spawn frame.
_render.Camera.Position = new Vector2(_actors.Player!.Position.X, _actors.Player.Position.Y);
SetInitialZoom();
// Player marker.
_playerMarker = new PlayerMarker
{
Position = new Vector2(_actors.Player.Position.X, _actors.Player.Position.Y),
Rotation = _actors.Player.FacingAngleRad,
Scale = CounterScaleVec(),
};
AddChild(_playerMarker);
BuildHud();
// M7.5/M8 will pick up _pendingEncounterRestore here once the
// combat HUD screen exists. For now we keep the snapshot on the
// body so a re-save preserves it across the Godot↔MonoGame round
// trip, but we don't attempt to resume combat.
if (_pendingEncounterRestore is not null)
GD.Print("[play] Loaded save has an active encounter — "
+ "combat HUD ships with M8; encounter preserved through save round-trip.");
// 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;
// WASD is context-sensitive: tactical mode steps the player,
// world-map mode pans the camera. Same keys, intent depends on zoom.
float wasdX = 0f, wasdY = 0f;
if (Godot.Input.IsKeyPressed(Key.W) || Godot.Input.IsKeyPressed(Key.Up)) wasdY -= 1f;
if (Godot.Input.IsKeyPressed(Key.S) || Godot.Input.IsKeyPressed(Key.Down)) wasdY += 1f;
if (Godot.Input.IsKeyPressed(Key.A) || Godot.Input.IsKeyPressed(Key.Left)) wasdX -= 1f;
if (Godot.Input.IsKeyPressed(Key.D) || Godot.Input.IsKeyPressed(Key.Right)) wasdX += 1f;
// Controller always ticks (path-follow runs even when WASD is idle).
// Pass step input only in tactical mode.
float stepX = tactical ? wasdX : 0f;
float stepY = tactical ? wasdY : 0f;
_controller.Update(dt, stepX, stepY, tactical, isFocused: true);
// World-map WASD pan. Skip while traveling — the follow logic below
// re-centres the camera on the player and would clobber the pan.
// Speed scales inversely with zoom so the on-screen pan rate feels
// consistent at any zoom level (matches MonoGame's 400 px/sec).
if (!tactical && !_controller.IsTraveling && (wasdX != 0f || wasdY != 0f))
{
const float PanScreenPxPerSec = 400f;
float invLen = (wasdX != 0f && wasdY != 0f) ? 0.70710678f : 1f;
float panSpeed = PanScreenPxPerSec / Mathf.Max(_render.Camera.Zoom.X, 0.01f);
_render.Camera.Position += new Vector2(wasdX * invLen, wasdY * invLen) * panSpeed * dt;
}
// Sync the player marker from Core state. Rotation drives the
// facing tick via the transform — auto-property setters on a
// PlayerMarker field would skip QueueRedraw and the cached
// _Draw commands would stay stuck at the initial angle.
var p = _actors.Player;
_playerMarker.Position = new Vector2(p.Position.X, p.Position.Y);
_playerMarker.Rotation = 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);
// M7.5 — friendly / neutral interact candidate. Only computed in
// tactical mode; world-map scale doesn't surface NPC interactions.
if (tactical)
_interactCandidate = EncounterTrigger.FindInteractCandidate(_actors);
else
_interactCandidate = null;
// M7.6 — hostile encounter stub. The real combat HUD ships with M8;
// for now, an autosave + console log + toast on each fresh hostile
// entering range gives the player a heads-up and ensures M8 has a
// valid snapshot to wire combat-restore into. Edge-detected by
// NPC id so movement past the same hostile doesn't refire.
if (tactical)
{
var hostile = EncounterTrigger.FindHostileTrigger(_actors);
if (hostile is not null)
{
if (hostile.Id != _lastHostileTriggerId)
{
_lastHostileTriggerId = hostile.Id;
string tpl = hostile.Template?.Id ?? "<resident>";
GD.Print($"[encounter] Would start fight with {hostile.DisplayName} "
+ $"(allegiance={hostile.Allegiance}, template={tpl})");
FlashSavedToast($"Combat HUD lands with M8 — encounter logged: {hostile.DisplayName}");
// NB: deliberately do NOT autosave here, even though the
// doc proposes it. SaveTo → CaptureBody → FlushAll evicts
// every loaded chunk, which despawns NPCs and respawns
// them on the next tactical tick with fresh actor ids —
// breaking _lastHostileTriggerId's edge detection and
// looping the stub. M8 owns combat-start autosave; at
// that point the combat HUD is pushed *before* FlushAll
// happens, so the encounter snapshot covers the live
// state and the loop can't form.
}
}
else
{
_lastHostileTriggerId = 0;
}
}
else
{
_lastHostileTriggerId = 0;
}
// F-press → push dialogue overlay. Edge-detect so a held key doesn't
// re-open the screen while the previous one is still up.
bool fNow = Godot.Input.IsKeyPressed(Key.F);
bool fJustDown = fNow && !_fWasDown;
_fWasDown = fNow;
if (fJustDown && _interactCandidate is not null)
{
AddChild(new InteractionScreen(_interactCandidate, this));
_interactCandidate = null;
}
// 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);
}
// Save-flash toast decay.
if (_saveFlashTimer > 0f)
{
_saveFlashTimer = MathF.Max(0f, _saveFlashTimer - dt);
if (_saveFlashLabel is not null)
{
_saveFlashLabel.Text = _saveFlashText;
_saveFlashLabel.Modulate = new Color(1, 1, 1, Mathf.Min(1f, _saveFlashTimer / 0.5f));
_saveFlashLabel.Visible = _saveFlashTimer > 0f;
}
}
else if (_saveFlashLabel is not null && _saveFlashLabel.Visible)
{
_saveFlashLabel.Visible = false;
}
UpdateHud(tactical);
UpdateCursorDebug(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;
}
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();
}
}
public override void _Input(InputEvent @event)
{
if (@event is not InputEventKey { Pressed: true } key) return;
if (key.Echo) return;
// Skip key events while the game is paused — the pause overlay
// owns input handling for itself; PlayScreen shouldn't see Esc/F5
// again until the overlay closes.
if (GetTree().Paused) return;
switch (key.Keycode)
{
case Key.F5:
SaveTo(SavePaths.AutosavePath());
GetViewport().SetInputAsHandled();
break;
case Key.Escape:
GetViewport().SetInputAsHandled();
AddChild(new PauseMenuScreen(this));
break;
}
}
/// <summary>Read-only accessor for the live player Character — used
/// by <see cref="PauseMenuScreen"/> to surface the level-up affordance
/// when eligible.</summary>
public Theriapolis.Core.Rules.Character.Character? PlayerCharacter()
=> _actors?.Player?.Character;
// ──────────────────────────────────────────────────────────────────────
// M7.5 accessors — let InteractionScreen build a DialogueContext +
// DialogueRunner from PlayScreen-owned aggregates without copying them.
internal Theriapolis.Core.Rules.Reputation.PlayerReputation Reputation => _reputation;
internal Dictionary<string, int> Flags => _flags;
internal QuestEngine QuestEngine => _questEngine;
internal WorldState World => _ctx.World;
internal ulong WorldSeed => _ctx.World.WorldSeed;
internal long ClockSeconds => _clock.InGameSeconds;
internal Vec2 PlayerPosition => _actors?.Player?.Position ?? new Vec2(0, 0);
internal ContentResolver? Content => _content;
/// <summary>Hand back a quest context wired to current actors/rep/flags
/// — used by InteractionScreen to fire start_quest effects after a
/// dialogue option resolves.</summary>
internal QuestContext? BuildQuestContextForDialogue()
{
if (_content is null || _questCtx is null) return null;
_questCtx.PlayerCharacter = _actors?.Player?.Character;
return _questCtx;
}
/// <summary>Surfaced to the toast layer so InteractionScreen can flash
/// "Shop ships with M8" without poking PlayScreen's private save-flash
/// machinery directly. M8 will swap this for a real ShopScreen push.</summary>
public void Toast(string text) => FlashSavedToast(text);
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;
_killedByChunk.TryGetValue(chunk.Coord, out var killed);
for (int i = 0; i < chunk.Spawns.Count; i++)
{
// Skip slots the player previously killed — they don't respawn
// on chunk reload until the save is wiped.
if (killed is not null && killed.Contains(i)) continue;
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)
{
// Stamp the counter-scale at construction time. NPCs spawn from
// OnChunkLoaded inside _Process, *after* the per-frame counter-scale
// loop has already iterated _npcMarkers. Without an initial scale,
// the new marker would render at Scale=(1,1) for one frame — at
// tactical zoom 32 that's a ~307 screen-pixel-radius red blob.
var marker = new NpcMarker
{
Position = new Vector2(npc.Position.X, npc.Position.Y),
Allegiance = npc.Allegiance,
Scale = CounterScaleVec(),
};
AddChild(marker);
_npcMarkers[npc.Id] = marker;
}
private Vector2 CounterScaleVec()
{
if (_render is null) return Vector2.One;
float zoom = _render.Camera.Zoom.X;
if (zoom <= 0f) return Vector2.One;
float inv = 1f / zoom;
return new Vector2(inv, inv);
}
// ──────────────────────────────────────────────────────────────────────
// M7.3 — Save / Load
/// <summary>Write the current state to the given slot path (atomic).</summary>
public bool SaveTo(string path)
{
try
{
var header = BuildHeader();
var body = CaptureBody();
var bytes = SaveCodec.Serialize(header, body);
SavePaths.WriteAtomic(path, bytes);
FlashSavedToast($"Saved to {Path.GetFileName(path)}");
return true;
}
catch (Exception ex)
{
FlashSavedToast($"Save failed: {ex.Message}");
GD.PushError($"[save] {ex}");
return false;
}
}
private SaveHeader BuildHeader()
{
var h = new SaveHeader
{
WorldSeedHex = $"0x{_ctx.World.WorldSeed:X}",
PlayerName = _actors.Player!.Name,
PlayerTier = _actors.Player.HighestTierReached,
InGameSeconds = _clock.InGameSeconds,
SavedAtUtc = DateTime.UtcNow.ToString("u"),
};
foreach (var kv in _ctx.World.StageHashes)
h.StageHashes[kv.Key] = $"0x{kv.Value:X}";
return h;
}
/// <summary>Build a save body from the current PlayScreen state. Mirrors
/// the MonoGame source's <c>CaptureBody</c> field-by-field so the
/// resulting byte stream is interoperable across builds.</summary>
private SaveBody CaptureBody()
{
// Mid-combat snapshot — null in M7 (combat HUD doesn't exist yet),
// but the field is preserved so a save loaded from MonoGame with
// an active encounter round-trips back through Godot intact.
EncounterState? activeEnc = _pendingEncounterRestore;
// Push every loaded chunk through eviction so any in-memory deltas
// land in the store before we read it.
_streamer.FlushAll();
var body = new SaveBody
{
Player = _actors.Player!.CaptureState(),
Clock = _clock.CaptureState(),
};
if (_actors.Player.Character is not null)
body.PlayerCharacter = CharacterCodec.Capture(_actors.Player.Character);
foreach (var kv in _deltas.All)
body.ModifiedChunks[kv.Key] = kv.Value;
foreach (var kv in _killedByChunk)
body.NpcRoster.ChunkDeltas.Add(new NpcChunkDelta
{
ChunkX = kv.Key.X,
ChunkY = kv.Key.Y,
KilledSpawnIndices = kv.Value.ToArray(),
});
body.ActiveEncounter = activeEnc;
body.ReputationState = ReputationCodec.Capture(_reputation);
body.Flags = new Dictionary<string, int>(_flags);
body.QuestEngineState = QuestCodec.Capture(_questEngine);
return body;
}
/// <summary>Restore PlayScreen state from a deserialised body. Caller
/// must have already set <c>_ctx</c>, <c>_content</c>, <c>_streamer</c>,
/// <c>_actors</c>, <c>_clock</c>, and <c>_anchorRegistry</c> — this is
/// invoked from <see cref="_Ready"/> after those are wired but before
/// the player marker is created.</summary>
private void ApplyRestoredBody(SaveBody body)
{
var player = _actors.RestorePlayer(body.Player);
_clock.RestoreState(body.Clock);
foreach (var kv in body.ModifiedChunks)
_deltas.Put(kv.Key, kv.Value);
foreach (var d in body.ModifiedWorldTiles)
{
ref var t = ref _ctx.World.TileAt(d.X, d.Y);
t.Biome = (BiomeId)d.NewBiome;
t.Features = (FeatureFlags)d.NewFeatures;
}
if (body.PlayerCharacter is not null)
player.Character = CharacterCodec.Restore(body.PlayerCharacter, _content);
_killedByChunk.Clear();
foreach (var d in body.NpcRoster.ChunkDeltas)
{
var coord = new ChunkCoord(d.ChunkX, d.ChunkY);
_killedByChunk[coord] = new HashSet<int>(d.KilledSpawnIndices);
}
// Defer the mid-combat encounter restore until M8 wires the combat
// HUD — but keep the body so a re-save round-trips byte-identical.
_pendingEncounterRestore = body.ActiveEncounter;
// Reputation aggregate — mutate the existing instance in place so
// consumers holding a reference (future ReputationScreen / dialogue
// runner) keep working.
var restoredRep = ReputationCodec.Restore(body.ReputationState);
_reputation.Factions.Clear();
foreach (var (k, v) in restoredRep.Factions.Standings) _reputation.Factions.Set(k, v);
_reputation.Personal.Clear();
foreach (var (k, v) in restoredRep.Personal) _reputation.Personal[k] = v;
_reputation.Ledger.Clear();
foreach (var ev in restoredRep.Ledger.Entries) _reputation.Ledger.Append(ev);
_flags.Clear();
foreach (var (k, v) in body.Flags) _flags[k] = v;
QuestCodec.Restore(_questEngine, body.QuestEngineState);
}
private void FlashSavedToast(string text)
{
_saveFlashText = text;
_saveFlashTimer = 2.5f;
}
// ──────────────────────────────────────────────────────────────────────
// 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()
{
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) + save-flash toast
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);
// Cursor-debug panel — top-right counterpart to the player-status
// panel. Shows tile coords, biome, feature flags, settlement,
// tactical-tile surface/deco, and any NPC under the mouse.
var cursorPanel = new PanelContainer
{
ThemeTypeVariation = "Card",
MouseFilter = MouseFilterEnum.Ignore,
AnchorLeft = 1, AnchorRight = 1,
OffsetLeft = -460, OffsetTop = 12, OffsetRight = -12, OffsetBottom = 260,
};
hudLayer.AddChild(cursorPanel);
var cursorMargin = new MarginContainer { MouseFilter = MouseFilterEnum.Ignore };
cursorMargin.AddThemeConstantOverride("margin_left", 12);
cursorMargin.AddThemeConstantOverride("margin_top", 8);
cursorMargin.AddThemeConstantOverride("margin_right", 12);
cursorMargin.AddThemeConstantOverride("margin_bottom", 8);
cursorPanel.AddChild(cursorMargin);
_cursorDebugLabel = new Label
{
Text = "CURSOR",
ThemeTypeVariation = "CardBody",
AutowrapMode = TextServer.AutowrapMode.WordSmart,
MouseFilter = MouseFilterEnum.Ignore,
};
cursorMargin.AddChild(_cursorDebugLabel);
// Save-flash toast, mounted bottom-center on the same canvas
// layer. Hidden by default; FlashSavedToast pops it in.
_saveFlashLabel = new Label
{
Text = "",
ThemeTypeVariation = "Eyebrow",
HorizontalAlignment = HorizontalAlignment.Center,
MouseFilter = MouseFilterEnum.Ignore,
AnchorLeft = 0.5f, AnchorRight = 0.5f,
AnchorTop = 1.0f, AnchorBottom = 1.0f,
OffsetLeft = -180, OffsetRight = 180,
OffsetTop = -56, OffsetBottom = -28,
Visible = false,
};
hudLayer.AddChild(_saveFlashLabel);
}
/// <summary>Top-right debug panel — what is under the mouse this
/// frame. World/tile coords, biome, feature flags, the settlement
/// whose footprint contains the tile, the tactical surface + deco
/// + walkability when zoomed in, and any NPC within hit radius.</summary>
private void UpdateCursorDebug(bool tactical)
{
var screenPos = GetViewport().GetMousePosition();
var worldPos = ScreenToWorld(screenPos);
int tx = (int)Mathf.Floor(worldPos.X / C.WORLD_TILE_PIXELS);
int ty = (int)Mathf.Floor(worldPos.Y / C.WORLD_TILE_PIXELS);
var sb = _cursorSb;
sb.Clear();
sb.Append("CURSOR world (").Append((int)worldPos.X).Append(", ")
.Append((int)worldPos.Y).Append(") tile (")
.Append(tx).Append(", ").Append(ty).Append(')').AppendLine();
if ((uint)tx < C.WORLD_WIDTH_TILES && (uint)ty < C.WORLD_HEIGHT_TILES)
{
ref var t = ref _ctx.World.TileAt(tx, ty);
sb.Append(" Biome: ").Append(t.Biome).AppendLine();
if (t.Features != FeatureFlags.None)
sb.Append(" Flags: ").Append(t.Features).AppendLine();
// Copy SettlementId out of the ref local before the lambda
// capture below — `ref var t` can't escape into a closure.
int settlementId = t.SettlementId;
if (settlementId != 0)
{
var settle = _ctx.World.Settlements.FirstOrDefault(s => s.Id == settlementId);
if (settle is not null)
{
sb.Append(" Settlement: ").Append(settle.Name)
.Append(" (Tier ").Append(settle.Tier).Append(')').AppendLine();
if (!settle.IsPoi)
sb.Append(" ").Append(settle.Economy)
.Append(" · ").Append(settle.Governance)
.Append(" · pop ").Append(settle.Population).AppendLine();
else if (settle.PoiType != PoiType.None)
sb.Append(" PoI: ").Append(settle.PoiType).AppendLine();
}
}
if (tactical)
{
int tacticalX = (int)Mathf.Floor(worldPos.X);
int tacticalY = (int)Mathf.Floor(worldPos.Y);
var tt = _streamer.SampleTile(tacticalX, tacticalY);
string move = !tt.IsWalkable ? "blocked"
: tt.SlowsMovement ? "slow" : "walkable";
string deco = tt.Deco == TacticalDeco.None ? "—" : tt.Deco.ToString();
sb.Append(" Tactical (").Append(tacticalX).Append(", ").Append(tacticalY).Append(')').AppendLine();
sb.Append(" Surface: ").Append(tt.Surface)
.Append(" (v").Append(tt.Variant).Append(") Deco: ").Append(deco).AppendLine();
sb.Append(" Move: ").Append(move).AppendLine();
}
}
else
{
sb.Append(" <off-world>").AppendLine();
}
// Bridge under cursor (point-on-segment test — cheap, ≤ a few dozen bridges).
const float BridgeHitPx = 6f;
foreach (var bridge in _ctx.World.Bridges)
{
if (DistancePointToSegmentSq(worldPos.X, worldPos.Y,
bridge.Start.X, bridge.Start.Y, bridge.End.X, bridge.End.Y) < BridgeHitPx * BridgeHitPx)
{
sb.Append(" Bridge over road ").Append(bridge.RoadId).AppendLine();
break;
}
}
// NPC under cursor (within marker hit radius).
const float NpcHitPx = 12f;
float closestSq = NpcHitPx * NpcHitPx;
NpcActor? hovered = null;
foreach (var npc in _actors.Npcs)
{
float ddx = npc.Position.X - worldPos.X;
float ddy = npc.Position.Y - worldPos.Y;
float distSq = ddx * ddx + ddy * ddy;
if (distSq < closestSq) { closestSq = distSq; hovered = npc; }
}
if (hovered is not null)
{
string tag = !string.IsNullOrEmpty(hovered.RoleTag)
? hovered.RoleTag
: (hovered.Template?.Id ?? "<resident>");
sb.AppendLine();
sb.Append("NPC: ").Append(hovered.DisplayName)
.Append(" [").Append(tag).Append(']').AppendLine();
sb.Append(" Allegiance: ").Append(hovered.Allegiance)
.Append(" HP ").Append(hovered.CurrentHp).Append('/').Append(hovered.MaxHp).AppendLine();
}
_cursorDebugLabel.Text = sb.ToString().TrimEnd();
}
private static float DistancePointToSegmentSq(float px, float py,
float ax, float ay, float bx, float by)
{
float vx = bx - ax, vy = by - ay;
float wx = px - ax, wy = py - ay;
float c1 = vx * wx + vy * wy;
if (c1 <= 0f) return wx * wx + wy * wy;
float c2 = vx * vx + vy * vy;
if (c2 <= c1) { float ex = px - bx, ey = py - by; return ex * ex + ey * ey; }
float t = c1 / c2;
float qx = ax + t * vx, qy = ay + t * vy;
float dx = px - qx, dy = py - qy;
return dx * dx + dy * dy;
}
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 (WASD to pan · click a tile to travel)";
string status = _controller.IsTraveling
? "Traveling…"
: tactical
? "Mouse-wheel out to leave tactical."
: "Mouse-wheel in for tactical.";
string interactBlock = _interactCandidate is { } npc
? $"\n[F] Talk to {npc.DisplayName}"
: "";
_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" +
"F5 quicksaves · Esc opens pause" + interactBlock;
}
// ──────────────────────────────────────────────────────────────────────
// Quit path
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();
}
}
+126
View File
@@ -0,0 +1,126 @@
using Godot;
using Theriapolis.GodotHost.UI;
namespace Theriapolis.GodotHost.Scenes;
/// <summary>
/// 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 <see cref="GameSession.Ctx"/> and <see cref="GameSession.PendingCharacter"/>
/// so the play-test confirms the M7.1 hand-off chain end-to-end:
/// Title → Wizard → CharacterAssembler → WorldGenProgress → here.
/// </summary>
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();
}
}
+171
View File
@@ -0,0 +1,171 @@
using System;
using System.IO;
using Godot;
using Theriapolis.Core;
using Theriapolis.Core.Persistence;
using Theriapolis.GodotHost.Platform;
using Theriapolis.GodotHost.UI;
namespace Theriapolis.GodotHost.Scenes;
/// <summary>
/// M7.3 — slot picker for *load*. Pushed by TitleScreen's "Continue"
/// when at least one compatible save exists. Lists the autosave row
/// followed by slots 1..<see cref="C.SAVE_SLOT_COUNT"/>; reads each
/// slot's header (cheap) for the label and disables incompatible /
/// unreadable rows.
///
/// On slot click: deserialise, stash the body + header + seed on
/// <see cref="GameSession"/>, swap to <see cref="WorldGenProgressScreen"/>
/// which will hand off to <see cref="PlayScreen"/> with the
/// restore-from-save path.
///
/// Save-from-pause (write) is M7.4 territory and intentionally lives
/// in a separate widget — keeps each picker single-purpose.
/// </summary>
public partial class SaveLoadScreen : 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);
BuildUI();
}
private void BuildUI()
{
var center = new CenterContainer { MouseFilter = MouseFilterEnum.Ignore };
AddChild(center);
center.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
var col = new VBoxContainer { CustomMinimumSize = new Vector2(520, 0) };
col.AddThemeConstantOverride("separation", 10);
center.AddChild(col);
col.AddChild(new Label
{
Text = "LOAD GAME",
ThemeTypeVariation = "H2",
HorizontalAlignment = HorizontalAlignment.Center,
});
// Autosave row first; numbered slots after.
AddSlotRow(col, "Autosave", SavePaths.AutosavePath());
for (int i = 1; i <= C.SAVE_SLOT_COUNT; i++)
AddSlotRow(col, $"Slot {i:D2}", SavePaths.SlotPath(i));
var spacer = new Control { CustomMinimumSize = new Vector2(0, 12) };
col.AddChild(spacer);
var back = new Button
{
Text = "← Back",
CustomMinimumSize = new Vector2(220, 40),
SizeFlagsHorizontal = SizeFlags.ShrinkCenter,
};
back.Pressed += BackToTitle;
col.AddChild(back);
}
private void AddSlotRow(VBoxContainer parent, string label, string path)
{
string text;
bool clickable = false;
bool exists = File.Exists(path);
if (exists)
{
try
{
var bytes = File.ReadAllBytes(path);
var header = SaveCodec.DeserializeHeaderOnly(bytes);
if (SaveCodec.IsCompatible(header))
{
text = SaveSlotFormat.FormatRow(label, header);
clickable = true;
}
else
{
text = $"{label} — <v{header.Version}: {SaveCodec.IncompatibilityReason(header)}>";
}
}
catch (Exception ex)
{
text = $"{label} — <unreadable: {ex.Message}>";
}
}
else
{
text = $"{label} — <empty>";
}
var btn = new Button
{
Text = text,
CustomMinimumSize = new Vector2(0, 40),
SizeFlagsHorizontal = SizeFlags.ExpandFill,
Disabled = !clickable,
Alignment = HorizontalAlignment.Left,
};
if (clickable) btn.Pressed += () => LoadSlot(path);
parent.AddChild(btn);
}
private void LoadSlot(string path)
{
try
{
var bytes = File.ReadAllBytes(path);
var (header, body) = SaveCodec.Deserialize(bytes);
if (!SaveCodec.IsCompatible(header))
{
GD.PushError($"[saveload] Refused incompatible save at {path}: "
+ SaveCodec.IncompatibilityReason(header));
return;
}
var session = GameSession.From(this);
session.Seed = header.ParseSeed();
session.PendingRestore = body;
session.PendingHeader = header;
session.PendingCharacter = null; // restore path supplies it via body
// Swap Title → WorldGenProgress (which will swap to PlayScreen
// once the pipeline finishes and stage-hash drift is checked).
var parent = GetParent();
if (parent is null) return;
foreach (Node sibling in parent.GetChildren())
if (sibling != this) sibling.QueueFree();
parent.AddChild(new WorldGenProgressScreen());
QueueFree();
}
catch (Exception ex)
{
GD.PushError($"[saveload] Failed to load {path}: {ex}");
}
}
private void BackToTitle()
{
var parent = GetParent();
if (parent is null) return;
foreach (Node sibling in parent.GetChildren())
if (sibling != this) sibling.QueueFree();
parent.AddChild(new TitleScreen());
QueueFree();
}
public override void _UnhandledInput(InputEvent @event)
{
if (@event is InputEventKey { Pressed: true, Keycode: Key.Escape })
{
GetViewport().SetInputAsHandled();
BackToTitle();
}
}
}
+61 -9
View File
@@ -18,7 +18,7 @@ namespace Theriapolis.GodotHost.Scenes;
/// </summary> /// </summary>
public partial class TitleScreen : Control public partial class TitleScreen : Control
{ {
private const string VersionLabel = "PORT / GODOT · M6.20"; private const string VersionLabel = "PORT / GODOT · M7.6";
private const string WizardScenePath = "res://Scenes/Wizard.tscn"; private const string WizardScenePath = "res://Scenes/Wizard.tscn";
public override void _Ready() public override void _Ready()
@@ -70,7 +70,7 @@ public partial class TitleScreen : Control
buttonStack.AddChild(newBtn); buttonStack.AddChild(newBtn);
var continueBtn = MakeMenuButton("Continue", primary: false); var continueBtn = MakeMenuButton("Continue", primary: false);
continueBtn.Disabled = !FileAccess.FileExists(CharacterAssembler.PersistedStatePath); continueBtn.Disabled = !AnyCompatibleSaveExists();
continueBtn.Pressed += OnContinue; continueBtn.Pressed += OnContinue;
buttonStack.AddChild(continueBtn); buttonStack.AddChild(continueBtn);
@@ -121,10 +121,15 @@ public partial class TitleScreen : Control
if (sibling != this) sibling.QueueFree(); if (sibling != this) sibling.QueueFree();
var wizardNode = packed.Instantiate(); var wizardNode = packed.Instantiate();
parent.AddChild(wizardNode); 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) if (wizardNode is Wizard wizard)
{
// "← Title" back-button (visible on step 0) emits BackToTitle.
wizard.BackToTitle += () => SwapBackToTitle(parent); 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(); QueueFree();
} }
@@ -134,14 +139,61 @@ public partial class TitleScreen : Control
parent.AddChild(new TitleScreen()); parent.AddChild(new TitleScreen());
} }
/// <summary>M7.1 hand-off: snapshot the built character + chosen
/// name onto <see cref="GameSession"/>, default the seed (a seed-entry
/// UI lands later), and swap to <see cref="WorldGenProgressScreen"/>.</summary>
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() private void OnContinue()
{ {
// M7 territory — the play-loop screens that consume the persisted var parent = GetParent();
// character don't exist yet. For now, surface a print so the click if (parent is null) return;
// does something visible and the button isn't dead UI. foreach (Node sibling in parent.GetChildren())
GD.Print($"[title] Continue: {CharacterAssembler.PersistedStatePath} exists. " if (sibling != this) sibling.QueueFree();
+ "Play-loop pickup lands with M7."); parent.AddChild(new SaveLoadScreen());
QueueFree();
} }
private void OnQuit() => GetTree().Quit(); private void OnQuit() => GetTree().Quit();
/// <summary>True iff at least one slot under <see cref="Platform.SavePaths.SavesDir"/>
/// has a header that <see cref="Theriapolis.Core.Persistence.SaveCodec.IsCompatible"/>
/// accepts. Cheap: <see cref="Theriapolis.Core.Persistence.SaveCodec.DeserializeHeaderOnly"/>
/// reads only the JSON prefix, not the binary body.</summary>
private static bool AnyCompatibleSaveExists()
{
try
{
string dir = Platform.SavePaths.SavesDir;
if (!System.IO.Directory.Exists(dir)) return false;
foreach (var path in System.IO.Directory.EnumerateFiles(dir, "*.trps"))
{
try
{
var bytes = System.IO.File.ReadAllBytes(path);
var header = Theriapolis.Core.Persistence.SaveCodec.DeserializeHeaderOnly(bytes);
if (Theriapolis.Core.Persistence.SaveCodec.IsCompatible(header)) return true;
}
catch { /* skip broken slot */ }
}
}
catch { /* defensive */ }
return false;
}
} }
+12
View File
@@ -15,6 +15,11 @@ public partial class Wizard : Control
{ {
[Signal] public delegate void BackToTitleEventHandler(); [Signal] public delegate void BackToTitleEventHandler();
/// <summary>Forwarded from <c>StepReview.CharacterConfirmed</c> so
/// the wizard's owner (TitleScreen / Main) can hand off to the
/// WorldGenProgressScreen without reaching into the step tree.</summary>
[Signal] public delegate void CharacterConfirmedEventHandler(UI.CharacterDraft draft);
private static readonly string[] StepKeys = private static readonly string[] StepKeys =
{ "clade", "species", "class", "subclass", "background", "stats", "skills", "review" }; { "clade", "species", "class", "subclass", "background", "stats", "skills", "review" };
private static readonly string[] StepNames = private static readonly string[] StepNames =
@@ -117,6 +122,13 @@ public partial class Wizard : Control
_activeStep = instance; _activeStep = instance;
instance.Bind(Character); instance.Bind(Character);
_stepHost.AddChild((Control)instance); _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(); UpdateChrome();
@@ -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;
/// <summary>
/// M7.1 — runs the 23-stage worldgen pipeline on a background thread
/// and shows per-stage progress. Transitions to <see cref="PlayScreenStub"/>
/// (which M7.2 will replace with the real PlayScreen) on completion.
///
/// Mirrors <c>Theriapolis.Game/Screens/WorldGenProgressScreen.cs</c>:
/// 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 <see cref="GameSession"/>):
/// - <c>Seed</c> — required.
/// - <c>PendingHeader</c> — present when restoring from save; triggers
/// the post-gen stage-hash diff against <c>WorldState.StageHashes</c>.
///
/// Outputs:
/// - <c>session.Ctx</c> set on success; consumed by the next screen.
///
/// Escape during generation: cancel the worker (honoured at the next
/// stage boundary), return to Title.
/// </summary>
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).");
}
}
+6
View File
@@ -21,6 +21,12 @@ window/size/mode=3
window/stretch/mode="canvas_items" window/stretch/mode="canvas_items"
window/stretch/aspect="expand" 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] [dotnet]
project/assembly_name="Theriapolis.Godot" project/assembly_name="Theriapolis.Godot"
File diff suppressed because it is too large Load Diff