Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a802fb318f | |||
| 289c918d6c | |||
| b1fc3f244b | |||
| 6f47700820 | |||
| 116193c1e3 | |||
| 8e2efdd878 | |||
| bf0041605f |
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 "<unknown>"
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 > TacticalRenderZoomMin.
|
|
||||||
/// Polylines/Bridges — Line2D children; always visible. Widths counter-
|
|
||||||
/// scaled per frame.
|
|
||||||
/// Settlements — SettlementDot children; visible only when zoom
|
|
||||||
/// < SettlementHideZoom.
|
|
||||||
/// Player — Always visible; counter-scaled.
|
|
||||||
///
|
|
||||||
/// Camera follows the player at all zooms; right-drag temporarily pans
|
|
||||||
/// (PanZoomCamera handles drag input).
|
|
||||||
/// </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('_', ' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user