b1fc3f244b
Mirror of the player-status panel, anchored top-right. Per-frame
sample of GetViewport().GetMousePosition() converted to world coords
via the camera transform; surfaces:
- World-pixel and world-tile coords under the cursor
- Biome at that tile
- Feature flag bitmask (HasRoad / HasRiver / HasRail / IsSettlement
/ IsPoi / IsCoast / RiverAdjacent / RailroadAdjacent)
- Settlement on the tile via WorldTile.SettlementId lookup —
name, tier, and economy/governance/pop (or PoI type)
- Bridge under cursor via point-on-segment hit test against
world.Bridges (6 px hit radius, ≤ a few dozen bridges so cheap)
- Tactical block when zoomed in: tactical-tile coords, surface +
variant, deco, walkability tag (walkable / slow / blocked)
- NPC under cursor within 12 px hit radius — display name, role
tag or template id, allegiance, HP
Cached StringBuilder field on PlayScreen (Clear() each frame instead
of new'ing) to keep per-frame GC pressure low. Held keys produce
auto-repeat InputEventKey instances that Godot 4 mono's GC must
collect before engine shutdown; reducing per-frame garbage buys
that collection more headroom and avoided a shutdown-assertion
race observed on the first launch with the panel mounted.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
796 lines
32 KiB
C#
796 lines
32 KiB
C#
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.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;
|
|
|
|
// Player marker.
|
|
_playerMarker = new PlayerMarker
|
|
{
|
|
Position = new Vector2(_actors.Player!.Position.X, _actors.Player.Position.Y),
|
|
Rotation = _actors.Player.FacingAngleRad,
|
|
};
|
|
AddChild(_playerMarker);
|
|
|
|
_render.Camera.Position = _playerMarker.Position;
|
|
SetInitialZoom();
|
|
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);
|
|
|
|
// 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;
|
|
|
|
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)
|
|
{
|
|
var marker = new NpcMarker
|
|
{
|
|
Position = new Vector2(npc.Position.X, npc.Position.Y),
|
|
Allegiance = npc.Allegiance,
|
|
};
|
|
AddChild(marker);
|
|
_npcMarkers[npc.Id] = marker;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// 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.";
|
|
|
|
_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";
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// 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();
|
|
}
|
|
}
|