Files
TheriapolisV3/Theriapolis.Godot/Input/PlayerController.cs
T
Christopher Wiebe bf0041605f M7.1-7.2: Play-loop hand-off — Wizard → WorldGen → PlayScreen
Lands the M7 plan's first two sub-milestones on port/godot.
theriapolis-rpg-implementation-plan-godot-port-m7.md is the design
doc (six screens collapse to four scenes + a camera mode, with
per-screen behavioural contracts and a six-step sub-milestone
breakdown).

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 18:07:28 -07:00

178 lines
7.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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));
}
}