diff --git a/Assets/_Project/Scripts/Modules/Pet/PetAnimatorDirectionDebouncer.cs b/Assets/_Project/Scripts/Modules/Pet/PetAnimatorDirectionDebouncer.cs
new file mode 100644
index 00000000..06cfe8a6
--- /dev/null
+++ b/Assets/_Project/Scripts/Modules/Pet/PetAnimatorDirectionDebouncer.cs
@@ -0,0 +1,66 @@
+#nullable enable
+using UnityEngine;
+
+namespace GeminiLab.Modules.Pet
+{
+ ///
+ /// 动画朝向去抖器。
+ /// 宠物漫游时用 Rigidbody2D 的 velocity 驱动,撞上家具(非 trigger 碰撞体)后会被
+ /// 物理引擎沿表面滑动/挤动,逐帧实际位移方向会抖动。若直接用该方向驱动 MoveDir,
+ /// 动画会在 Move_Front / Move_Back / Move_Side 之间乱切换。
+ /// 该去抖器要求新方向与当前朝向差异足够大(点积低于 ),
+ /// 且连续稳定 帧后才切换朝向,从而过滤碰撞产生的抖动。
+ ///
+ public struct PetAnimatorDirectionDebouncer
+ {
+ ///
+ /// 候选方向与当前朝向点积的最小阈值。低于此值视为“方向差异足够大”,约等于 45° 夹角。
+ ///
+ public const float KeepDotThreshold = 0.70710678f;
+
+ /// 新方向需连续保持的帧数,达到后才被采纳。
+ public const int PersistFrames = 6;
+
+ private int _consecutiveChangeFrames;
+
+ /// 当前已连续累计的“方向差异足够大”帧数。
+ public int ConsecutiveChangeFrames => _consecutiveChangeFrames;
+
+ /// 清空累计帧数。宠物停止移动/玩家接管时应调用。
+ public void Reset()
+ {
+ _consecutiveChangeFrames = 0;
+ }
+
+ ///
+ /// 每帧调用一次,返回本帧应使用的稳定朝向。
+ ///
+ /// 本帧的候选朝向(应为归一化向量)。
+ /// 当前持有的朝向(应为归一化向量)。
+ public Vector2 Step(Vector2 candidate, Vector2 currentDirection)
+ {
+ if (currentDirection.sqrMagnitude < 0.000001f)
+ {
+ // 尚无有效朝向时立即采纳首个候选,避免起步阶段朝向错误。
+ _consecutiveChangeFrames = 0;
+ return candidate;
+ }
+
+ if (Vector2.Dot(candidate, currentDirection) >= KeepDotThreshold)
+ {
+ // 与当前朝向足够接近,视为同一方向,重置累计帧数。
+ _consecutiveChangeFrames = 0;
+ return currentDirection;
+ }
+
+ _consecutiveChangeFrames++;
+ if (_consecutiveChangeFrames >= PersistFrames)
+ {
+ _consecutiveChangeFrames = 0;
+ return candidate;
+ }
+
+ return currentDirection;
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Modules/Pet/PetAnimatorDirectionDebouncer.cs.meta b/Assets/_Project/Scripts/Modules/Pet/PetAnimatorDirectionDebouncer.cs.meta
new file mode 100644
index 00000000..639f5d3d
--- /dev/null
+++ b/Assets/_Project/Scripts/Modules/Pet/PetAnimatorDirectionDebouncer.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 865cbe098b6da63469efc8bf45128793
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Project/Scripts/Modules/Pet/PetController.cs b/Assets/_Project/Scripts/Modules/Pet/PetController.cs
index d7c2c5c3..69866b32 100644
--- a/Assets/_Project/Scripts/Modules/Pet/PetController.cs
+++ b/Assets/_Project/Scripts/Modules/Pet/PetController.cs
@@ -87,6 +87,7 @@ public sealed class PetController : MonoBehaviour
private Vector2 _lastMoveDirection = Vector2.down;
private Vector2 _playerAnimationDirection = Vector2.down;
private bool _hasPlayerAnimationDirection;
+ private PetAnimatorDirectionDebouncer _animationDirectionDebouncer;
private string _lastForcedAnimatorStateName = string.Empty;
// 方向更新的最小 delta 阈值:过滤物理振荡(~0.002),低于此值使用目标方向
@@ -98,6 +99,26 @@ public sealed class PetController : MonoBehaviour
private float _wanderStuckTimer;
private const float WanderStuckTimeout = 2f;
private const float WanderStuckMoveThreshold = 0.005f;
+
+ // 漫游受阻 → 家具交互:宠物撞上家具后无法接近漫游目标(物理卡住或沿表面滑动),
+ // 短暂等待后转入最近的家具交互动画,而不是一直原地走。
+ private bool _wanderInteractionActive;
+ private float _wanderInteractionRemaining;
+ private string _wanderInteractionAnimatorStateName = string.Empty;
+ private float _wanderLastTargetDistance;
+ private bool _hasWanderLastTargetDistance;
+ private const float WanderInteractionRadius = 2.5f;
+ // 本帧目标距离未减少该值即视为未接近目标(与 WanderStuckMoveThreshold 同量级,
+ // 小于单帧正常位移 ~0.02,避免把正常移动误判为受阻)。
+ private const float WanderProgressThreshold = 0.005f;
+ // 未受阻帧的衰减系数:受阻帧 +deltaTime,未受阻帧 -deltaTime*系数。
+ // 宠物撞上家具后会「受阻/未受阻」交替抖动,若未受阻直接清零则计时器永远到不了 2s 放弃阈值。
+ private const float WanderUnblockedDecayFactor = 0.25f;
+ // 自动家具交互冷却:撞上家具时触发失败后 1s 重试,一次交互结束后 5s 限频,
+ // 期间若再次卡在家具上会走 2s 放弃逻辑换目标,避免宠物被困在角落反复交互。
+ private const float WanderInteractionRetryCooldownSeconds = 1f;
+ private const float WanderInteractionPostSuccessCooldown = 5f;
+ private float _wanderInteractionRetryCooldown;
private PetRuntimeSnapshotChangedEvent? _lastPublishedSnapshot;
private readonly List _hiddenInteractionRenderers = new();
private readonly List _hiddenInteractionRendererStates = new();
@@ -658,6 +679,19 @@ private bool HasPlayerInputController()
private void TickPlayerControlled(PetContext context, float deltaTime)
{
+ // 玩家接管后终止漫游触发中的家具交互,避免取消选中后残留交互动画。
+ // 同时还原自动交互期间应用的覆盖(pose/可视/排序),否则宠物会被钉在交互点。
+ if (_wanderInteractionActive)
+ {
+ _wanderInteractionActive = false;
+ _wanderInteractionAnimatorStateName = string.Empty;
+ _wanderInteractionRemaining = 0f;
+ RestoreHiddenInteractionVisuals();
+ RestoreSleepInteractionVisual();
+ RestoreInteractionSorting();
+ RestoreInteractionPose();
+ }
+
if (TickPlayerInteraction(context, deltaTime))
{
return;
@@ -696,6 +730,14 @@ private void TickInactivePlayerControlled(PetContext context, float deltaTime)
CancelPlayerInteraction(context);
_hasPlayerAnimationDirection = false;
+ if (_wanderInteractionActive)
+ {
+ TickWanderInteraction(context, deltaTime);
+ return;
+ }
+
+ _wanderInteractionRetryCooldown = Mathf.Max(0f, _wanderInteractionRetryCooldown - deltaTime);
+
RandomWander? wander = GetComponent();
bool isWandering = false;
@@ -717,6 +759,7 @@ private void TickInactivePlayerControlled(PetContext context, float deltaTime)
isWandering = false;
_wanderStuckTimer = 0f;
_hasWanderPrevActualPosition = false;
+ _hasWanderLastTargetDistance = false;
}
else
{
@@ -730,22 +773,57 @@ private void TickInactivePlayerControlled(PetContext context, float deltaTime)
stuckThisFrame = step > WanderStuckMoveThreshold && actualMove < WanderStuckMoveThreshold;
}
- if (stuckThisFrame)
+ // 受阻 = 撞上家具:要么物理上卡住不动,要么沿表面滑动但无法接近漫游目标。
+ // 物理卡住帧用于立即触发家具交互;距离未缩短(含滑行)仅用于累计 2s 放弃计时。
+ bool blockedThisFrame = stuckThisFrame;
+ if (!blockedThisFrame && _hasWanderLastTargetDistance)
+ {
+ float targetDistance = toTarget.magnitude;
+ blockedThisFrame = step > WanderStuckMoveThreshold &&
+ targetDistance > _wanderLastTargetDistance - WanderProgressThreshold;
+ }
+ _wanderLastTargetDistance = toTarget.magnitude;
+ _hasWanderLastTargetDistance = true;
+
+ if (blockedThisFrame)
{
_wanderStuckTimer += deltaTime;
- if (_wanderStuckTimer >= WanderStuckTimeout)
+ // 物理真正卡住(本帧实际无位移)时立即尝试家具交互,不依赖累计计时。
+ // 计时方案在「受阻/未受阻」交替抖动下不可靠:实测卡住帧与滑动帧交替,
+ // 计时器反复被清零,从未稳定到触发阈值。改为用 stuckThisFrame(真实卡住)
+ // 而非 blockedThisFrame(含沿表面滑行),避免正常绕行时误触发。
+ if (stuckThisFrame && _wanderInteractionRetryCooldown <= 0f)
{
+ if (TryStartWanderInteraction(context))
+ {
+ // 交互已启动:本帧立即进入 Interacting 状态,后续由 TickWanderInteraction 处理。
+ isWandering = false;
+ }
+ else
+ {
+ // 半径内没有可交互家具:短暂冷却避免每帧重试,之后靠 2s 放弃逻辑换目标。
+ _wanderInteractionRetryCooldown = WanderInteractionRetryCooldownSeconds;
+ }
+ }
+
+ if (!_wanderInteractionActive && _wanderStuckTimer >= WanderStuckTimeout)
+ {
+ // 卡住 2s 且未触发家具交互(没有可用家具 / 交互冷却中):
+ // 放弃当前目标,避免一直原地走。
SetWanderVelocity(Vector2.zero);
wander.AbandonTarget();
isWandering = false;
_wanderStuckTimer = 0f;
_hasWanderPrevActualPosition = false;
+ _hasWanderLastTargetDistance = false;
}
- // 卡住但未超时:保持 velocity,继续朝向目标播放移动动画
+ // 受阻但未放弃:保持 velocity,继续朝向目标播放移动动画
}
else
{
- _wanderStuckTimer = 0f;
+ // 未受阻:衰减而非清零。撞上家具后受阻/未受阻帧交替抖动,
+ // 清零会让计时器永远到不了阈值;真正自由移动时衰减回 0 不会误触发。
+ _wanderStuckTimer = Mathf.Max(0f, _wanderStuckTimer - deltaTime * WanderUnblockedDecayFactor);
SetWanderVelocity(toTarget.normalized * wander.MoveSpeed);
}
@@ -760,20 +838,282 @@ private void TickInactivePlayerControlled(PetContext context, float deltaTime)
SetWanderVelocity(Vector2.zero);
_wanderStuckTimer = 0f;
_hasWanderPrevActualPosition = false;
+ _hasWanderLastTargetDistance = false;
}
- SetPlayerControlledState(context, isWandering ? MovingState.StateName : IdleState.StateName);
+ SetPlayerControlledState(
+ context,
+ _wanderInteractionActive
+ ? InteractingState.StateName
+ : isWandering ? MovingState.StateName : IdleState.StateName);
context.Advance(deltaTime);
_tickService?.Tick(context, deltaTime);
ResetPlayerControlledRuntime(context);
- if (!isWandering)
+ if (_wanderInteractionActive)
+ {
+ // 交互启动帧:扣减剩余时长,结束后恢复正常漫游等待。
+ _wanderInteractionRemaining -= deltaTime;
+ if (_wanderInteractionRemaining <= 0f)
+ {
+ EndWanderInteraction();
+ // 同帧切回 Idle,避免 UpdateMovementAnimation 仍以 Interacting 状态
+ // 播放一次 Move_Front 兜底动画。
+ SetPlayerControlledState(context, IdleState.StateName);
+ }
+ }
+ else if (!isWandering)
{
context.RuntimeData.TargetPosition = context.RuntimeData.Position;
context.RuntimeData.TargetReached = true;
}
}
+ private void TickWanderInteraction(PetContext context, float deltaTime)
+ {
+ SetWanderVelocity(Vector2.zero);
+ SetPlayerControlledState(context, InteractingState.StateName);
+ context.Advance(deltaTime);
+ _tickService?.Tick(context, deltaTime);
+ ResetPlayerControlledRuntime(context);
+ context.RuntimeData.TargetReached = true;
+
+ _wanderInteractionRemaining -= deltaTime;
+ if (_wanderInteractionRemaining > 0f)
+ {
+ return;
+ }
+
+ EndWanderInteraction();
+ // 同帧切回 Idle,避免 UpdateMovementAnimation 仍以 Interacting 状态
+ // 播放一次 Move_Front 兜底动画。
+ SetPlayerControlledState(context, IdleState.StateName);
+ }
+
+ private void EndWanderInteraction()
+ {
+ _wanderInteractionActive = false;
+ _wanderInteractionAnimatorStateName = string.Empty;
+ SetWanderVelocity(Vector2.zero);
+ _wanderStuckTimer = 0f;
+ // 交互结束后限频:期间若再次撞上家具,走 2s 放弃逻辑换目标,
+ // 避免宠物被困在角落「交互→等待→再交互」死循环,也避免交互过于频繁。
+ _wanderInteractionRetryCooldown = WanderInteractionPostSuccessCooldown;
+ _hasWanderPrevActualPosition = false;
+ _hasWanderLastTargetDistance = false;
+ // 还原自动交互期间应用的可视/排序/pose 覆盖(与手动路径一致),
+ // 并把宠物恢复回交互前的游荡位置。
+ RestoreHiddenInteractionVisuals();
+ RestoreSleepInteractionVisual();
+ RestoreInteractionSorting();
+ RestoreInteractionPose();
+ RandomWander? wander = GetComponent();
+ if (wander != null)
+ {
+ wander.NotifyArrived();
+ }
+ }
+
+ private bool TryStartWanderInteraction(PetContext context)
+ {
+ if (_wanderInteractionActive)
+ {
+ return false;
+ }
+
+ // 优先用宠物自身的交互绑定(与手动 F 键/点击同一套:硬编码交互点 + 显式动画状态名 + pose 数据)。
+ // 公寓场景里 FurnitureService._placedFurniture 为空(ApartmentSceneFurnitureBindings 的
+ // ResolveTarget 全部解析失败,Editor.log 有 30 条 "Skip binding" 警告),自动交互不能拿它当主依赖;
+ // 但仍有场景会注册家具,所以命中失败后兜底再试 _placedFurniture(request 为 default,
+ // 状态用 ResolveFurnitureInteractionStateName 按宠物映射)。
+ PetPlayerInteractionRequest request = default;
+ bool hasRequestData = TryFindNearbyAutoBinding(out FurnitureInteractionTarget target, out request);
+ bool found = hasRequestData || TryFindNearbyFurniture(context, out target);
+
+ if (!found)
+ {
+ return false;
+ }
+
+ _wanderInteractionActive = true;
+ _wanderInteractionRemaining = Mathf.Max(0.1f, target.InteractionDurationSeconds);
+ if (hasRequestData)
+ {
+ // 绑定命中:显式动画状态名优先(如 Interact_PlayGame);缺省时用变体映射兜底(如 "devil sleep" → Interact_DevilSleep)。
+ _wanderInteractionAnimatorStateName = !string.IsNullOrWhiteSpace(request.AnimatorStateNameOverride)
+ ? request.AnimatorStateNameOverride
+ : !string.IsNullOrWhiteSpace(request.AnimationVariant)
+ ? ResolvePlayerInteractionStateName(request.AnimationVariant)
+ : ResolveFurnitureInteractionStateName(target.InteractionType);
+ }
+ else
+ {
+ // _placedFurniture 命中:request 是 default,按家具类型映射(已按宠物区分天使/恶魔状态)。
+ _wanderInteractionAnimatorStateName = ResolveFurnitureInteractionStateName(target.InteractionType);
+ }
+
+ SetWanderVelocity(Vector2.zero);
+
+ // 与手动路径一致:应用可视/排序/特殊可视覆盖,并把宠物摆到固定交互点,
+ // 而不是在卡住的原地播动画。_placedFurniture 路径 request 为 default,
+ // 覆盖/pose 均以 default 请求应用(缩放已在 ApplyAutoInteractionPose 内兜底为当前缩放)。
+ ApplyInteractionVisualOverride(request);
+ ApplyInteractionSortingOverride(request);
+ ApplySpecialInteractionVisualOverride(request);
+
+ // 绑定路径尊重绑定的 pose 意图:门边/书柜等 UsePetPoseOverride=false 的绑定,
+ // 手动 F 键/点击不会移动或缩放宠物(保持当前缩放 0.5,只在原地播动画),自动路径
+ // 也必须一致;否则自动触发会把宠物强制缩到绑定缩放(门边/书柜为 1.0),出现
+ // "自动播放动画的大小 != 手动触发的大小"。_placedFurniture 兜底路径 request 为
+ // default(UsePetPoseOverride=false),但它是唯一命中,仍需 pose 到家具交互点
+ // (缩放兜底为当前缩放),所以用 !hasRequestData 放行。
+ if (!hasRequestData || request.UsePetPoseOverride)
+ {
+ ApplyAutoInteractionPose(request, target.InteractionPoint);
+ }
+
+ Debug.Log(
+ $"[PetInteraction] Auto wander interaction started target='{target.FurnitureId}' " +
+ $"state='{_wanderInteractionAnimatorStateName}' duration={_wanderInteractionRemaining:F2}");
+
+ // 与 InteractingState.Enter 一致:应用环境加成并广播事件(仅当通过 _placedFurniture 命中时)。
+ if (context.FurnitureService is not null &&
+ context.FurnitureService.TryConsumeInteractionBuff(target.FurnitureId, out EnvironmentalBuff buff))
+ {
+ StatTickService.ApplyEnvironmentalBuff(context.RuntimeData, buff.MoodDelta, buff.EnergyDelta);
+ context.RuntimeData.LastInteractionFurnitureId = target.FurnitureId;
+ context.RuntimeData.LastInteractionSummary =
+ $"{target.InteractionType.ToDisplayLabel()} / {target.Category} (Mood {FormatSigned(buff.MoodDelta)}, Energy {FormatSigned(buff.EnergyDelta)})";
+ context.EventBus?.Publish(new PetInteractionCompletedEvent(
+ context.RuntimeData.PetId,
+ target.FurnitureId,
+ target.Category,
+ target.InteractionType));
+ }
+
+ return true;
+ }
+
+ private bool TryFindNearbyFurniture(PetContext context, out FurnitureInteractionTarget target)
+ {
+ target = default;
+ if (context.FurnitureService is null)
+ {
+ return false;
+ }
+
+ IReadOnlyList placed = context.FurnitureService.GetPlacedFurniture();
+ if (placed is null || placed.Count == 0)
+ {
+ return false;
+ }
+
+ Vector2 origin = GetCurrentWorldPosition();
+ GeminiLab.Modules.Furniture.Furniture? best = null;
+ float bestDistance = WanderInteractionRadius;
+ for (int i = 0; i < placed.Count; i++)
+ {
+ GeminiLab.Modules.Furniture.Furniture furniture = placed[i];
+ if (furniture is null || !furniture.Anchor.IsAvailable)
+ {
+ continue;
+ }
+
+ float distance = Vector2.Distance(origin, furniture.Anchor.WorldPosition);
+ if (distance <= bestDistance)
+ {
+ bestDistance = distance;
+ best = furniture;
+ }
+ }
+
+ if (best is null)
+ {
+ return false;
+ }
+
+ FurnitureDefinitionSO definition = best.Definition;
+ target = new FurnitureInteractionTarget(
+ best.InstanceId,
+ definition.Id,
+ definition.Category,
+ definition.InteractionType,
+ definition.InteractionDurationSeconds,
+ best.Anchor.WorldPosition,
+ -bestDistance);
+ return true;
+ }
+
+ ///
+ /// 从宠物自身的 PetPlayerFurnitureInteractionController 绑定中寻找最近的可交互家具。
+ /// 手动 F 键/点击路径用同一套绑定(硬编码交互点 + 显式动画状态名),不依赖 _placedFurniture;
+ /// 公寓场景 _placedFurniture 为空,只有这条路径能命中。
+ ///
+ private bool TryFindNearbyAutoBinding(out FurnitureInteractionTarget target, out PetPlayerInteractionRequest request)
+ {
+ target = default;
+ request = default;
+
+ if (!TryGetComponent(out PetPlayerFurnitureInteractionController interactionController) ||
+ !interactionController.TryGetAutoInteractionCandidate(out AutoInteractionCandidate candidate))
+ {
+ return false;
+ }
+
+ request = candidate.Request;
+ target = new FurnitureInteractionTarget(
+ request.TargetName,
+ request.TargetName,
+ request.Category,
+ request.InteractionType,
+ request.InteractionDurationSeconds,
+ candidate.InteractionPoint,
+ 0f);
+ return true;
+ }
+
+ ///
+ /// 自动交互的 pose:复用 ,把宠物摆到
+ /// 绑定解析出的固定交互点(target.InteractionPoint,即绑定硬编码的 fallbackWorldPoint)。
+ /// 手动路径靠玩家把宠物走到点位;自动路径宠物可能卡在家具边缘,必须主动摆到固定点。
+ /// 仅当调用方确认该交互需要 pose 时才调用(UsePetPoseOverride=false 的门边/书柜不走这里,
+ /// 与手动行为一致,保持当前缩放)。
+ ///
+ private void ApplyAutoInteractionPose(PetPlayerInteractionRequest request, Vector2 interactionPoint)
+ {
+ // 绑定路径的 request 携带绑定缩放(0.39~0.5);_placedFurniture 兜底路径的 request 是 default,
+ // PetInteractionScale 为 (0,0,0),直接套用会让宠物缩到看不见,这里用当前缩放兜底。
+ Vector3 scale = request.PetInteractionScale.sqrMagnitude > 0.0001f
+ ? request.PetInteractionScale
+ : transform.localScale;
+
+ PetPlayerInteractionRequest poseRequest = new PetPlayerInteractionRequest(
+ request.TargetName,
+ request.Category,
+ request.InteractionType,
+ request.AnimationVariant,
+ request.AnimatorStateNameOverride,
+ request.HideTargetWhileInteracting,
+ request.VisualHideTarget,
+ visualPoseTarget: null,
+ request.AdditionalVisualHideTargets,
+ request.UseTargetSortingWhileInteracting,
+ request.VisualSortingTarget,
+ request.SortingOrderOffsetWhileInteracting,
+ usePetPoseOverride: true,
+ useTargetPositionForPetPose: false,
+ petInteractionLocalOffset: Vector2.zero,
+ petInteractionWorldPoint: interactionPoint,
+ petInteractionScale: scale,
+ request.InteractionDurationSeconds);
+ ApplyInteractionPoseOverride(poseRequest);
+ }
+
+ private static string FormatSigned(float value)
+ {
+ return value >= 0f ? $"+{value:0.#}" : value.ToString("0.#");
+ }
+
private void SetWanderVelocity(Vector2 velocity)
{
if (_rigidbody2D != null)
@@ -1257,7 +1597,8 @@ private void UpdateMovementAnimation()
if (currentState == InteractingState.StateName || currentState == WorkingState.StateName)
{
- PlayForcedAnimatorState(ResolveInteractionStateName());
+ string resolvedInteractionState = ResolveInteractionStateName();
+ PlayForcedAnimatorState(resolvedInteractionState);
_animator.SetBool(IsMovingHash, false);
_animator.speed = 1f;
return;
@@ -1272,6 +1613,7 @@ private void UpdateMovementAnimation()
if (!isMoving)
{
+ _animationDirectionDebouncer.Reset();
PlayForcedAnimatorState(ResolveIdleStateName(_lastMoveDirection));
}
else
@@ -1281,22 +1623,29 @@ private void UpdateMovementAnimation()
if (IsPlayerControlled() && _hasPlayerAnimationDirection)
{
+ // 玩家输入的方向即时生效,不参与去抖。
_lastMoveDirection = _playerAnimationDirection;
- }
- else if (hasDelta && delta.sqrMagnitude > MinDirectionDeltaSqr)
- {
- _lastMoveDirection = delta.normalized;
+ _animationDirectionDebouncer.Reset();
}
else if (isMoving && _context is not null)
{
- // When frame-to-frame delta is tiny, keep direction aligned with
- // current movement target so transitions still choose correct clip.
+ // 移动中优先使用目标方向:目标是稳定点,而逐帧实际位移在撞上家具后
+ // 会沿表面滑动/抖动。两种来源都经过去抖,避免 MoveDir 高频翻转
+ // 让动画在 Move_Front / Move_Back / Move_Side 之间乱切换。
Vector2 targetDelta = _context.RuntimeData.TargetPosition - currentPosition;
if (targetDelta.sqrMagnitude > DirectionEpsilonSqr)
{
- _lastMoveDirection = targetDelta.normalized;
+ _lastMoveDirection = _animationDirectionDebouncer.Step(targetDelta.normalized, _lastMoveDirection);
+ }
+ else if (hasDelta && delta.sqrMagnitude > MinDirectionDeltaSqr)
+ {
+ _lastMoveDirection = _animationDirectionDebouncer.Step(delta.normalized, _lastMoveDirection);
}
}
+ else if (hasDelta && delta.sqrMagnitude > MinDirectionDeltaSqr)
+ {
+ _lastMoveDirection = _animationDirectionDebouncer.Step(delta.normalized, _lastMoveDirection);
+ }
_animator.SetBool(IsMovingHash, isMoving);
_animator.SetFloat(MoveXHash, _lastMoveDirection.x);
@@ -1371,6 +1720,11 @@ private void UpdateSideMirror(int moveDir, Vector2 direction)
private string ResolveInteractionStateName()
{
+ if (_wanderInteractionActive && !string.IsNullOrEmpty(_wanderInteractionAnimatorStateName))
+ {
+ return _wanderInteractionAnimatorStateName;
+ }
+
if (_context?.RuntimeData.IsPlayerInteractionActive == true)
{
if (!string.IsNullOrWhiteSpace(_context.RuntimeData.PlayerInteractionAnimatorStateName))
@@ -1388,7 +1742,40 @@ private string ResolveInteractionStateName()
return InteractReadStateName;
}
- return _context?.RuntimeData.TargetFurnitureInteractionType switch
+ return ResolveFurnitureInteractionStateName(
+ _context?.RuntimeData.TargetFurnitureInteractionType ?? FurnitureInteractionType.Unknown);
+ }
+
+ private string ResolveFurnitureInteractionStateName(FurnitureInteractionType interactionType)
+ {
+ if (_petId == PetId.Devil)
+ {
+ // 恶魔控制器只有 Interact_DevilSleep/Draw/LookAround/PlayGame,
+ // 没有天使的 Interact_BesideDoor/Read/PlayingMusic。按宠物映射到恶魔实际拥有的状态,
+ // 避免 PlayForcedAnimatorState 因状态缺失报警告并播不出动画。
+ return interactionType switch
+ {
+ FurnitureInteractionType.PlayHarp => InteractPlayGameStateName,
+ FurnitureInteractionType.PlayGuitar => InteractDrawStateName,
+ FurnitureInteractionType.PaintAtEasel => InteractDrawStateName,
+ FurnitureInteractionType.ViewPhotoBoard => InteractDrawStateName,
+ FurnitureInteractionType.LeisureEngage => InteractDrawStateName,
+ FurnitureInteractionType.InspectBookshelf => InteractLookAroundStateName,
+ FurnitureInteractionType.InspectMirror => InteractLookAroundStateName,
+ FurnitureInteractionType.InspectNightstand => InteractLookAroundStateName,
+ FurnitureInteractionType.ObservePlant => InteractLookAroundStateName,
+ FurnitureInteractionType.ObserveWindow => InteractLookAroundStateName,
+ FurnitureInteractionType.InspectToy => InteractLookAroundStateName,
+ FurnitureInteractionType.ArrangePillow => InteractLookAroundStateName,
+ FurnitureInteractionType.InspectPapers => InteractLookAroundStateName,
+ FurnitureInteractionType.ListenToAudio => InteractLookAroundStateName,
+ FurnitureInteractionType.OrganizeStorage => InteractLookAroundStateName,
+ FurnitureInteractionType.DecorInspect => InteractLookAroundStateName,
+ _ => MoveFrontStateName
+ };
+ }
+
+ return interactionType switch
{
FurnitureInteractionType.PlayHarp => InteractPlayingMusicStateName,
FurnitureInteractionType.PlayGuitar => InteractReadStateName,
diff --git a/Assets/_Project/Scripts/Modules/Pet/PetPlayerFurnitureInteractionController.cs b/Assets/_Project/Scripts/Modules/Pet/PetPlayerFurnitureInteractionController.cs
index 1219afec..f06e7e8e 100644
--- a/Assets/_Project/Scripts/Modules/Pet/PetPlayerFurnitureInteractionController.cs
+++ b/Assets/_Project/Scripts/Modules/Pet/PetPlayerFurnitureInteractionController.cs
@@ -14,6 +14,13 @@ public sealed class PetPlayerFurnitureInteractionController : MonoBehaviour
{
private const string DevilFTracePrefix = "[DEVIL_F_TRACE]";
+ ///
+ /// 漫游自动交互的搜索半径增量。手动 F 键/点击用绑定自身的激活距离即可;
+ /// 自动路径在宠物“卡住”时触发,宠物可能撞在大型家具边缘而硬编码交互点在家具中心,
+ /// 因此额外放宽该距离,确保边缘碰撞也能命中对应绑定。
+ ///
+ private const float AutoInteractionRadiusPadding = 1.25f;
+
[Serializable]
public sealed class InteractionAnimationOption
{
@@ -212,6 +219,93 @@ private bool TryGetClosestBinding(
return bestBinding != null && !string.IsNullOrWhiteSpace(bestTargetName);
}
+ ///
+ /// 自动交互专用:与
+ /// 相同,但搜索半径在绑定激活距离上增加 。
+ /// 手动路径在玩家已走近家具时才判定;自动路径在宠物撞上家具“卡住”时触发,
+ /// 宠物可能在大型家具边缘而交互点在家具中心,放宽半径才能命中。
+ ///
+ private bool TryGetClosestAutoBinding(
+ Vector2 petPosition,
+ float radiusPadding,
+ out InteractionBinding? bestBinding,
+ out string bestTargetName,
+ out GameObject? bestTargetObject,
+ out float bestBindingDistance)
+ {
+ bestBinding = null;
+ bestTargetName = string.Empty;
+ bestTargetObject = null;
+ bestBindingDistance = float.MaxValue;
+
+ for (int i = 0; i < _bindings.Length; i++)
+ {
+ InteractionBinding binding = _bindings[i];
+ if (!TryResolveInteractionPoint(binding, out Vector2 interactionPoint, out string targetName, out GameObject? targetObject))
+ {
+ continue;
+ }
+
+ float distance = Vector2.Distance(petPosition, interactionPoint);
+ float activationDistance = binding.ActivationDistance + radiusPadding;
+ if (distance > activationDistance || distance >= bestBindingDistance)
+ {
+ continue;
+ }
+
+ bestBindingDistance = distance;
+ bestBinding = binding;
+ bestTargetName = targetName;
+ bestTargetObject = targetObject;
+ }
+
+ return bestBinding != null && !string.IsNullOrWhiteSpace(bestTargetName);
+ }
+
+ ///
+ /// 漫游自动交互候选:返回离宠物最近、且在激活距离(含放宽)内的家具绑定。
+ /// 与手动 F 键/点击共用同一套绑定(硬编码交互点 + 显式动画状态名),
+ /// 因此不依赖 FurnitureService 是否注册了家具——公寓场景里
+ /// ApartmentSceneFurnitureBindings 全部解析失败、_placedFurniture 为空,
+ /// 只有这套绑定可解析,自动交互必须复用它才能命中并播放正确动画。
+ ///
+ public bool TryGetAutoInteractionCandidate(out AutoInteractionCandidate candidate)
+ {
+ candidate = default;
+ if (!_enableInteraction || !isActiveAndEnabled)
+ {
+ return false;
+ }
+
+ if (_petController == null)
+ {
+ _petController = GetComponent();
+ }
+
+ if (_petController == null ||
+ !TryGetClosestAutoBinding(
+ transform.position,
+ AutoInteractionRadiusPadding,
+ out InteractionBinding? binding,
+ out string targetName,
+ out GameObject? targetObject,
+ out _))
+ {
+ return false;
+ }
+
+ if (binding is null ||
+ !TryResolveInteractionPoint(binding, out Vector2 interactionPoint, out _, out _))
+ {
+ return false;
+ }
+
+ candidate = new AutoInteractionCandidate(
+ BuildInteractionRequest(binding, targetName, targetObject),
+ interactionPoint);
+ return true;
+ }
+
private bool TryEnsurePetController(out PetController? petController)
{
if (_petController == null)
@@ -646,6 +740,26 @@ private static bool DoesWorldPointMatchBinding(
}
}
+ ///
+ /// 漫游自动交互的家具候选。由
+ /// 产生,携带与该宠物手动交互绑定完全一致的完整请求(显式动画状态、变体、时长、pose 数据)
+ /// 以及绑定解析出的固定交互点。
+ ///
+ public readonly struct AutoInteractionCandidate
+ {
+ public AutoInteractionCandidate(PetPlayerInteractionRequest request, Vector2 interactionPoint)
+ {
+ Request = request;
+ InteractionPoint = interactionPoint;
+ }
+
+ /// 与手动路径一致、由绑定构建的完整交互请求(含显式动画状态与 pose 数据)。
+ public PetPlayerInteractionRequest Request { get; }
+
+ /// 绑定解析出的固定交互点(硬编码 fallbackWorldPoint 或目标物体位置)。
+ public Vector2 InteractionPoint { get; }
+ }
+
public readonly struct PetPlayerInteractionRequest
{
public PetPlayerInteractionRequest(
diff --git a/Assets/_Project/Tests/EditMode/PetAnimatorDirectionDebouncerTests.cs b/Assets/_Project/Tests/EditMode/PetAnimatorDirectionDebouncerTests.cs
new file mode 100644
index 00000000..ce1ec0a9
--- /dev/null
+++ b/Assets/_Project/Tests/EditMode/PetAnimatorDirectionDebouncerTests.cs
@@ -0,0 +1,106 @@
+#nullable enable
+using GeminiLab.Modules.Pet;
+using NUnit.Framework;
+using UnityEngine;
+
+namespace GeminiLab.Tests.EditMode
+{
+ ///
+ /// 验证漫游宠物撞上家具后动画方向的去抖行为。
+ /// 场景:宠物沿家具表面被物理引擎滑动/挤动时,逐帧实际位移方向会抖动,
+ /// 若直接驱动 MoveDir 会让动画在 Move_Front/Back/Side 间乱切换。
+ ///
+ public sealed class PetAnimatorDirectionDebouncerTests
+ {
+ private static readonly Vector2 Right = Vector2.right;
+ private static readonly Vector2 Up = Vector2.up;
+ private static readonly Vector2 Down = Vector2.down;
+
+ [Test]
+ public void OscillatingCandidate_NeverReachesPersistCount_KeepsHeldDirection()
+ {
+ // 模拟宠物在家具边缘抖动:方向在“朝右”和“朝上”之间来回,
+ // 任何一种方向都无法连续稳定 6 帧。
+ var debouncer = new PetAnimatorDirectionDebouncer();
+ Vector2 held = Right;
+
+ for (int i = 0; i < 30; i++)
+ {
+ Vector2 candidate = i % 2 == 0 ? Up : Right;
+ held = debouncer.Step(candidate, held);
+ Assert.AreEqual(Right, held, $"第 {i} 帧方向不应因抖动而改变");
+ }
+ }
+
+ [Test]
+ public void PersistentNewDirection_IsAdoptedAfterPersistFrames()
+ {
+ var debouncer = new PetAnimatorDirectionDebouncer();
+ Vector2 held = Right;
+
+ // 连续 5 帧朝上:仍应保持朝右(未达到连续帧数阈值)。
+ for (int i = 0; i < PetAnimatorDirectionDebouncer.PersistFrames - 1; i++)
+ {
+ held = debouncer.Step(Up, held);
+ Assert.AreEqual(Right, held, $"第 {i} 帧尚未达到阈值,不应切换");
+ }
+
+ // 第 6 帧:达到阈值,应切换到朝上。
+ held = debouncer.Step(Up, held);
+ Assert.AreEqual(Up, held, "达到连续帧数阈值后应采纳新方向");
+ }
+
+ [Test]
+ public void CandidateWithinKeepCone_DoesNotChangeDirection()
+ {
+ // 与当前朝向夹角约 26°(点积 0.9),属于可接受抖动范围。
+ var debouncer = new PetAnimatorDirectionDebouncer();
+ Vector2 candidate = new Vector2(0.9f, 0.435f).normalized;
+
+ Vector2 held = debouncer.Step(candidate, Right);
+
+ Assert.AreEqual(Right, held, "夹角在阈值内的候选方向不应触发切换");
+ }
+
+ [Test]
+ public void SingleBounce_DoesNotAccumulateTowardDirectionFlip()
+ {
+ var debouncer = new PetAnimatorDirectionDebouncer();
+ Vector2 held = Right;
+
+ // 一次“朝上”抖动后立刻回到“朝右”:计数应被重置。
+ held = debouncer.Step(Up, held);
+ Assert.AreEqual(Right, held);
+ held = debouncer.Step(Right, held);
+ Assert.AreEqual(Right, held);
+
+ // 再次“朝上”,需要重新累积 6 帧才会切换。
+ for (int i = 0; i < PetAnimatorDirectionDebouncer.PersistFrames - 1; i++)
+ {
+ held = debouncer.Step(Up, held);
+ Assert.AreEqual(Right, held, "回弹后应重新累计帧数");
+ }
+
+ held = debouncer.Step(Up, held);
+ Assert.AreEqual(Up, held);
+ }
+
+ [Test]
+ public void Reset_ClearsConsecutiveFrames()
+ {
+ var debouncer = new PetAnimatorDirectionDebouncer();
+ Vector2 held = Right;
+
+ for (int i = 0; i < PetAnimatorDirectionDebouncer.PersistFrames - 1; i++)
+ {
+ held = debouncer.Step(Up, held);
+ }
+
+ Assert.Greater(debouncer.ConsecutiveChangeFrames, 0, "重置前应已累计帧数");
+
+ debouncer.Reset();
+
+ Assert.AreEqual(0, debouncer.ConsecutiveChangeFrames, "Reset 后应清空累计帧数");
+ }
+ }
+}
diff --git a/Assets/_Project/Tests/EditMode/PetAnimatorDirectionDebouncerTests.cs.meta b/Assets/_Project/Tests/EditMode/PetAnimatorDirectionDebouncerTests.cs.meta
new file mode 100644
index 00000000..adac9daa
--- /dev/null
+++ b/Assets/_Project/Tests/EditMode/PetAnimatorDirectionDebouncerTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: f671be399b8a463488048ddccbb6e8a7
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant: