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>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
|
||||
namespace Theriapolis.Game.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Single-frame input snapshot with helper methods.
|
||||
/// Call Update() once per frame before any input queries.
|
||||
/// </summary>
|
||||
public sealed class InputManager
|
||||
{
|
||||
private KeyboardState _prevKeys;
|
||||
private KeyboardState _currKeys;
|
||||
private MouseState _prevMouse;
|
||||
private MouseState _currMouse;
|
||||
|
||||
public void Update()
|
||||
{
|
||||
_prevKeys = _currKeys;
|
||||
_currKeys = Keyboard.GetState();
|
||||
_prevMouse = _currMouse;
|
||||
_currMouse = Mouse.GetState();
|
||||
}
|
||||
|
||||
// ── Keyboard ──────────────────────────────────────────────────────────────
|
||||
public bool IsDown(Keys key) => _currKeys.IsKeyDown(key);
|
||||
public bool JustPressed(Keys key) => _currKeys.IsKeyDown(key) && _prevKeys.IsKeyUp(key);
|
||||
public bool JustReleased(Keys key) => _currKeys.IsKeyUp(key) && _prevKeys.IsKeyDown(key);
|
||||
|
||||
// ── Mouse ─────────────────────────────────────────────────────────────────
|
||||
public Vector2 MousePosition => new(_currMouse.X, _currMouse.Y);
|
||||
public bool LeftDown => _currMouse.LeftButton == ButtonState.Pressed;
|
||||
public bool LeftJustDown => _currMouse.LeftButton == ButtonState.Pressed && _prevMouse.LeftButton == ButtonState.Released;
|
||||
public bool LeftJustUp => _currMouse.LeftButton == ButtonState.Released && _prevMouse.LeftButton == ButtonState.Pressed;
|
||||
public bool RightDown => _currMouse.RightButton == ButtonState.Pressed;
|
||||
public bool RightJustDown => _currMouse.RightButton == ButtonState.Pressed && _prevMouse.RightButton == ButtonState.Released;
|
||||
|
||||
/// <summary>Mouse wheel delta in scroll "ticks" (positive = forward/up).</summary>
|
||||
public int ScrollDelta => _currMouse.ScrollWheelValue - _prevMouse.ScrollWheelValue;
|
||||
|
||||
private Vector2 _dragStart;
|
||||
private bool _dragging;
|
||||
private bool _dragActivated;
|
||||
private const float DragActivationPixels = 4f;
|
||||
public bool IsDragging => _dragging;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the world-space pan delta from mouse dragging. Panning is
|
||||
/// suppressed until the mouse moves more than <see cref="DragActivationPixels"/>
|
||||
/// from the press position, so hand-jitter during a click doesn't pan the
|
||||
/// camera (at low zoom, one screen pixel can be many world pixels).
|
||||
/// </summary>
|
||||
public Vector2 ConsumeDragDelta(Rendering.Camera2D camera)
|
||||
{
|
||||
if (LeftJustDown)
|
||||
{
|
||||
_dragStart = MousePosition;
|
||||
_dragging = true;
|
||||
_dragActivated = false;
|
||||
}
|
||||
if (LeftJustUp)
|
||||
{
|
||||
_dragging = false;
|
||||
_dragActivated = false;
|
||||
}
|
||||
|
||||
if (!_dragging || !LeftDown) return Vector2.Zero;
|
||||
|
||||
if (!_dragActivated)
|
||||
{
|
||||
if (Vector2.Distance(MousePosition, _dragStart) < DragActivationPixels)
|
||||
return Vector2.Zero;
|
||||
_dragActivated = true;
|
||||
_dragStart = MousePosition; // start panning from here, not from press
|
||||
}
|
||||
|
||||
Vector2 delta = MousePosition - _dragStart;
|
||||
_dragStart = MousePosition;
|
||||
return -delta / camera.Zoom;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user