bf0041605f
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>
132 lines
5.1 KiB
C#
132 lines
5.1 KiB
C#
using System.IO;
|
|
using Godot;
|
|
using Theriapolis.Core;
|
|
using Theriapolis.Core.Tactical;
|
|
using Theriapolis.Core.Util;
|
|
using Theriapolis.Core.World.Generation;
|
|
using Theriapolis.GodotHost.Platform;
|
|
|
|
namespace Theriapolis.GodotHost.Rendering;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
///
|
|
/// 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
|
|
{
|
|
private readonly ulong _seed;
|
|
private readonly int _startWorldTileX;
|
|
private readonly int _startWorldTileY;
|
|
private readonly float _initialZoom;
|
|
|
|
// World-pixel movement speed. 32 wp = 1 world tile, so 96 = ~3 tiles/sec.
|
|
private const float MoveSpeedWorldPx = 96f;
|
|
private const int StreamingBufferWorldTiles = 2;
|
|
private const float StreamRadiusZoomMin = WorldRenderNode.TacticalRenderZoomMin;
|
|
|
|
private ChunkStreamer? _streamer;
|
|
private WorldRenderNode? _render;
|
|
private Vec2 _playerPos;
|
|
private PlayerMarker? _playerMarker;
|
|
|
|
public WorldView(ulong seed, int startWorldTileX = 128, int startWorldTileY = 128, float initialZoom = 0f)
|
|
{
|
|
_seed = seed;
|
|
_startWorldTileX = startWorldTileX;
|
|
_startWorldTileY = startWorldTileY;
|
|
_initialZoom = initialZoom;
|
|
}
|
|
|
|
public override void _Ready()
|
|
{
|
|
string dataDir = ContentPaths.DataDir;
|
|
if (!Directory.Exists(dataDir))
|
|
{
|
|
GD.PrintErr($"[world] Data directory not found: {dataDir}");
|
|
return;
|
|
}
|
|
|
|
GD.Print($"[world] seed=0x{_seed:X} start-tile=({_startWorldTileX},{_startWorldTileY})");
|
|
var ctx = new WorldGenContext(_seed, dataDir);
|
|
WorldGenerator.RunAll(ctx);
|
|
var world = ctx.World;
|
|
GD.Print($"[world] worldgen done — rivers={world.Rivers.Count} " +
|
|
$"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 += _render.AddChunkNode;
|
|
_streamer.OnChunkEvicting += _render.RemoveChunkNode;
|
|
|
|
_playerPos = new Vec2(
|
|
_startWorldTileX * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f,
|
|
_startWorldTileY * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f);
|
|
|
|
_playerMarker = new PlayerMarker { Position = new Vector2(_playerPos.X, _playerPos.Y) };
|
|
AddChild(_playerMarker);
|
|
|
|
_render.Camera.Position = new Vector2(_playerPos.X, _playerPos.Y);
|
|
StreamIfTactical();
|
|
}
|
|
|
|
public override void _Process(double delta)
|
|
{
|
|
if (_render is null || _playerMarker is null) return;
|
|
|
|
Vector2 dir = Vector2.Zero;
|
|
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)
|
|
{
|
|
dir = dir.Normalized();
|
|
float step = MoveSpeedWorldPx * (float)delta;
|
|
_playerPos = new Vec2(_playerPos.X + dir.X * step, _playerPos.Y + dir.Y * step);
|
|
|
|
float maxX = C.WORLD_WIDTH_TILES * C.WORLD_TILE_PIXELS - 1f;
|
|
float maxY = C.WORLD_HEIGHT_TILES * C.WORLD_TILE_PIXELS - 1f;
|
|
_playerPos = new Vec2(
|
|
Mathf.Clamp(_playerPos.X, 0f, maxX),
|
|
Mathf.Clamp(_playerPos.Y, 0f, maxY));
|
|
|
|
StreamIfTactical();
|
|
}
|
|
|
|
var pos = new Vector2(_playerPos.X, _playerPos.Y);
|
|
_playerMarker.Position = pos;
|
|
_render.Camera.Position = pos;
|
|
|
|
// 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 || _render is null) return;
|
|
if (_render.Camera.Zoom.X < StreamRadiusZoomMin) return;
|
|
|
|
Vector2 viewport = GetViewport().GetVisibleRect().Size;
|
|
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);
|
|
}
|
|
}
|