Files
TheriapolisV3/Theriapolis.Game/Input/PlayerController.cs
T
Christopher Wiebe b451f83174 Initial commit: Theriapolis baseline at port/godot branch point
Captures the pre-Godot-port state of the codebase. This is the rollback
anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md).
All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:40:51 -07:00

174 lines
7.1 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 Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Input;
using Theriapolis.Core;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Time;
using Theriapolis.Core.Util;
using Theriapolis.Core.World;
using Theriapolis.Game.Rendering;
namespace Theriapolis.Game.Input;
/// <summary>
/// Drives the player. World-map mode: click a destination, A* the path,
/// and animate the player along it while the WorldClock advances. Tactical
/// step input is added in M3 once the chunk streamer is in place — until then,
/// tactical mode is a passive observer (zoom and look around).
/// </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 PlayScreen once tactical streaming is up
/// (M3+). 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; // tile waypoints
private int _pathIndex; // index of the next waypoint
// 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; // we're already at the start tile
return true;
}
public void Update(GameTime gt, InputManager input, Camera2D camera, bool isWindowFocused)
{
float dt = (float)gt.ElapsedGameTime.TotalSeconds;
if (camera.Mode == ViewMode.WorldMap)
UpdateWorldMap(dt);
else
UpdateTactical(dt, input, isWindowFocused);
}
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, InputManager input, bool isWindowFocused)
{
// M3 will install TacticalIsWalkable; until then there's nothing to do.
if (!isWindowFocused || TacticalIsWalkable is null) return;
int dx = 0, dy = 0;
if (input.IsDown(Keys.W) || input.IsDown(Keys.Up)) dy = -1;
if (input.IsDown(Keys.S) || input.IsDown(Keys.Down)) dy = +1;
if (input.IsDown(Keys.A) || input.IsDown(Keys.Left)) dx = -1;
if (input.IsDown(Keys.D) || input.IsDown(Keys.Right)) dx = +1;
if (dx == 0 && dy == 0) return;
// Normalize so diagonal isn't √2 faster than cardinal.
float invLen = (dx != 0 && dy != 0) ? 0.70710678f : 1f;
float vx = dx * invLen;
float vy = dy * invLen;
// Phase 5 M3: 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; // tactical tiles
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);
// Clock: 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));
}
}