M7.1-7.2: Play-loop hand-off — Wizard → WorldGen → PlayScreen
Lands the M7 plan's first two sub-milestones on port/godot. theriapolis-rpg-implementation-plan-godot-port-m7.md is the design doc (six screens collapse to four scenes + a camera mode, with per-screen behavioural contracts and a six-step sub-milestone breakdown). M7.1 — WorldGenProgressScreen + GameSession autoload + wizard hand-off rewrite. GameSession holds the cross-scene state that outlives any single screen: seed, post-worldgen Ctx, pending character (from the M6 wizard) and pending save snapshot (for M7.3's load path). Wizard forwards StepReview.CharacterConfirmed upward, and TitleScreen swaps to the progress screen instead of just printing the build summary. The progress screen runs the 23-stage pipeline on a background thread, drives a ProgressBar from ctx.ProgressCallback, and writes the full exception trace to user://worldgen_error.log on failure. Escape cancels at the next stage boundary and returns to title. M7.2 — PlayScreen with a walking character. Extracted WorldRenderNode from the M2+M4 WorldView demo so PlayScreen and WorldView mount the same renderer (biome image + polylines + bridges + settlement dots + tactical chunk lifecycle + PanZoomCamera + per-frame layer visibility + line-width counter-scaling). PlayScreen owns the streamer (M7.3 save needs it), composes ContentResolver + ActorManager + WorldClock + AnchorRegistry + PlayerController, spawns the player at the Tier-1 anchor, and wires resident + non-resident NPC spawning from chunk-load events with allegiance-tinted markers. PlayerController ported engine-agnostic to Theriapolis.Godot/Input/. Takes pre-resolved dx/dy/dt/isTactical/isFocused instead of poking MonoGame InputManager + Camera2D, so the arithmetic that advances PlayerActor.Position and WorldClock.InGameSeconds is bit-identical to the MonoGame version — saves round-trip cleanly. Click-to-travel in world-map mode (camera zoom < TacticalRenderZoomMin), WASD step in tactical mode with axis- separated motion + encumbrance + sub-second clock carry. HUD overlay top-left shows HP/AC/seed/tile/biome/view-mode/time. Esc returns to title (M7.4 replaces this with a pause menu). Namespace gotcha: Theriapolis.GodotHost.Input shadows the engine's Godot.Input static class for any file under the GodotHost namespace tree. Files needing keyboard polls (WorldView, PlayScreen) fully qualify as Godot.Input.IsKeyPressed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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,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,42 @@
|
||||
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.
|
||||
///
|
||||
/// Mirrors the MonoGame PlayerSprite's visual vocabulary — dark outline,
|
||||
/// faction-red fill, optional facing tick. Phase-7-styled `PlayerSprite`
|
||||
/// proper (walking animation frames) lands later.
|
||||
/// </summary>
|
||||
public partial class PlayerMarker : Node2D
|
||||
{
|
||||
private const float RadiusWorldPx = C.PLAYER_MARKER_SCREEN_PX * 0.5f;
|
||||
private const float FacingTickPx = RadiusWorldPx * 1.4f;
|
||||
|
||||
/// <summary>Facing direction in radians; 0 = +X. Drives the optional
|
||||
/// tick rendered on the body's leading edge.</summary>
|
||||
public float FacingAngleRad { get; set; }
|
||||
|
||||
/// <summary>When true, draws a small tick at the leading edge so the
|
||||
/// player can read facing without a full sprite. Hidden at low zoom
|
||||
/// to avoid clutter.</summary>
|
||||
public bool ShowFacingTick { get; set; } = true;
|
||||
|
||||
public override void _Draw()
|
||||
{
|
||||
DrawCircle(Vector2.Zero, RadiusWorldPx, new Color(0, 0, 0, 0.78f));
|
||||
DrawCircle(Vector2.Zero, RadiusWorldPx * 0.85f, new Color(0.86f, 0.31f, 0.24f));
|
||||
|
||||
if (ShowFacingTick)
|
||||
{
|
||||
var dir = new Vector2(Mathf.Cos(FacingAngleRad), Mathf.Sin(FacingAngleRad));
|
||||
DrawLine(dir * (RadiusWorldPx * 0.4f), dir * FacingTickPx,
|
||||
new Color(1f, 0.96f, 0.86f), width: RadiusWorldPx * 0.18f, antialiased: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,346 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Godot;
|
||||
using Theriapolis.Core;
|
||||
using Theriapolis.Core.Tactical;
|
||||
using Theriapolis.Core.Util;
|
||||
using Theriapolis.Core.World;
|
||||
using Theriapolis.Core.World.Polylines;
|
||||
|
||||
namespace Theriapolis.GodotHost.Rendering;
|
||||
|
||||
/// <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;
|
||||
if (_tacticalLayer is not null)
|
||||
_tacticalLayer.Visible = zoom >= TacticalRenderZoomMin;
|
||||
if (_settlementLayer is not null)
|
||||
_settlementLayer.Visible = zoom < SettlementHideZoom;
|
||||
}
|
||||
|
||||
private void UpdateZoomScaledNodes()
|
||||
{
|
||||
if (_camera is null) return;
|
||||
float zoom = _camera.Zoom.X;
|
||||
if (zoom <= 0f) return;
|
||||
float invZoom = 1f / zoom;
|
||||
foreach (var (line, baseScreenPx) in _scaledLines)
|
||||
line.Width = baseScreenPx * invZoom;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
|
||||
private static int RoadDrawRank(Polyline r) => r.RoadClassification switch
|
||||
{
|
||||
RoadType.Footpath => 0,
|
||||
RoadType.DirtRoad => 1,
|
||||
RoadType.PostRoad => 2,
|
||||
RoadType.Highway => 3,
|
||||
_ => 1,
|
||||
};
|
||||
|
||||
private static Color ColorByte(byte r, byte g, byte b) =>
|
||||
new(r / 255f, g / 255f, b / 255f);
|
||||
|
||||
private static BiomeId ParseBiomeId(string id) => id.ToLowerInvariant() switch
|
||||
{
|
||||
"ocean" => BiomeId.Ocean,
|
||||
"tundra" => BiomeId.Tundra,
|
||||
"boreal" => BiomeId.Boreal,
|
||||
"temperate_deciduous" => BiomeId.TemperateDeciduous,
|
||||
"temperate_grassland" => BiomeId.TemperateGrassland,
|
||||
"mountain_alpine" => BiomeId.MountainAlpine,
|
||||
"mountain_forested" => BiomeId.MountainForested,
|
||||
"subtropical_forest" => BiomeId.SubtropicalForest,
|
||||
"wetland" => BiomeId.Wetland,
|
||||
"coastal" => BiomeId.Coastal,
|
||||
"river_valley" => BiomeId.RiverValley,
|
||||
"scrubland" => BiomeId.Scrubland,
|
||||
"desert_cold" => BiomeId.DesertCold,
|
||||
"forest_edge" => BiomeId.ForestEdge,
|
||||
"foothills" => BiomeId.Foothills,
|
||||
"marsh_edge" => BiomeId.MarshEdge,
|
||||
"beach" => BiomeId.Beach,
|
||||
"cliff" => BiomeId.Cliff,
|
||||
"tidal_flat" => BiomeId.TidalFlat,
|
||||
"mangrove" => BiomeId.Mangrove,
|
||||
_ => BiomeId.TemperateGrassland,
|
||||
};
|
||||
}
|
||||
@@ -1,37 +1,24 @@
|
||||
using Godot;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Godot;
|
||||
using Theriapolis.Core;
|
||||
using Theriapolis.Core.Tactical;
|
||||
using Theriapolis.Core.Util;
|
||||
using Theriapolis.Core.World;
|
||||
using Theriapolis.Core.World.Generation;
|
||||
using Theriapolis.Core.World.Polylines;
|
||||
using Theriapolis.GodotHost.Platform;
|
||||
|
||||
namespace Theriapolis.GodotHost.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Unified seamless-zoom view (CLAUDE.md "Seamless Zoom Model"). One scene
|
||||
/// covers world-map and tactical scales; layers fade in/out at zoom
|
||||
/// thresholds. Polyline widths and the player marker counter-scale with
|
||||
/// zoom so they stay visually consistent across the full range.
|
||||
/// Standalone demo entry point for the M2+M4 unified seamless-zoom view.
|
||||
/// Runs worldgen inline, spawns a placeholder player, and lets you walk
|
||||
/// around with WASD. Used by the <c>--world-map</c> and <c>--tactical</c>
|
||||
/// CLI flags for headless / debug viewing without going through the
|
||||
/// title → wizard → progress flow.
|
||||
///
|
||||
/// Layers, bottom-up:
|
||||
/// BiomeLayer — 256x256 biome image, scaled by WORLD_TILE_PIXELS;
|
||||
/// always visible. Acts as the backdrop past the
|
||||
/// tactical streaming radius.
|
||||
/// TacticalChunks — TacticalChunkNode children added on chunk load;
|
||||
/// visible only when zoom > TacticalRenderZoomMin.
|
||||
/// Polylines/Bridges — Line2D children; always visible. Widths counter-
|
||||
/// scaled per frame.
|
||||
/// Settlements — SettlementDot children; visible only when zoom
|
||||
/// < SettlementHideZoom.
|
||||
/// Player — Always visible; counter-scaled.
|
||||
///
|
||||
/// Camera follows the player at all zooms; right-drag temporarily pans
|
||||
/// (PanZoomCamera handles drag input).
|
||||
/// The actual rendering — biome / polyline / settlement / chunk layers
|
||||
/// and the camera — lives in <see cref="WorldRenderNode"/>, which is
|
||||
/// shared with M7's <see cref="Scenes.PlayScreen"/>. This shell just
|
||||
/// owns the demo's local player position and streaming loop.
|
||||
/// </summary>
|
||||
public partial class WorldView : Node2D
|
||||
{
|
||||
@@ -40,56 +27,22 @@ public partial class WorldView : Node2D
|
||||
private readonly int _startWorldTileY;
|
||||
private readonly float _initialZoom;
|
||||
|
||||
// Zoom thresholds, in Camera2D zoom units (1.0 = 1 world px per screen px,
|
||||
// 32.0 = sprite-native tactical view, ~0.07 = world fits 1080p).
|
||||
private const float TacticalRenderZoomMin = 4.0f;
|
||||
private const float SettlementHideZoom = 2.0f;
|
||||
private const float StreamRadiusZoomMin = 4.0f;
|
||||
|
||||
// World-pixel movement speed. 32 wp = 1 world tile, so 96 = ~3 tiles/sec.
|
||||
private const float MoveSpeedWorldPx = 96f;
|
||||
private const int StreamingBufferWorldTiles = 2;
|
||||
|
||||
// Polyline base widths in *screen* pixels (counter-scaled to world space
|
||||
// per frame). Mirrors the differentiation in LineFeatureRenderer.cs.
|
||||
private const float HighwayScreenPx = 4f;
|
||||
private const float PostRoadScreenPx = 3f;
|
||||
private const float DirtRoadScreenPx = 2f;
|
||||
private const float RiverMajorScreenPx = 4.5f;
|
||||
private const float RiverScreenPx = 3f;
|
||||
private const float StreamScreenPx = 2f;
|
||||
private const float RailTieScreenPx = 4f;
|
||||
private const float RailLineScreenPx = 2f;
|
||||
private const float BridgeScreenPx = 6f;
|
||||
|
||||
// Polyline colours mirror LineFeatureRenderer.cs / WorldgenDump.cs.
|
||||
private static readonly Color RiverMajorColour = ColorByte(40, 100, 200);
|
||||
private static readonly Color RiverColour = ColorByte(60, 120, 200);
|
||||
private static readonly Color StreamColour = ColorByte(100, 150, 220);
|
||||
private static readonly Color HighwayColour = ColorByte(210, 180, 80);
|
||||
private static readonly Color PostRoadColour = ColorByte(180, 155, 70);
|
||||
private static readonly Color DirtRoadColour = ColorByte(150, 130, 90);
|
||||
private static readonly Color RailTieColour = ColorByte(120, 100, 80);
|
||||
private static readonly Color RailColour = ColorByte(80, 65, 50);
|
||||
private static readonly Color BridgeColour = ColorByte(160, 140, 100);
|
||||
private const float StreamRadiusZoomMin = WorldRenderNode.TacticalRenderZoomMin;
|
||||
|
||||
private ChunkStreamer? _streamer;
|
||||
private WorldRenderNode? _render;
|
||||
private Vec2 _playerPos;
|
||||
private PanZoomCamera? _camera;
|
||||
private Node2D? _tacticalLayer;
|
||||
private Node2D? _polylineLayer;
|
||||
private Node2D? _bridgeLayer;
|
||||
private Node2D? _settlementLayer;
|
||||
private PlayerMarker? _playerMarker;
|
||||
private readonly Dictionary<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)
|
||||
{
|
||||
_seed = seed;
|
||||
_startWorldTileX = startWorldTileX;
|
||||
_startWorldTileY = startWorldTileY;
|
||||
_initialZoom = initialZoom; // 0 = compute fit-to-viewport
|
||||
_initialZoom = initialZoom;
|
||||
}
|
||||
|
||||
public override void _Ready()
|
||||
@@ -109,17 +62,13 @@ public partial class WorldView : Node2D
|
||||
$"roads={world.Roads.Count} rails={world.Rails.Count} " +
|
||||
$"settlements={world.Settlements.Count} bridges={world.Bridges.Count}");
|
||||
|
||||
_render = new WorldRenderNode();
|
||||
AddChild(_render);
|
||||
_render.Initialize(world, _initialZoom);
|
||||
|
||||
_streamer = new ChunkStreamer(_seed, world, new InMemoryChunkDeltaStore());
|
||||
_streamer.OnChunkLoaded += AddChunkNode;
|
||||
_streamer.OnChunkEvicting += RemoveChunkNode;
|
||||
|
||||
TacticalAtlas.EnsureLoaded();
|
||||
|
||||
BuildBiomeSprite(world);
|
||||
_tacticalLayer = AddNamedLayer("TacticalChunks");
|
||||
BuildPolylines(world);
|
||||
BuildBridges(world);
|
||||
BuildSettlements(world);
|
||||
_streamer.OnChunkLoaded += _render.AddChunkNode;
|
||||
_streamer.OnChunkEvicting += _render.RemoveChunkNode;
|
||||
|
||||
_playerPos = new Vec2(
|
||||
_startWorldTileX * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f,
|
||||
@@ -128,20 +77,19 @@ public partial class WorldView : Node2D
|
||||
_playerMarker = new PlayerMarker { Position = new Vector2(_playerPos.X, _playerPos.Y) };
|
||||
AddChild(_playerMarker);
|
||||
|
||||
AddCamera();
|
||||
UpdateLayerVisibility();
|
||||
_render.Camera.Position = new Vector2(_playerPos.X, _playerPos.Y);
|
||||
StreamIfTactical();
|
||||
}
|
||||
|
||||
public override void _Process(double delta)
|
||||
{
|
||||
if (_camera is null || _playerMarker is null) return;
|
||||
if (_render is null || _playerMarker is null) return;
|
||||
|
||||
Vector2 dir = Vector2.Zero;
|
||||
if (Input.IsKeyPressed(Key.W) || Input.IsKeyPressed(Key.Up)) dir.Y -= 1;
|
||||
if (Input.IsKeyPressed(Key.S) || Input.IsKeyPressed(Key.Down)) dir.Y += 1;
|
||||
if (Input.IsKeyPressed(Key.A) || Input.IsKeyPressed(Key.Left)) dir.X -= 1;
|
||||
if (Input.IsKeyPressed(Key.D) || Input.IsKeyPressed(Key.Right)) dir.X += 1;
|
||||
if (Godot.Input.IsKeyPressed(Key.W) || Godot.Input.IsKeyPressed(Key.Up)) dir.Y -= 1;
|
||||
if (Godot.Input.IsKeyPressed(Key.S) || Godot.Input.IsKeyPressed(Key.Down)) dir.Y += 1;
|
||||
if (Godot.Input.IsKeyPressed(Key.A) || Godot.Input.IsKeyPressed(Key.Left)) dir.X -= 1;
|
||||
if (Godot.Input.IsKeyPressed(Key.D) || Godot.Input.IsKeyPressed(Key.Right)) dir.X += 1;
|
||||
|
||||
if (dir != Vector2.Zero)
|
||||
{
|
||||
@@ -160,309 +108,24 @@ public partial class WorldView : Node2D
|
||||
|
||||
var pos = new Vector2(_playerPos.X, _playerPos.Y);
|
||||
_playerMarker.Position = pos;
|
||||
_camera.Position = pos;
|
||||
_render.Camera.Position = pos;
|
||||
|
||||
UpdateLayerVisibility();
|
||||
UpdateZoomScaledNodes();
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Layer construction
|
||||
|
||||
private void BuildBiomeSprite(WorldState world)
|
||||
{
|
||||
int W = C.WORLD_WIDTH_TILES;
|
||||
int H = C.WORLD_HEIGHT_TILES;
|
||||
|
||||
var palette = new Color[(int)BiomeId.Mangrove + 1];
|
||||
foreach (var def in world.BiomeDefs!)
|
||||
{
|
||||
var (r, g, b) = def.ParsedColor();
|
||||
int id = (int)ParseBiomeId(def.Id);
|
||||
if (id >= 0 && id < palette.Length) palette[id] = ColorByte(r, g, b);
|
||||
}
|
||||
|
||||
var image = Image.CreateEmpty(W, H, false, Image.Format.Rgb8);
|
||||
for (int y = 0; y < H; y++)
|
||||
for (int x = 0; x < W; x++)
|
||||
{
|
||||
int id = (int)world.Tiles[x, y].Biome;
|
||||
Color c = (id >= 0 && id < palette.Length && palette[id].A > 0f)
|
||||
? palette[id]
|
||||
: ColorByte(255, 0, 255);
|
||||
image.SetPixel(x, y, c);
|
||||
}
|
||||
|
||||
var sprite = new Sprite2D
|
||||
{
|
||||
Texture = ImageTexture.CreateFromImage(image),
|
||||
Centered = false,
|
||||
Scale = new Vector2(C.WORLD_TILE_PIXELS, C.WORLD_TILE_PIXELS),
|
||||
TextureFilter = TextureFilterEnum.Nearest,
|
||||
Name = "Biome",
|
||||
};
|
||||
AddChild(sprite);
|
||||
}
|
||||
|
||||
private Node2D AddNamedLayer(string name)
|
||||
{
|
||||
var n = new Node2D { Name = name };
|
||||
AddChild(n);
|
||||
return n;
|
||||
}
|
||||
|
||||
private void BuildPolylines(WorldState world)
|
||||
{
|
||||
_polylineLayer = AddNamedLayer("Polylines");
|
||||
|
||||
foreach (var road in world.Roads.OrderBy(RoadDrawRank))
|
||||
{
|
||||
var (color, screenPx) = road.RoadClassification switch
|
||||
{
|
||||
RoadType.Highway => (HighwayColour, HighwayScreenPx),
|
||||
RoadType.PostRoad => (PostRoadColour, PostRoadScreenPx),
|
||||
_ => (DirtRoadColour, DirtRoadScreenPx),
|
||||
};
|
||||
AddScaledLine(_polylineLayer, road.Points, color, screenPx);
|
||||
}
|
||||
|
||||
foreach (var river in world.Rivers)
|
||||
{
|
||||
var (color, screenPx) = river.RiverClassification switch
|
||||
{
|
||||
RiverClass.MajorRiver => (RiverMajorColour, RiverMajorScreenPx),
|
||||
RiverClass.River => (RiverColour, RiverScreenPx),
|
||||
_ => (StreamColour, StreamScreenPx),
|
||||
};
|
||||
float flowScale = 1f + (river.FlowAccumulation / (float)C.RIVER_MAJOR_THRESHOLD) * 0.3f;
|
||||
AddScaledLine(_polylineLayer, river.Points, color,
|
||||
Mathf.Min(screenPx * flowScale, RiverMajorScreenPx * 1.5f));
|
||||
}
|
||||
|
||||
foreach (var rail in world.Rails)
|
||||
{
|
||||
AddScaledLine(_polylineLayer, rail.Points, RailTieColour, RailTieScreenPx);
|
||||
AddScaledLine(_polylineLayer, rail.Points, RailColour, RailLineScreenPx);
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildBridges(WorldState world)
|
||||
{
|
||||
if (world.Bridges.Count == 0) return;
|
||||
_bridgeLayer = AddNamedLayer("Bridges");
|
||||
|
||||
foreach (var bridge in world.Bridges)
|
||||
{
|
||||
var line = new Line2D
|
||||
{
|
||||
DefaultColor = BridgeColour,
|
||||
JointMode = Line2D.LineJointMode.Round,
|
||||
};
|
||||
line.AddPoint(new Vector2(bridge.Start.X, bridge.Start.Y));
|
||||
line.AddPoint(new Vector2(bridge.End.X, bridge.End.Y));
|
||||
_bridgeLayer.AddChild(line);
|
||||
_scaledLines.Add((line, BridgeScreenPx));
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildSettlements(WorldState world)
|
||||
{
|
||||
if (world.Settlements.Count == 0) return;
|
||||
_settlementLayer = AddNamedLayer("Settlements");
|
||||
|
||||
foreach (var s in world.Settlements)
|
||||
{
|
||||
var (colour, tileRadius) = s.Tier switch
|
||||
{
|
||||
1 => (ColorByte(255, 215, 0), 2.5f),
|
||||
2 => (ColorByte(230, 230, 230), 1.8f),
|
||||
3 => (ColorByte(150, 200, 255), 1.3f),
|
||||
4 => (ColorByte(200, 200, 200), 0.8f),
|
||||
_ => (ColorByte(200, 60, 60), 0.7f),
|
||||
};
|
||||
float radius = tileRadius * C.WORLD_TILE_PIXELS;
|
||||
var dot = new SettlementDot
|
||||
{
|
||||
Position = new Vector2(
|
||||
s.TileX * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f,
|
||||
s.TileY * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f),
|
||||
Radius = radius,
|
||||
FillColor = colour,
|
||||
};
|
||||
_settlementLayer.AddChild(dot);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddScaledLine(Node2D parent, IReadOnlyList<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);
|
||||
// Counter-scale the marker so its on-screen size stays constant.
|
||||
float zoom = _render.Camera.Zoom.X;
|
||||
if (zoom > 0f)
|
||||
_playerMarker.Scale = new Vector2(1f / zoom, 1f / zoom);
|
||||
}
|
||||
|
||||
private void StreamIfTactical()
|
||||
{
|
||||
if (_streamer is null) return;
|
||||
if (_camera is null || _camera.Zoom.X < StreamRadiusZoomMin)
|
||||
{
|
||||
// Optional: evict everything outside a small fallback set so we
|
||||
// don't keep a stale tactical cache when zoomed out for a long
|
||||
// time. Skipping for M4 — soft cap in the streamer handles it.
|
||||
return;
|
||||
}
|
||||
if (_streamer is null || _render is null) return;
|
||||
if (_render.Camera.Zoom.X < StreamRadiusZoomMin) return;
|
||||
|
||||
Vector2 viewport = GetViewport().GetVisibleRect().Size;
|
||||
float halfExtentWorldPx = Mathf.Max(viewport.X, viewport.Y) / _camera.Zoom.X * 0.5f;
|
||||
float halfExtentWorldPx = Mathf.Max(viewport.X, viewport.Y) / _render.Camera.Zoom.X * 0.5f;
|
||||
int halfExtentTiles = Mathf.CeilToInt(halfExtentWorldPx / C.WORLD_TILE_PIXELS);
|
||||
int radius = halfExtentTiles + StreamingBufferWorldTiles;
|
||||
|
||||
_streamer.EnsureLoadedAround(_playerPos, radius);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Chunk node lifecycle
|
||||
|
||||
private void AddChunkNode(TacticalChunk chunk)
|
||||
{
|
||||
if (_tacticalLayer is null) return;
|
||||
if (_chunkNodes.ContainsKey(chunk.Coord)) return;
|
||||
|
||||
var node = new TacticalChunkNode { Name = $"Chunk{chunk.Coord.X}_{chunk.Coord.Y}" };
|
||||
_tacticalLayer.AddChild(node);
|
||||
node.Bind(chunk);
|
||||
_chunkNodes[chunk.Coord] = node;
|
||||
}
|
||||
|
||||
private void RemoveChunkNode(TacticalChunk chunk)
|
||||
{
|
||||
if (!_chunkNodes.TryGetValue(chunk.Coord, out var node)) return;
|
||||
node.QueueFree();
|
||||
_chunkNodes.Remove(chunk.Coord);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
|
||||
private static int RoadDrawRank(Polyline r) => r.RoadClassification switch
|
||||
{
|
||||
RoadType.Footpath => 0,
|
||||
RoadType.DirtRoad => 1,
|
||||
RoadType.PostRoad => 2,
|
||||
RoadType.Highway => 3,
|
||||
_ => 1,
|
||||
};
|
||||
|
||||
private static Color ColorByte(byte r, byte g, byte b) =>
|
||||
new(r / 255f, g / 255f, b / 255f);
|
||||
|
||||
private static BiomeId ParseBiomeId(string id) => id.ToLowerInvariant() switch
|
||||
{
|
||||
"ocean" => BiomeId.Ocean,
|
||||
"tundra" => BiomeId.Tundra,
|
||||
"boreal" => BiomeId.Boreal,
|
||||
"temperate_deciduous" => BiomeId.TemperateDeciduous,
|
||||
"temperate_grassland" => BiomeId.TemperateGrassland,
|
||||
"mountain_alpine" => BiomeId.MountainAlpine,
|
||||
"mountain_forested" => BiomeId.MountainForested,
|
||||
"subtropical_forest" => BiomeId.SubtropicalForest,
|
||||
"wetland" => BiomeId.Wetland,
|
||||
"coastal" => BiomeId.Coastal,
|
||||
"river_valley" => BiomeId.RiverValley,
|
||||
"scrubland" => BiomeId.Scrubland,
|
||||
"desert_cold" => BiomeId.DesertCold,
|
||||
"forest_edge" => BiomeId.ForestEdge,
|
||||
"foothills" => BiomeId.Foothills,
|
||||
"marsh_edge" => BiomeId.MarshEdge,
|
||||
"beach" => BiomeId.Beach,
|
||||
"cliff" => BiomeId.Cliff,
|
||||
"tidal_flat" => BiomeId.TidalFlat,
|
||||
"mangrove" => BiomeId.Mangrove,
|
||||
_ => BiomeId.TemperateGrassland,
|
||||
};
|
||||
}
|
||||
|
||||
/// <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,400 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Godot;
|
||||
using Theriapolis.Core;
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Entities;
|
||||
using Theriapolis.Core.Rules.Combat;
|
||||
using Theriapolis.Core.Tactical;
|
||||
using Theriapolis.Core.Time;
|
||||
using Theriapolis.Core.Util;
|
||||
using Theriapolis.Core.World;
|
||||
using Theriapolis.Core.World.Generation;
|
||||
using Theriapolis.Core.World.Settlements;
|
||||
using Theriapolis.GodotHost.Input;
|
||||
using Theriapolis.GodotHost.Platform;
|
||||
using Theriapolis.GodotHost.Rendering;
|
||||
using Theriapolis.GodotHost.UI;
|
||||
|
||||
namespace Theriapolis.GodotHost.Scenes;
|
||||
|
||||
/// <summary>
|
||||
/// M7.2 — the play screen. Wraps <see cref="WorldRenderNode"/> with the
|
||||
/// game-state layer: player actor, world clock, chunk streamer, NPC
|
||||
/// markers, player controller, and a top-left HUD overlay. Click on the
|
||||
/// world map to travel; WASD to step at tactical zoom.
|
||||
///
|
||||
/// Per the M7 plan §6: PlayScreen owns the chunk streamer (so M7.3 save
|
||||
/// can serialise its delta store) and the actor manager (so M7.5 can
|
||||
/// drive interact prompts). The world-map view and the tactical view
|
||||
/// are the same scene at different zoom levels — there is no separate
|
||||
/// WorldMapScreen, by design.
|
||||
///
|
||||
/// M7.2 omissions (deferred to later sub-milestones):
|
||||
/// - Save / load round-trip (M7.3)
|
||||
/// - Pause menu (M7.4)
|
||||
/// - Interact prompt + dialogue push (M7.5)
|
||||
/// - Encounter detection stub + autosave toast (M7.6)
|
||||
/// </summary>
|
||||
public partial class PlayScreen : Control
|
||||
{
|
||||
private const float ClickSlopPixels = 4f;
|
||||
|
||||
// Composed Core systems
|
||||
private WorldGenContext _ctx = null!;
|
||||
private ContentResolver _content = null!;
|
||||
private InMemoryChunkDeltaStore _deltas = null!;
|
||||
private ChunkStreamer _streamer = null!;
|
||||
private ActorManager _actors = null!;
|
||||
private WorldClock _clock = null!;
|
||||
private PlayerController _controller = null!;
|
||||
private AnchorRegistry _anchorRegistry = null!;
|
||||
|
||||
// Godot tree
|
||||
private WorldRenderNode _render = null!;
|
||||
private PlayerMarker _playerMarker = null!;
|
||||
private readonly Dictionary<int, NpcMarker> _npcMarkers = new();
|
||||
private Label _hudLabel = null!;
|
||||
private PanelContainer _hudPanel = null!;
|
||||
|
||||
// Click-vs-drag state (left-click only; PanZoomCamera handles
|
||||
// middle/right-drag pan independently).
|
||||
private Vector2 _mouseDownPos;
|
||||
private int _mouseDownTileX, _mouseDownTileY;
|
||||
private bool _mouseDownTracked;
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
var session = GameSession.From(this);
|
||||
if (session.Ctx is null)
|
||||
{
|
||||
GD.PushError("[play] No WorldGenContext on session — falling back to title.");
|
||||
BackToTitle();
|
||||
return;
|
||||
}
|
||||
_ctx = session.Ctx;
|
||||
|
||||
Theme = CodexTheme.Build();
|
||||
SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||
|
||||
// World render layer — biome + polylines + settlements + camera.
|
||||
_render = new WorldRenderNode();
|
||||
AddChild(_render);
|
||||
_render.Initialize(_ctx.World);
|
||||
|
||||
// Core systems.
|
||||
_content = new ContentResolver(
|
||||
new Theriapolis.Core.Data.ContentLoader(ContentPaths.DataDir));
|
||||
_deltas = new InMemoryChunkDeltaStore();
|
||||
_streamer = new ChunkStreamer(
|
||||
_ctx.World.WorldSeed, _ctx.World, _deltas, _content.Settlements);
|
||||
_streamer.OnChunkLoaded += _render.AddChunkNode;
|
||||
_streamer.OnChunkEvicting += _render.RemoveChunkNode;
|
||||
_streamer.OnChunkLoaded += HandleChunkLoaded;
|
||||
_streamer.OnChunkEvicting += HandleChunkEvicting;
|
||||
|
||||
_clock = new WorldClock();
|
||||
_actors = new ActorManager();
|
||||
_anchorRegistry = new AnchorRegistry();
|
||||
_anchorRegistry.RegisterAllAnchors(_ctx.World);
|
||||
|
||||
// Spawn player at the Tier-1 anchor (Millhaven), or the centre of
|
||||
// the world if no inhabited settlement exists.
|
||||
var spawn = ChooseSpawn(_ctx.World);
|
||||
if (session.PendingCharacter is not null)
|
||||
{
|
||||
var p = _actors.SpawnPlayer(spawn, session.PendingCharacter);
|
||||
if (!string.IsNullOrWhiteSpace(session.PendingName))
|
||||
p.Name = session.PendingName;
|
||||
}
|
||||
else
|
||||
{
|
||||
_actors.SpawnPlayer(spawn);
|
||||
}
|
||||
|
||||
_controller = new PlayerController(_actors.Player!, _ctx.World, _clock);
|
||||
_controller.TacticalIsWalkable = (tx, ty) => _streamer.SampleTile(tx, ty).IsWalkable;
|
||||
|
||||
// Player marker.
|
||||
_playerMarker = new PlayerMarker
|
||||
{
|
||||
Position = new Vector2(_actors.Player!.Position.X, _actors.Player.Position.Y),
|
||||
FacingAngleRad = _actors.Player.FacingAngleRad,
|
||||
};
|
||||
AddChild(_playerMarker);
|
||||
|
||||
_render.Camera.Position = _playerMarker.Position;
|
||||
SetInitialZoom();
|
||||
BuildHud();
|
||||
|
||||
// Clear pending so a quit-to-title doesn't see stale data.
|
||||
session.PendingCharacter = null;
|
||||
session.PendingName = "Wanderer";
|
||||
session.PendingRestore = null;
|
||||
session.PendingHeader = null;
|
||||
}
|
||||
|
||||
public override void _Process(double delta)
|
||||
{
|
||||
if (_actors?.Player is null || _render is null) return;
|
||||
float dt = (float)delta;
|
||||
|
||||
bool tactical = _render.Camera.Zoom.X >= WorldRenderNode.TacticalRenderZoomMin;
|
||||
|
||||
// Tactical WASD direction (world-map mode ignores keys — middle-drag
|
||||
// pans, click-to-travel sets the destination).
|
||||
float dx = 0f, dy = 0f;
|
||||
if (tactical)
|
||||
{
|
||||
if (Godot.Input.IsKeyPressed(Key.W) || Godot.Input.IsKeyPressed(Key.Up)) dy -= 1f;
|
||||
if (Godot.Input.IsKeyPressed(Key.S) || Godot.Input.IsKeyPressed(Key.Down)) dy += 1f;
|
||||
if (Godot.Input.IsKeyPressed(Key.A) || Godot.Input.IsKeyPressed(Key.Left)) dx -= 1f;
|
||||
if (Godot.Input.IsKeyPressed(Key.D) || Godot.Input.IsKeyPressed(Key.Right)) dx += 1f;
|
||||
}
|
||||
|
||||
_controller.Update(dt, dx, dy, tactical, isFocused: true);
|
||||
|
||||
// Sync the player marker from Core state.
|
||||
var p = _actors.Player;
|
||||
_playerMarker.Position = new Vector2(p.Position.X, p.Position.Y);
|
||||
_playerMarker.FacingAngleRad = p.FacingAngleRad;
|
||||
|
||||
// Camera follow when traveling or in tactical (matches MonoGame).
|
||||
if (_controller.IsTraveling || tactical)
|
||||
_render.Camera.Position = _playerMarker.Position;
|
||||
|
||||
// Stream tactical chunks around the player when at tactical zoom.
|
||||
if (tactical)
|
||||
_streamer.EnsureLoadedAround(p.Position, C.TACTICAL_WINDOW_WORLD_TILES);
|
||||
|
||||
// Counter-scale markers so on-screen size stays constant.
|
||||
float zoom = _render.Camera.Zoom.X;
|
||||
if (zoom > 0f)
|
||||
{
|
||||
float inv = 1f / zoom;
|
||||
_playerMarker.Scale = new Vector2(inv, inv);
|
||||
_playerMarker.ShowFacingTick = tactical;
|
||||
foreach (var marker in _npcMarkers.Values)
|
||||
marker.Scale = new Vector2(inv, inv);
|
||||
}
|
||||
|
||||
UpdateHud(tactical);
|
||||
}
|
||||
|
||||
public override void _UnhandledInput(InputEvent @event)
|
||||
{
|
||||
if (@event is not InputEventMouseButton mb || mb.ButtonIndex != MouseButton.Left)
|
||||
return;
|
||||
|
||||
if (mb.Pressed)
|
||||
{
|
||||
_mouseDownPos = mb.Position;
|
||||
var worldPos = ScreenToWorld(mb.Position);
|
||||
_mouseDownTileX = (int)Mathf.Floor(worldPos.X / C.WORLD_TILE_PIXELS);
|
||||
_mouseDownTileY = (int)Mathf.Floor(worldPos.Y / C.WORLD_TILE_PIXELS);
|
||||
_mouseDownTracked = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Release.
|
||||
if (!_mouseDownTracked) return;
|
||||
_mouseDownTracked = false;
|
||||
bool wasClick = mb.Position.DistanceTo(_mouseDownPos) <= ClickSlopPixels;
|
||||
if (!wasClick) return;
|
||||
|
||||
bool tactical = _render.Camera.Zoom.X >= WorldRenderNode.TacticalRenderZoomMin;
|
||||
if (!tactical && InBounds(_mouseDownTileX, _mouseDownTileY))
|
||||
{
|
||||
_controller.RequestTravelTo(_mouseDownTileX, _mouseDownTileY);
|
||||
GetViewport().SetInputAsHandled();
|
||||
}
|
||||
}
|
||||
|
||||
private Vector2 ScreenToWorld(Vector2 screenPos)
|
||||
=> _render.Camera.GetCanvasTransform().AffineInverse() * screenPos;
|
||||
|
||||
private static bool InBounds(int x, int y)
|
||||
=> (uint)x < C.WORLD_WIDTH_TILES && (uint)y < C.WORLD_HEIGHT_TILES;
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Chunk → NPC lifecycle (Phase 5 M5)
|
||||
|
||||
private void HandleChunkLoaded(TacticalChunk chunk)
|
||||
{
|
||||
if (_content is null) return;
|
||||
for (int i = 0; i < chunk.Spawns.Count; i++)
|
||||
{
|
||||
var spawn = chunk.Spawns[i];
|
||||
if (_actors.FindNpcBySource(chunk.Coord, i) is not null) continue;
|
||||
|
||||
if (spawn.Kind == SpawnKind.Resident)
|
||||
{
|
||||
var resident = ResidentInstantiator.Spawn(
|
||||
_ctx.World.WorldSeed, chunk, i, spawn,
|
||||
_ctx.World, _content, _actors, _anchorRegistry);
|
||||
if (resident is not null) MountNpcMarker(resident);
|
||||
continue;
|
||||
}
|
||||
|
||||
var template = NpcInstantiator.PickTemplate(spawn.Kind, chunk.DangerZone, _content.Npcs);
|
||||
if (template is null) continue;
|
||||
|
||||
int tx = chunk.OriginX + spawn.LocalX;
|
||||
int ty = chunk.OriginY + spawn.LocalY;
|
||||
var npc = _actors.SpawnNpc(template, new Vec2(tx, ty), chunk.Coord, i);
|
||||
MountNpcMarker(npc);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleChunkEvicting(TacticalChunk chunk)
|
||||
{
|
||||
var toRemove = new List<int>();
|
||||
foreach (var npc in _actors.Npcs)
|
||||
{
|
||||
if (npc.SourceChunk is { } src && src.Equals(chunk.Coord))
|
||||
{
|
||||
toRemove.Add(npc.Id);
|
||||
if (!string.IsNullOrEmpty(npc.RoleTag))
|
||||
_anchorRegistry.UnregisterRole(npc.RoleTag);
|
||||
}
|
||||
}
|
||||
foreach (int id in toRemove)
|
||||
{
|
||||
_actors.RemoveActor(id);
|
||||
if (_npcMarkers.Remove(id, out var marker))
|
||||
marker.QueueFree();
|
||||
}
|
||||
}
|
||||
|
||||
private void MountNpcMarker(NpcActor npc)
|
||||
{
|
||||
var marker = new NpcMarker
|
||||
{
|
||||
Position = new Vector2(npc.Position.X, npc.Position.Y),
|
||||
Allegiance = npc.Allegiance,
|
||||
};
|
||||
AddChild(marker);
|
||||
_npcMarkers[npc.Id] = marker;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Spawn + initial-zoom helpers
|
||||
|
||||
private static Vec2 ChooseSpawn(WorldState w)
|
||||
{
|
||||
var tier1 = w.Settlements.FirstOrDefault(s => s.Tier == 1 && !s.IsPoi);
|
||||
if (tier1 is not null) return new Vec2(tier1.WorldPixelX, tier1.WorldPixelY);
|
||||
var anyInhabited = w.Settlements.FirstOrDefault(s => !s.IsPoi);
|
||||
if (anyInhabited is not null) return new Vec2(anyInhabited.WorldPixelX, anyInhabited.WorldPixelY);
|
||||
return new Vec2(
|
||||
C.WORLD_WIDTH_TILES * C.WORLD_TILE_PIXELS * 0.5f,
|
||||
C.WORLD_HEIGHT_TILES * C.WORLD_TILE_PIXELS * 0.5f);
|
||||
}
|
||||
|
||||
private void SetInitialZoom()
|
||||
{
|
||||
// Frame ~24 tiles across the screen — comfortable overland zoom.
|
||||
Vector2 viewport = GetViewport().GetVisibleRect().Size;
|
||||
float targetZoom = viewport.X / (24f * C.WORLD_TILE_PIXELS);
|
||||
targetZoom = Mathf.Clamp(targetZoom,
|
||||
_render.Camera.MinZoom,
|
||||
WorldRenderNode.TacticalRenderZoomMin * 0.95f);
|
||||
_render.Camera.Zoom = new Vector2(targetZoom, targetZoom);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// HUD overlay (top-left panel, codex-styled)
|
||||
|
||||
private void BuildHud()
|
||||
{
|
||||
var hudLayer = new CanvasLayer { Layer = 50, Name = "Hud" };
|
||||
AddChild(hudLayer);
|
||||
|
||||
_hudPanel = new PanelContainer
|
||||
{
|
||||
ThemeTypeVariation = "Card",
|
||||
MouseFilter = MouseFilterEnum.Ignore,
|
||||
OffsetLeft = 12, OffsetTop = 12,
|
||||
OffsetRight = 420, OffsetBottom = 220,
|
||||
};
|
||||
hudLayer.AddChild(_hudPanel);
|
||||
|
||||
var margin = new MarginContainer { MouseFilter = MouseFilterEnum.Ignore };
|
||||
margin.AddThemeConstantOverride("margin_left", 12);
|
||||
margin.AddThemeConstantOverride("margin_top", 8);
|
||||
margin.AddThemeConstantOverride("margin_right", 12);
|
||||
margin.AddThemeConstantOverride("margin_bottom", 8);
|
||||
_hudPanel.AddChild(margin);
|
||||
|
||||
_hudLabel = new Label
|
||||
{
|
||||
Text = "…",
|
||||
ThemeTypeVariation = "CardBody",
|
||||
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||
MouseFilter = MouseFilterEnum.Ignore,
|
||||
};
|
||||
margin.AddChild(_hudLabel);
|
||||
}
|
||||
|
||||
private void UpdateHud(bool tactical)
|
||||
{
|
||||
var p = _actors.Player!;
|
||||
int ptx = (int)Mathf.Floor(p.Position.X / C.WORLD_TILE_PIXELS);
|
||||
int pty = (int)Mathf.Floor(p.Position.Y / C.WORLD_TILE_PIXELS);
|
||||
int cx = Mathf.Clamp(ptx, 0, C.WORLD_WIDTH_TILES - 1);
|
||||
int cy = Mathf.Clamp(pty, 0, C.WORLD_HEIGHT_TILES - 1);
|
||||
ref var t = ref _ctx.World.TileAt(cx, cy);
|
||||
|
||||
string charBlock = "";
|
||||
if (p.Character is { } pc)
|
||||
{
|
||||
int ac = Theriapolis.Core.Rules.Stats.DerivedStats.ArmorClass(pc);
|
||||
charBlock = $"{p.Name} HP {pc.CurrentHp}/{pc.MaxHp} AC {ac}\n";
|
||||
}
|
||||
|
||||
string viewBlock = tactical
|
||||
? "View: Tactical (WASD to step)"
|
||||
: "View: World Map (click a tile to travel)";
|
||||
|
||||
string status = _controller.IsTraveling
|
||||
? "Traveling…"
|
||||
: tactical
|
||||
? "Mouse-wheel out to leave tactical."
|
||||
: "Mouse-wheel in for tactical.";
|
||||
|
||||
_hudLabel.Text =
|
||||
charBlock +
|
||||
$"Seed: 0x{_ctx.World.WorldSeed:X}\n" +
|
||||
$"Player: ({ptx}, {pty}) {t.Biome}\n" +
|
||||
$"{viewBlock}\n" +
|
||||
$"Time: Day {_clock.Day}, {_clock.Hour:D2}:{_clock.Minute:D2}\n" +
|
||||
$"{status}\n" +
|
||||
"M7.3 brings save/load. ESC pause arrives M7.4.";
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Quit path (M7.4 will wire ESC → pause; for now Esc returns to title)
|
||||
|
||||
public override void _Input(InputEvent @event)
|
||||
{
|
||||
if (@event is InputEventKey { Pressed: true, Keycode: Key.Escape })
|
||||
{
|
||||
GetViewport().SetInputAsHandled();
|
||||
BackToTitle();
|
||||
}
|
||||
}
|
||||
|
||||
private void BackToTitle()
|
||||
{
|
||||
var session = GameSession.From(this);
|
||||
session.ClearPending();
|
||||
session.Ctx = null;
|
||||
|
||||
var parent = GetParent();
|
||||
if (parent is null) return;
|
||||
foreach (Node sibling in parent.GetChildren())
|
||||
if (sibling != this) sibling.QueueFree();
|
||||
parent.AddChild(new TitleScreen());
|
||||
QueueFree();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ namespace Theriapolis.GodotHost.Scenes;
|
||||
/// </summary>
|
||||
public partial class TitleScreen : Control
|
||||
{
|
||||
private const string VersionLabel = "PORT / GODOT · M6.20";
|
||||
private const string VersionLabel = "PORT / GODOT · M7.2";
|
||||
private const string WizardScenePath = "res://Scenes/Wizard.tscn";
|
||||
|
||||
public override void _Ready()
|
||||
@@ -121,10 +121,15 @@ public partial class TitleScreen : Control
|
||||
if (sibling != this) sibling.QueueFree();
|
||||
var wizardNode = packed.Instantiate();
|
||||
parent.AddChild(wizardNode);
|
||||
// The wizard's "← Title" back-button (visible on step 0) emits
|
||||
// BackToTitle; reinstate this title screen when that fires.
|
||||
if (wizardNode is Wizard wizard)
|
||||
{
|
||||
// "← Title" back-button (visible on step 0) emits BackToTitle.
|
||||
wizard.BackToTitle += () => SwapBackToTitle(parent);
|
||||
// M7.1 — Confirm & Begin in StepReview is forwarded by the
|
||||
// wizard as CharacterConfirmed. Stash the built character on
|
||||
// GameSession and hand off to WorldGenProgressScreen.
|
||||
wizard.CharacterConfirmed += draft => SwapToWorldGen(parent, draft);
|
||||
}
|
||||
QueueFree();
|
||||
}
|
||||
|
||||
@@ -134,6 +139,27 @@ public partial class TitleScreen : Control
|
||||
parent.AddChild(new TitleScreen());
|
||||
}
|
||||
|
||||
/// <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()
|
||||
{
|
||||
// M7 territory — the play-loop screens that consume the persisted
|
||||
|
||||
@@ -15,6 +15,11 @@ public partial class Wizard : Control
|
||||
{
|
||||
[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 =
|
||||
{ "clade", "species", "class", "subclass", "background", "stats", "skills", "review" };
|
||||
private static readonly string[] StepNames =
|
||||
@@ -117,6 +122,13 @@ public partial class Wizard : Control
|
||||
_activeStep = instance;
|
||||
instance.Bind(Character);
|
||||
_stepHost.AddChild((Control)instance);
|
||||
|
||||
// Forward the final-step confirmation upward so TitleScreen
|
||||
// (or whatever shell owns the wizard) can swap to M7.1's
|
||||
// WorldGenProgressScreen without coupling to the step tree.
|
||||
if (instance is Steps.StepReview review)
|
||||
review.CharacterConfirmed += draft =>
|
||||
EmitSignal(SignalName.CharacterConfirmed, draft);
|
||||
}
|
||||
|
||||
UpdateChrome();
|
||||
|
||||
@@ -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/aspect="expand"
|
||||
|
||||
[autoload]
|
||||
|
||||
; M7.1 — cross-scene state (seed, post-worldgen ctx, pending character,
|
||||
; pending save snapshot). See GameSession.cs and the M7 plan §4.3.
|
||||
GameSession="*res://GameSession.cs"
|
||||
|
||||
[dotnet]
|
||||
|
||||
project/assembly_name="Theriapolis.Godot"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user