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; /// /// 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). /// public sealed class PlayerController { private readonly PlayerActor _player; private readonly WorldState _world; private readonly WorldClock _clock; private readonly WorldTravelPlanner _planner; /// /// Optional callback installed by PlayScreen once tactical streaming is up /// (M3+). Returns whether the given tactical-tile coord is walkable. /// public Func? 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; } /// Queue a click destination as a new travel plan. Returns true if a path was found. 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)); } }