178 lines
7.3 KiB
C#
178 lines
7.3 KiB
C#
|
|
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));
|
|||
|
|
}
|
|||
|
|
}
|