From bdd449fd891750162b561eeb235830a5e2cc8f33 Mon Sep 17 00:00:00 2001 From: IcePop Date: Fri, 7 Aug 2026 21:10:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=A1=8C=E5=AE=A0=E6=BC=AB=E6=B8=B8?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E4=BA=A4=E4=BA=92=E5=AE=B6=E5=85=B7=20?= =?UTF-8?q?=E2=80=94=20=E6=92=9E=E5=AE=B6=E5=85=B7=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E5=8A=A8=E7=94=BB=E3=80=81=E4=BD=8D=E7=BD=AE?= =?UTF-8?q?=E5=9B=BA=E5=AE=9A=E3=80=81=E5=A4=A7=E5=B0=8F=E4=B8=8E=E6=89=8B?= =?UTF-8?q?=E5=8A=A8=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 宠物撞上家具后自动触发交互动画(优先复用手动 F 键同一套绑定,兜底 _placedFurniture) - 交互播放到绑定固定交互点,不再在卡住的原地播动画 - 自动交互缩放与手动触发一致:尊重绑定的 UsePetPoseOverride,门边/书柜保持当前缩放 (修复自动触发把宠物缩到 1.0、手动保持 0.5 的大小差异) - 动画状态按宠物映射:恶魔映射到 Interact_PlayGame/Draw/LookAround,避免播放天使专属状态报警 - 移动动画方向去抖(新增 PetAnimatorDirectionDebouncer + EditMode 测试),避免撞家具后 Move_Front/Back/Side 因物理滑动抖动而乱切换 --- .../Pet/PetAnimatorDirectionDebouncer.cs | 66 +++ .../Pet/PetAnimatorDirectionDebouncer.cs.meta | 11 + .../Scripts/Modules/Pet/PetController.cs | 417 +++++++++++++++++- ...PetPlayerFurnitureInteractionController.cs | 114 +++++ .../PetAnimatorDirectionDebouncerTests.cs | 106 +++++ ...PetAnimatorDirectionDebouncerTests.cs.meta | 11 + 6 files changed, 710 insertions(+), 15 deletions(-) create mode 100644 Assets/_Project/Scripts/Modules/Pet/PetAnimatorDirectionDebouncer.cs create mode 100644 Assets/_Project/Scripts/Modules/Pet/PetAnimatorDirectionDebouncer.cs.meta create mode 100644 Assets/_Project/Tests/EditMode/PetAnimatorDirectionDebouncerTests.cs create mode 100644 Assets/_Project/Tests/EditMode/PetAnimatorDirectionDebouncerTests.cs.meta 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: