1095 lines
33 KiB
C#
1095 lines
33 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using Godot;
|
|
|
|
public partial class CampusStudent : CharacterBody2D
|
|
{
|
|
private Sprite2D _accessory;
|
|
private AnimationPlayer _animationPlayer;
|
|
|
|
private Sprite2D _body;
|
|
private Sprite2D _eye;
|
|
private Sprite2D _hairstyle;
|
|
private double _lastDelta;
|
|
private FacingDirection _lastFacing = FacingDirection.Down;
|
|
private Vector2 _lastPosition;
|
|
|
|
private NavigationAgent2D _navigationAgent;
|
|
private Sprite2D _outfit;
|
|
private bool _patrolConfigured;
|
|
private int _patrolIndex;
|
|
private List<Vector2> _patrolPoints = new();
|
|
private Sprite2D _smartphone;
|
|
private float _stuckTimer;
|
|
private Rid _navigationMap;
|
|
private Vector2 _currentTarget = Vector2.Zero;
|
|
private bool _hasTarget;
|
|
private bool _behaviorControlEnabled;
|
|
private Vector2 _behaviorTarget = Vector2.Zero;
|
|
private bool _behaviorHasTarget;
|
|
private bool _phoneIdleActive;
|
|
private bool _phoneExitLocked;
|
|
private bool _usePhysicsMovement = true;
|
|
private readonly List<Vector2> _gridPath = new();
|
|
private int _gridPathIndex;
|
|
private bool _gridPathActive;
|
|
private bool _gridPathPending;
|
|
private AStarGrid2D _astarGrid;
|
|
private Rect2I _astarRegion;
|
|
private int _astarMapIteration;
|
|
private float _gridPathRetryTimer;
|
|
private NavigationRegion2D _navigationRegion;
|
|
[Export] public float MoveSpeed { get; set; } = 60.0f;
|
|
[Export] public float TargetReachDistance { get; set; } = 6.0f;
|
|
[Export] public bool Use16X16Sprites { get; set; } = true;
|
|
[Export] public bool EnableAvoidance { get; set; }
|
|
[Export] public float StuckRepathSeconds { get; set; } = 0.6f;
|
|
[Export] public float StuckDistanceEpsilon { get; set; } = 2.0f;
|
|
[Export] public float NavMeshClampDistance { get; set; } = 6.0f;
|
|
[Export] public bool UseGridPathfinding { get; set; } = true;
|
|
[Export] public float GridCellSize { get; set; } = 8.0f;
|
|
[Export] public float GridWalkableTolerance { get; set; } = 2.0f;
|
|
[Export] public int GridSearchNodeLimit { get; set; } = 8000;
|
|
[Export] public float GridRepathInterval { get; set; } = 0.25f;
|
|
[Export] public bool DebugDrawGrid { get; set; }
|
|
[Export] public bool DebugDrawSolidOnly { get; set; } = true;
|
|
[Export] public bool DebugDrawPath { get; set; }
|
|
[Export] public int DebugDrawRadiusCells { get; set; } = 20;
|
|
[Export] public bool DebugLogGrid { get; set; }
|
|
[Export] public uint EnvironmentCollisionMask { get; set; } = 1u;
|
|
[Export] public uint StudentCollisionLayer { get; set; } = 1u << 1;
|
|
|
|
public override void _Ready()
|
|
{
|
|
_navigationAgent = GetNodeOrNull<NavigationAgent2D>("NavigationAgent2D");
|
|
_animationPlayer = GetNodeOrNull<AnimationPlayer>("AnimationPlayer");
|
|
_navigationRegion = FindNavigationRegion();
|
|
CacheSprites();
|
|
ConfigureCollision();
|
|
|
|
if (_animationPlayer != null)
|
|
{
|
|
_animationPlayer.AnimationFinished += OnAnimationFinished;
|
|
}
|
|
|
|
if (_navigationAgent != null)
|
|
{
|
|
// 强制关闭避让,学生之间直接穿过
|
|
EnableAvoidance = false;
|
|
|
|
// 让寻路点更“贴近目标”,避免走到边缘时抖动
|
|
_navigationAgent.PathDesiredDistance = TargetReachDistance;
|
|
_navigationAgent.TargetDesiredDistance = TargetReachDistance;
|
|
_navigationAgent.AvoidanceEnabled = EnableAvoidance;
|
|
|
|
if (EnableAvoidance)
|
|
// 开启避让时使用安全速度回调进行移动
|
|
_navigationAgent.VelocityComputed += OnVelocityComputed;
|
|
|
|
if (_patrolConfigured) AdvanceTarget();
|
|
}
|
|
|
|
PlayIdleAnimation();
|
|
_lastPosition = GlobalPosition;
|
|
}
|
|
|
|
public override void _PhysicsProcess(double delta)
|
|
{
|
|
_lastDelta = delta;
|
|
if (DebugDrawGrid || DebugDrawPath)
|
|
{
|
|
QueueRedraw();
|
|
}
|
|
if ((DebugDrawGrid || DebugDrawPath) && _astarGrid == null)
|
|
{
|
|
EnsureAStarGrid();
|
|
}
|
|
if (_gridPathRetryTimer > 0.0f)
|
|
{
|
|
_gridPathRetryTimer = Mathf.Max(0.0f, _gridPathRetryTimer - (float)delta);
|
|
}
|
|
if (_phoneExitLocked)
|
|
{
|
|
Velocity = Vector2.Zero;
|
|
if (!EnableAvoidance && _usePhysicsMovement)
|
|
{
|
|
MoveAndSlide();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (_behaviorControlEnabled && _behaviorHasTarget)
|
|
{
|
|
TryBuildPendingGridPath();
|
|
if (_gridPathActive)
|
|
{
|
|
if (ProcessGridPathMovement(delta))
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
else if (UseGridPathfinding && _gridPathPending)
|
|
{
|
|
Velocity = Vector2.Zero;
|
|
if (!EnableAvoidance && _usePhysicsMovement)
|
|
{
|
|
MoveAndSlide();
|
|
}
|
|
|
|
if (!_phoneIdleActive && !_phoneExitLocked)
|
|
{
|
|
PlayIdleAnimation();
|
|
}
|
|
UpdateStuckTimer(delta);
|
|
return;
|
|
}
|
|
}
|
|
if (_navigationAgent == null || (!_behaviorControlEnabled && _patrolPoints.Count == 0) || (_behaviorControlEnabled && !_behaviorHasTarget))
|
|
{
|
|
if (!_phoneIdleActive && !_phoneExitLocked)
|
|
{
|
|
PlayIdleAnimation();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 到达目标点或无路可走时,切换到下一个巡游点
|
|
if (_navigationAgent.IsNavigationFinished())
|
|
{
|
|
if (_behaviorControlEnabled)
|
|
{
|
|
Velocity = Vector2.Zero;
|
|
if (!EnableAvoidance && _usePhysicsMovement)
|
|
{
|
|
MoveAndSlide();
|
|
}
|
|
|
|
if (!_phoneIdleActive && !_phoneExitLocked)
|
|
{
|
|
PlayIdleAnimation();
|
|
}
|
|
UpdateStuckTimer(delta);
|
|
return;
|
|
}
|
|
|
|
AdvanceTarget();
|
|
}
|
|
|
|
var nextPosition = _navigationAgent.GetNextPathPosition();
|
|
var toNext = nextPosition - GlobalPosition;
|
|
if (toNext.LengthSquared() < 0.01f)
|
|
{
|
|
if (!EnableAvoidance && _usePhysicsMovement)
|
|
{
|
|
Velocity = Vector2.Zero;
|
|
MoveAndSlide();
|
|
}
|
|
|
|
if (!_phoneIdleActive && !_phoneExitLocked)
|
|
{
|
|
PlayIdleAnimation();
|
|
}
|
|
UpdateStuckTimer(delta);
|
|
return;
|
|
}
|
|
|
|
var direction = toNext.Normalized();
|
|
var desiredVelocity = direction * MoveSpeed;
|
|
|
|
if (EnableAvoidance)
|
|
{
|
|
// 交给导航系统做群体避让
|
|
_navigationAgent.Velocity = desiredVelocity;
|
|
}
|
|
else
|
|
{
|
|
// 未启用避让时直接移动
|
|
Velocity = ClampVelocityToNavMesh(desiredVelocity);
|
|
ApplyMovement(delta);
|
|
UpdateFacingAnimation(Velocity);
|
|
UpdateStuckTimer(delta);
|
|
}
|
|
}
|
|
|
|
public override void _Draw()
|
|
{
|
|
if (!DebugDrawGrid && !DebugDrawPath) return;
|
|
if (_astarGrid == null || GridCellSize <= 0.0f) return;
|
|
|
|
var cellSize = Mathf.Max(1.0f, GridCellSize);
|
|
var half = new Vector2(cellSize * 0.5f, cellSize * 0.5f);
|
|
|
|
if (DebugDrawGrid)
|
|
{
|
|
var radius = Mathf.Max(1, DebugDrawRadiusCells);
|
|
var originCell = WorldToGrid(GlobalPosition);
|
|
var minX = Math.Max(_astarRegion.Position.X, originCell.X - radius);
|
|
var maxX = Math.Min(_astarRegion.Position.X + _astarRegion.Size.X - 1, originCell.X + radius);
|
|
var minY = Math.Max(_astarRegion.Position.Y, originCell.Y - radius);
|
|
var maxY = Math.Min(_astarRegion.Position.Y + _astarRegion.Size.Y - 1, originCell.Y + radius);
|
|
|
|
var solidFill = new Color(1.0f, 0.3f, 0.3f, 0.25f);
|
|
var solidOutline = new Color(1.0f, 0.3f, 0.3f, 0.6f);
|
|
var walkFill = new Color(0.2f, 0.8f, 0.3f, 0.08f);
|
|
var walkOutline = new Color(0.2f, 0.8f, 0.3f, 0.2f);
|
|
|
|
for (var x = minX; x <= maxX; x++)
|
|
{
|
|
for (var y = minY; y <= maxY; y++)
|
|
{
|
|
var cell = new Vector2I(x, y);
|
|
var isSolid = _astarGrid.IsPointSolid(cell);
|
|
if (DebugDrawSolidOnly && !isSolid)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var worldCenter = GridToWorld(cell);
|
|
// Draw around the cell center to verify path alignment.
|
|
var localTopLeft = ToLocal(worldCenter) - half;
|
|
var rect = new Rect2(localTopLeft, new Vector2(cellSize, cellSize));
|
|
DrawRect(rect, isSolid ? solidFill : walkFill);
|
|
DrawRect(rect, isSolid ? solidOutline : walkOutline, false, 1.0f);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (DebugDrawPath && _gridPath.Count > 0 && _gridPathIndex < _gridPath.Count)
|
|
{
|
|
var pathColor = new Color(0.2f, 0.7f, 1.0f, 0.9f);
|
|
var current = ToLocal(GlobalPosition);
|
|
for (var i = _gridPathIndex; i < _gridPath.Count; i++)
|
|
{
|
|
var next = ToLocal(_gridPath[i]);
|
|
DrawLine(current, next, pathColor, 2.0f);
|
|
DrawCircle(next, 2.5f, pathColor);
|
|
current = next;
|
|
}
|
|
}
|
|
}
|
|
|
|
public void ConfigurePatrol(List<Vector2> points, int startIndex)
|
|
{
|
|
_patrolPoints = points ?? new List<Vector2>();
|
|
if (_patrolPoints.Count == 0) return;
|
|
|
|
_patrolIndex = (startIndex % _patrolPoints.Count + _patrolPoints.Count) % _patrolPoints.Count;
|
|
_patrolConfigured = true;
|
|
ApplyRandomTheme();
|
|
|
|
if (_navigationAgent != null) AdvanceTarget();
|
|
}
|
|
|
|
public void EnableBehaviorControl()
|
|
{
|
|
_behaviorControlEnabled = true;
|
|
_behaviorHasTarget = false;
|
|
_patrolConfigured = false;
|
|
_patrolPoints.Clear();
|
|
}
|
|
|
|
public void SetBehaviorTarget(Vector2 target)
|
|
{
|
|
_behaviorControlEnabled = true;
|
|
target = ClampTargetToNavMesh(target);
|
|
_behaviorTarget = target;
|
|
_behaviorHasTarget = true;
|
|
_currentTarget = target;
|
|
_hasTarget = true;
|
|
_stuckTimer = 0.0f;
|
|
_gridPathActive = false;
|
|
_gridPathPending = false;
|
|
_gridPathIndex = 0;
|
|
_gridPathRetryTimer = 0.0f;
|
|
_gridPath.Clear();
|
|
|
|
if (UseGridPathfinding)
|
|
{
|
|
_gridPathPending = true;
|
|
TryBuildPendingGridPath();
|
|
}
|
|
|
|
if (!UseGridPathfinding && _navigationAgent != null)
|
|
{
|
|
_navigationAgent.TargetPosition = target;
|
|
}
|
|
}
|
|
|
|
public void ClearBehaviorTarget()
|
|
{
|
|
_behaviorHasTarget = false;
|
|
_hasTarget = false;
|
|
_currentTarget = Vector2.Zero;
|
|
_stuckTimer = 0.0f;
|
|
_gridPathActive = false;
|
|
_gridPathPending = false;
|
|
_gridPathIndex = 0;
|
|
_gridPathRetryTimer = 0.0f;
|
|
_gridPath.Clear();
|
|
}
|
|
|
|
public bool HasReachedBehaviorTarget()
|
|
{
|
|
if (!_behaviorHasTarget) return true;
|
|
if (_gridPathActive)
|
|
{
|
|
return _gridPathIndex >= _gridPath.Count;
|
|
}
|
|
if (_gridPathPending)
|
|
{
|
|
return false;
|
|
}
|
|
if (_navigationAgent == null) return true;
|
|
return _navigationAgent.IsNavigationFinished();
|
|
}
|
|
|
|
public void StartPhoneIdle()
|
|
{
|
|
if (_animationPlayer == null || !_animationPlayer.HasAnimation("phone_up")) return;
|
|
if (_phoneIdleActive) return;
|
|
|
|
_phoneExitLocked = false;
|
|
_phoneIdleActive = true;
|
|
_animationPlayer.Play("phone_up");
|
|
}
|
|
|
|
public void StopPhoneIdle(bool lockMovement = false)
|
|
{
|
|
if (_animationPlayer == null)
|
|
{
|
|
_phoneIdleActive = false;
|
|
_phoneExitLocked = false;
|
|
return;
|
|
}
|
|
|
|
if (_phoneExitLocked && !lockMovement)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!_phoneIdleActive && !_phoneExitLocked)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_phoneIdleActive = false;
|
|
if (_animationPlayer.HasAnimation("phone_down"))
|
|
{
|
|
_phoneExitLocked = lockMovement;
|
|
_animationPlayer.Play("phone_down");
|
|
}
|
|
else
|
|
{
|
|
_phoneExitLocked = false;
|
|
PlayIdleAnimation();
|
|
}
|
|
}
|
|
|
|
public void SetNavigationMap(Rid map)
|
|
{
|
|
// 由校园控制器传入导航地图,供本地边界夹紧使用
|
|
_navigationMap = map;
|
|
_astarGrid = null;
|
|
_astarRegion = new Rect2I();
|
|
_astarMapIteration = 0;
|
|
_navigationRegion = FindNavigationRegion();
|
|
}
|
|
|
|
public void ApplyRandomTheme()
|
|
{
|
|
// 随机替换身体与配件贴图,形成不同主题外观
|
|
if (_body == null) CacheSprites();
|
|
|
|
Debug.Assert(_body != null, nameof(_body) + " != null");
|
|
_body.Texture = ResourceLoader.Load<Texture2D>(Res.GetRandom(Res.Type.Body, Use16X16Sprites));
|
|
_hairstyle.Texture = ResourceLoader.Load<Texture2D>(Res.GetRandom(Res.Type.Hair, Use16X16Sprites));
|
|
_outfit.Texture = ResourceLoader.Load<Texture2D>(Res.GetRandom(Res.Type.Outfit, Use16X16Sprites));
|
|
_eye.Texture = ResourceLoader.Load<Texture2D>(Res.GetRandom(Res.Type.Eye, Use16X16Sprites));
|
|
_accessory.Texture = ResourceLoader.Load<Texture2D>(Res.GetRandom(Res.Type.Accessory, Use16X16Sprites));
|
|
_smartphone.Texture = ResourceLoader.Load<Texture2D>(Res.GetRandom(Res.Type.Phone, Use16X16Sprites));
|
|
}
|
|
|
|
private void CacheSprites()
|
|
{
|
|
// 缓存子节点引用,避免每帧查找
|
|
_body = GetNode<Sprite2D>("parts/body");
|
|
_hairstyle = GetNode<Sprite2D>("parts/hairstyle");
|
|
_outfit = GetNode<Sprite2D>("parts/outfit");
|
|
_eye = GetNode<Sprite2D>("parts/eye");
|
|
_accessory = GetNode<Sprite2D>("parts/accessory");
|
|
_smartphone = GetNode<Sprite2D>("parts/smartphone");
|
|
}
|
|
|
|
private void ConfigureCollision()
|
|
{
|
|
// 学生只与环境碰撞,不与其它学生碰撞
|
|
CollisionLayer = StudentCollisionLayer;
|
|
CollisionMask = EnvironmentCollisionMask;
|
|
_usePhysicsMovement = true;
|
|
}
|
|
|
|
private void AdvanceTarget()
|
|
{
|
|
if (_patrolPoints.Count == 0 || _navigationAgent == null) return;
|
|
if (_behaviorControlEnabled) return;
|
|
|
|
// 避免当前点过近导致原地抖动,最多尝试一轮
|
|
for (var i = 0; i < _patrolPoints.Count; i++)
|
|
{
|
|
var target = _patrolPoints[_patrolIndex];
|
|
_patrolIndex = (_patrolIndex + 1) % _patrolPoints.Count;
|
|
if (GlobalPosition.DistanceTo(target) > TargetReachDistance * 1.5f)
|
|
{
|
|
_currentTarget = target;
|
|
_hasTarget = true;
|
|
_navigationAgent.TargetPosition = target;
|
|
_stuckTimer = 0.0f;
|
|
return;
|
|
}
|
|
}
|
|
|
|
_currentTarget = _patrolPoints[_patrolIndex];
|
|
_hasTarget = true;
|
|
_navigationAgent.TargetPosition = _currentTarget;
|
|
_stuckTimer = 0.0f;
|
|
}
|
|
|
|
private void OnVelocityComputed(Vector2 safeVelocity)
|
|
{
|
|
if (_phoneExitLocked)
|
|
{
|
|
Velocity = Vector2.Zero;
|
|
if (_usePhysicsMovement)
|
|
{
|
|
MoveAndSlide();
|
|
}
|
|
return;
|
|
}
|
|
// 使用安全速度移动,避免与其它角色硬碰硬卡住
|
|
Velocity = ClampVelocityToNavMesh(safeVelocity);
|
|
ApplyMovement(_lastDelta);
|
|
UpdateFacingAnimation(Velocity);
|
|
UpdateStuckTimer(_lastDelta);
|
|
}
|
|
|
|
private void UpdateStuckTimer(double delta)
|
|
{
|
|
if (StuckRepathSeconds <= 0.0f || !_hasTarget)
|
|
{
|
|
_lastPosition = GlobalPosition;
|
|
return;
|
|
}
|
|
|
|
// 若短时间内几乎没有位移,则重新请求路径来脱困
|
|
if (GlobalPosition.DistanceTo(_lastPosition) <= StuckDistanceEpsilon)
|
|
_stuckTimer += (float)delta;
|
|
else
|
|
_stuckTimer = 0.0f;
|
|
|
|
_lastPosition = GlobalPosition;
|
|
|
|
if (_stuckTimer >= StuckRepathSeconds)
|
|
{
|
|
_stuckTimer = 0.0f;
|
|
RepathToCurrentTarget();
|
|
}
|
|
}
|
|
|
|
private void RepathToCurrentTarget()
|
|
{
|
|
if (!_hasTarget) return;
|
|
|
|
if (UseGridPathfinding)
|
|
{
|
|
_gridPathActive = false;
|
|
_gridPathPending = true;
|
|
_gridPathIndex = 0;
|
|
_gridPath.Clear();
|
|
TryBuildPendingGridPath();
|
|
return;
|
|
}
|
|
|
|
if (_navigationAgent == null) return;
|
|
|
|
// 重新请求到同一目标点的路径,避免“固定时间换目的地”
|
|
_navigationAgent.TargetPosition = _currentTarget;
|
|
}
|
|
|
|
private void TryBuildPendingGridPath()
|
|
{
|
|
if (!UseGridPathfinding || !_gridPathPending) return;
|
|
if (_gridPathRetryTimer > 0.0f) return;
|
|
if (!IsNavigationMapReady()) return;
|
|
|
|
var path = BuildGridPath(GlobalPosition, _currentTarget);
|
|
if (path == null || path.Count == 0)
|
|
{
|
|
_gridPathRetryTimer = Mathf.Max(0.1f, GridRepathInterval);
|
|
return;
|
|
}
|
|
|
|
_gridPath.Clear();
|
|
_gridPath.AddRange(path);
|
|
_gridPathIndex = 0;
|
|
_gridPathActive = true;
|
|
_gridPathPending = false;
|
|
if (DebugDrawGrid || DebugDrawPath)
|
|
{
|
|
QueueRedraw();
|
|
}
|
|
}
|
|
|
|
private bool IsNavigationMapReady()
|
|
{
|
|
return _navigationMap.IsValid && NavigationServer2D.MapGetIterationId(_navigationMap) > 0;
|
|
}
|
|
|
|
private Vector2 ClampVelocityToNavMesh(Vector2 velocity)
|
|
{
|
|
if (!IsNavigationMapReady()) return velocity;
|
|
if (_lastDelta <= 0.0) return velocity;
|
|
|
|
// 预测下一帧位置,若偏离导航网格过远则夹回网格内
|
|
var candidate = GlobalPosition + velocity * (float)_lastDelta;
|
|
var closest = NavigationServer2D.MapGetClosestPoint(_navigationMap, candidate);
|
|
if (closest.DistanceTo(candidate) > NavMeshClampDistance)
|
|
{
|
|
var corrected = closest - GlobalPosition;
|
|
if (corrected.LengthSquared() < 0.01f)
|
|
{
|
|
return Vector2.Zero;
|
|
}
|
|
return corrected / (float)_lastDelta;
|
|
}
|
|
|
|
return velocity;
|
|
}
|
|
|
|
private Vector2 ClampTargetToNavMesh(Vector2 target)
|
|
{
|
|
if (!IsNavigationMapReady()) return target;
|
|
|
|
var closest = NavigationServer2D.MapGetClosestPoint(_navigationMap, target);
|
|
if (closest.DistanceTo(target) <= 0.01f)
|
|
{
|
|
return target;
|
|
}
|
|
|
|
return closest;
|
|
}
|
|
|
|
private void ApplyMovement(double delta)
|
|
{
|
|
if (_usePhysicsMovement)
|
|
{
|
|
MoveAndSlide();
|
|
return;
|
|
}
|
|
|
|
// 关闭碰撞时直接平移,避免 CharacterBody2D 在无碰撞层时不移动
|
|
GlobalPosition += Velocity * (float)delta;
|
|
}
|
|
|
|
private void UpdateFacingAnimation(Vector2 velocity)
|
|
{
|
|
if (_phoneIdleActive || _phoneExitLocked)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (velocity.LengthSquared() < 0.01f)
|
|
{
|
|
PlayIdleAnimation();
|
|
return;
|
|
}
|
|
|
|
if (Mathf.Abs(velocity.X) >= Mathf.Abs(velocity.Y))
|
|
_lastFacing = velocity.X >= 0 ? FacingDirection.Right : FacingDirection.Left;
|
|
else
|
|
_lastFacing = velocity.Y >= 0 ? FacingDirection.Down : FacingDirection.Up;
|
|
|
|
switch (_lastFacing)
|
|
{
|
|
case FacingDirection.Left:
|
|
PlayAnimation("walk_left");
|
|
break;
|
|
case FacingDirection.Right:
|
|
PlayAnimation("walk_right");
|
|
break;
|
|
case FacingDirection.Up:
|
|
PlayAnimation("walk_up");
|
|
break;
|
|
case FacingDirection.Down:
|
|
PlayAnimation("walk_down");
|
|
break;
|
|
}
|
|
}
|
|
|
|
private void PlayIdleAnimation()
|
|
{
|
|
if (_phoneIdleActive || _phoneExitLocked)
|
|
{
|
|
return;
|
|
}
|
|
|
|
switch (_lastFacing)
|
|
{
|
|
case FacingDirection.Left:
|
|
PlayAnimation("idle_left");
|
|
break;
|
|
case FacingDirection.Right:
|
|
PlayAnimation("idle_right");
|
|
break;
|
|
case FacingDirection.Up:
|
|
PlayAnimation("idle_back");
|
|
break;
|
|
case FacingDirection.Down:
|
|
PlayAnimation("idle_front");
|
|
break;
|
|
}
|
|
}
|
|
|
|
private void PlayAnimation(string animationName)
|
|
{
|
|
if (_animationPlayer == null) return;
|
|
|
|
if (_animationPlayer.CurrentAnimation != animationName) _animationPlayer.Play(animationName);
|
|
}
|
|
|
|
private void OnAnimationFinished(StringName animationName)
|
|
{
|
|
if (_animationPlayer == null) return;
|
|
|
|
if (animationName == "phone_up" && _phoneIdleActive)
|
|
{
|
|
if (_animationPlayer.HasAnimation("phone_loop"))
|
|
{
|
|
_animationPlayer.Play("phone_loop");
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (animationName == "phone_down" && !_phoneIdleActive)
|
|
{
|
|
_phoneExitLocked = false;
|
|
PlayIdleAnimation();
|
|
}
|
|
}
|
|
|
|
private bool ProcessGridPathMovement(double delta)
|
|
{
|
|
if (_gridPathIndex >= _gridPath.Count)
|
|
{
|
|
Velocity = Vector2.Zero;
|
|
if (!EnableAvoidance && _usePhysicsMovement)
|
|
{
|
|
MoveAndSlide();
|
|
}
|
|
|
|
if (!_phoneIdleActive && !_phoneExitLocked)
|
|
{
|
|
PlayIdleAnimation();
|
|
}
|
|
UpdateStuckTimer(delta);
|
|
return true;
|
|
}
|
|
|
|
var target = _gridPath[_gridPathIndex];
|
|
var toNext = target - GlobalPosition;
|
|
if (toNext.LengthSquared() <= TargetReachDistance * TargetReachDistance)
|
|
{
|
|
_gridPathIndex += 1;
|
|
return true;
|
|
}
|
|
|
|
if (delta <= 0.0)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
var axisVelocity = ToAxisVelocity(toNext);
|
|
var step = axisVelocity * MoveSpeed * (float)delta;
|
|
var candidate = GlobalPosition + step;
|
|
if (!IsWorldWalkable(candidate))
|
|
{
|
|
Velocity = Vector2.Zero;
|
|
if (!EnableAvoidance && _usePhysicsMovement)
|
|
{
|
|
MoveAndSlide();
|
|
}
|
|
RepathToCurrentTarget();
|
|
UpdateStuckTimer(delta);
|
|
return true;
|
|
}
|
|
|
|
Velocity = step / (float)delta;
|
|
ApplyMovement(delta);
|
|
UpdateFacingAnimation(Velocity);
|
|
UpdateStuckTimer(delta);
|
|
return true;
|
|
}
|
|
|
|
private Vector2 ToAxisVelocity(Vector2 delta)
|
|
{
|
|
if (Mathf.Abs(delta.X) >= Mathf.Abs(delta.Y))
|
|
{
|
|
return new Vector2(Mathf.Sign(delta.X), 0);
|
|
}
|
|
|
|
return new Vector2(0, Mathf.Sign(delta.Y));
|
|
}
|
|
|
|
private List<Vector2> BuildGridPath(Vector2 start, Vector2 target)
|
|
{
|
|
if (!IsNavigationMapReady() || GridCellSize <= 0.0f)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
EnsureAStarGrid();
|
|
if (_astarGrid == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var startCell = WorldToGrid(start);
|
|
var targetCell = WorldToGrid(target);
|
|
if (!IsCellInBounds(startCell) || !IsCellInBounds(targetCell))
|
|
{
|
|
if (DebugLogGrid)
|
|
{
|
|
GD.Print($"[AStarGrid] out_of_bounds start={startCell} target={targetCell} region={_astarRegion}");
|
|
}
|
|
return null;
|
|
}
|
|
|
|
startCell = FindNearestOpenCell(startCell, 6);
|
|
targetCell = FindNearestOpenCell(targetCell, 6);
|
|
if (!IsCellInBounds(startCell) || !IsCellInBounds(targetCell))
|
|
{
|
|
if (DebugLogGrid)
|
|
{
|
|
GD.Print($"[AStarGrid] no_open_cell start={startCell} target={targetCell}");
|
|
}
|
|
return null;
|
|
}
|
|
|
|
if (_astarGrid.IsPointSolid(startCell) || _astarGrid.IsPointSolid(targetCell))
|
|
{
|
|
if (DebugLogGrid)
|
|
{
|
|
GD.Print($"[AStarGrid] solid start={startCell} target={targetCell}");
|
|
}
|
|
return null;
|
|
}
|
|
|
|
var path = _astarGrid.GetIdPath(startCell, targetCell);
|
|
if (path.Count == 0)
|
|
{
|
|
if (DebugLogGrid)
|
|
{
|
|
GD.Print($"[AStarGrid] empty_path start={startCell} target={targetCell}");
|
|
}
|
|
return null;
|
|
}
|
|
|
|
var result = new List<Vector2>(path.Count);
|
|
foreach (Vector2I cell in path)
|
|
{
|
|
result.Add(GridToWorld(cell));
|
|
}
|
|
|
|
if (result.Count > 0)
|
|
{
|
|
result.RemoveAt(0);
|
|
}
|
|
|
|
RemoveImmediateBacktracks(result);
|
|
return SimplifyGridPath(result);
|
|
}
|
|
|
|
private void RemoveImmediateBacktracks(List<Vector2> path)
|
|
{
|
|
if (path == null || path.Count < 3) return;
|
|
|
|
var index = 2;
|
|
while (index < path.Count)
|
|
{
|
|
if (path[index].DistanceTo(path[index - 2]) <= 0.01f)
|
|
{
|
|
path.RemoveAt(index - 1);
|
|
index = Math.Max(2, index - 1);
|
|
continue;
|
|
}
|
|
index += 1;
|
|
}
|
|
}
|
|
|
|
private List<Vector2> SimplifyGridPath(List<Vector2> path)
|
|
{
|
|
if (path == null || path.Count < 3) return path;
|
|
|
|
var simplified = new List<Vector2> { path[0] };
|
|
var lastDir = Vector2.Zero;
|
|
|
|
for (var i = 1; i < path.Count; i++)
|
|
{
|
|
var delta = path[i] - path[i - 1];
|
|
if (delta.LengthSquared() < 0.01f)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var axisDir = ToAxisVelocity(delta);
|
|
if (simplified.Count == 1)
|
|
{
|
|
simplified.Add(path[i]);
|
|
lastDir = axisDir;
|
|
continue;
|
|
}
|
|
|
|
if (axisDir == lastDir)
|
|
{
|
|
simplified[simplified.Count - 1] = path[i];
|
|
}
|
|
else
|
|
{
|
|
simplified.Add(path[i]);
|
|
lastDir = axisDir;
|
|
}
|
|
}
|
|
|
|
return simplified;
|
|
}
|
|
|
|
private void EnsureAStarGrid()
|
|
{
|
|
if (!IsNavigationMapReady()) return;
|
|
|
|
var region = BuildGridRegion();
|
|
if (region.Size.X <= 0 || region.Size.Y <= 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var cellSize = new Vector2(Mathf.Max(1.0f, GridCellSize), Mathf.Max(1.0f, GridCellSize));
|
|
var mapIteration = (int)NavigationServer2D.MapGetIterationId(_navigationMap);
|
|
var needsRebuild = _astarGrid == null
|
|
|| _astarMapIteration != mapIteration
|
|
|| _astarRegion.Position != region.Position
|
|
|| _astarRegion.Size != region.Size
|
|
|| _astarGrid.CellSize != cellSize;
|
|
|
|
if (!needsRebuild)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_astarGrid ??= new AStarGrid2D();
|
|
_astarGrid.Region = region;
|
|
_astarGrid.CellSize = cellSize;
|
|
_astarGrid.Offset = Vector2.Zero;
|
|
_astarGrid.DiagonalMode = AStarGrid2D.DiagonalModeEnum.Never;
|
|
_astarGrid.Update();
|
|
|
|
for (var x = region.Position.X; x < region.Position.X + region.Size.X; x++)
|
|
{
|
|
for (var y = region.Position.Y; y < region.Position.Y + region.Size.Y; y++)
|
|
{
|
|
var cell = new Vector2I(x, y);
|
|
var center = GridToWorld(cell);
|
|
var walkable = IsCellWalkable(center);
|
|
_astarGrid.SetPointSolid(cell, !walkable);
|
|
}
|
|
}
|
|
|
|
_astarRegion = region;
|
|
_astarMapIteration = mapIteration;
|
|
if (DebugLogGrid)
|
|
{
|
|
GD.Print($"[AStarGrid] region={region} cell={cellSize}");
|
|
}
|
|
if (DebugDrawGrid || DebugDrawPath)
|
|
{
|
|
QueueRedraw();
|
|
}
|
|
}
|
|
|
|
private Rect2I BuildGridRegion()
|
|
{
|
|
var bounds = BuildWorldBounds();
|
|
var cell = Mathf.Max(1.0f, GridCellSize);
|
|
var minX = Mathf.FloorToInt(bounds.Position.X / cell);
|
|
var minY = Mathf.FloorToInt(bounds.Position.Y / cell);
|
|
var maxX = Mathf.CeilToInt((bounds.Position.X + bounds.Size.X) / cell);
|
|
var maxY = Mathf.CeilToInt((bounds.Position.Y + bounds.Size.Y) / cell);
|
|
var sizeX = Math.Max(1, maxX - minX);
|
|
var sizeY = Math.Max(1, maxY - minY);
|
|
return new Rect2I(new Vector2I(minX, minY), new Vector2I(sizeX, sizeY));
|
|
}
|
|
|
|
private Rect2 BuildWorldBounds()
|
|
{
|
|
var viewport = GetViewportRect();
|
|
var bounds = new Rect2(viewport.Position, viewport.Size);
|
|
|
|
var navRegion = _navigationRegion ?? FindNavigationRegion();
|
|
if (navRegion == null || navRegion.NavigationPolygon == null)
|
|
{
|
|
return bounds;
|
|
}
|
|
|
|
var polygon = navRegion.NavigationPolygon;
|
|
var outlineCount = polygon.GetOutlineCount();
|
|
if (outlineCount == 0)
|
|
{
|
|
return bounds;
|
|
}
|
|
|
|
var min = Vector2.Zero;
|
|
var max = Vector2.Zero;
|
|
var hasPoint = false;
|
|
|
|
for (var i = 0; i < outlineCount; i++)
|
|
{
|
|
var outline = polygon.GetOutline(i);
|
|
for (var j = 0; j < outline.Length; j++)
|
|
{
|
|
var world = navRegion.ToGlobal(outline[j]);
|
|
if (!hasPoint)
|
|
{
|
|
min = world;
|
|
max = world;
|
|
hasPoint = true;
|
|
continue;
|
|
}
|
|
|
|
min = new Vector2(Mathf.Min(min.X, world.X), Mathf.Min(min.Y, world.Y));
|
|
max = new Vector2(Mathf.Max(max.X, world.X), Mathf.Max(max.Y, world.Y));
|
|
}
|
|
}
|
|
|
|
if (!hasPoint)
|
|
{
|
|
return bounds;
|
|
}
|
|
|
|
var navBounds = new Rect2(min, max - min);
|
|
return bounds.Merge(navBounds);
|
|
}
|
|
|
|
private bool IsCellInRegion(Vector2I cell)
|
|
{
|
|
return cell.X >= _astarRegion.Position.X
|
|
&& cell.X < _astarRegion.Position.X + _astarRegion.Size.X
|
|
&& cell.Y >= _astarRegion.Position.Y
|
|
&& cell.Y < _astarRegion.Position.Y + _astarRegion.Size.Y;
|
|
}
|
|
|
|
private bool IsCellInBounds(Vector2I cell)
|
|
{
|
|
if (_astarGrid != null)
|
|
{
|
|
return _astarGrid.IsInBounds(cell.X, cell.Y);
|
|
}
|
|
|
|
return IsCellInRegion(cell);
|
|
}
|
|
|
|
private Vector2I WorldToGrid(Vector2 world)
|
|
{
|
|
var size = Mathf.Max(1.0f, GridCellSize);
|
|
return new Vector2I(
|
|
Mathf.FloorToInt(world.X / size),
|
|
Mathf.FloorToInt(world.Y / size));
|
|
}
|
|
|
|
private Vector2 GridToWorld(Vector2I grid)
|
|
{
|
|
var size = Mathf.Max(1.0f, GridCellSize);
|
|
return new Vector2((grid.X + 0.5f) * size, (grid.Y + 0.5f) * size);
|
|
}
|
|
|
|
private Vector2I FindNearestOpenCell(Vector2I origin, int radius)
|
|
{
|
|
if (_astarGrid == null) return origin;
|
|
if (IsCellInBounds(origin) && !_astarGrid.IsPointSolid(origin)) return origin;
|
|
|
|
for (var r = 1; r <= radius; r++)
|
|
{
|
|
for (var dx = -r; dx <= r; dx++)
|
|
{
|
|
var dy = r - Math.Abs(dx);
|
|
var candidateA = new Vector2I(origin.X + dx, origin.Y + dy);
|
|
if (IsCellInBounds(candidateA) && !_astarGrid.IsPointSolid(candidateA))
|
|
{
|
|
return candidateA;
|
|
}
|
|
|
|
if (dy == 0) continue;
|
|
var candidateB = new Vector2I(origin.X + dx, origin.Y - dy);
|
|
if (IsCellInBounds(candidateB) && !_astarGrid.IsPointSolid(candidateB))
|
|
{
|
|
return candidateB;
|
|
}
|
|
}
|
|
}
|
|
|
|
return origin;
|
|
}
|
|
|
|
private bool IsWorldWalkable(Vector2 world)
|
|
{
|
|
if (!IsNavigationMapReady()) return false;
|
|
var closest = NavigationServer2D.MapGetClosestPoint(_navigationMap, world);
|
|
return closest.DistanceTo(world) <= GetGridTolerance();
|
|
}
|
|
|
|
private NavigationRegion2D FindNavigationRegion()
|
|
{
|
|
return GetTree()?.CurrentScene?.FindChild("NavigationRegion2D", true, false) as NavigationRegion2D;
|
|
}
|
|
|
|
private bool IsCellWalkable(Vector2 center)
|
|
{
|
|
if (!IsWorldWalkable(center)) return false;
|
|
|
|
var half = Mathf.Max(1.0f, GridCellSize * 0.45f);
|
|
var offsets = new[]
|
|
{
|
|
new Vector2(half, 0),
|
|
new Vector2(-half, 0),
|
|
new Vector2(0, half),
|
|
new Vector2(0, -half),
|
|
new Vector2(half, half),
|
|
new Vector2(half, -half),
|
|
new Vector2(-half, half),
|
|
new Vector2(-half, -half)
|
|
};
|
|
|
|
for (var i = 0; i < offsets.Length; i++)
|
|
{
|
|
if (!IsWorldWalkable(center + offsets[i]))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private float GetGridTolerance()
|
|
{
|
|
return Mathf.Max(1.0f, GridWalkableTolerance);
|
|
}
|
|
|
|
private enum FacingDirection
|
|
{
|
|
Up,
|
|
Down,
|
|
Left,
|
|
Right
|
|
}
|
|
}
|