diff --git a/Assets/_Project/Prefabs/Pet/Pet_Angel.prefab b/Assets/_Project/Prefabs/Pet/Pet_Angel.prefab index e83b95f0..d757c6ee 100644 --- a/Assets/_Project/Prefabs/Pet/Pet_Angel.prefab +++ b/Assets/_Project/Prefabs/Pet/Pet_Angel.prefab @@ -431,7 +431,7 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: a313c8d962bc67d4594340dda94bec83, type: 3} m_Name: m_EditorClassIdentifier: - _config: {fileID: 0} + _config: {fileID: 11400000, guid: 71ae029310f9d8449965a9e094ca2ed9, type: 2} _personality: {fileID: 11400000, guid: 1ecede15733b42244a1305f7645463bd, type: 2} _movementController: {fileID: 9100000, guid: ba46f117e79c2504888c585f53d1ecf8, type: 2} _sideFramesFaceLeft: 1 @@ -450,6 +450,7 @@ MonoBehaviour: _useDetachedVisual: 1 - _animatorStateName: Interact_Write _useDetachedVisual: 1 + _behaviorConfig: {fileID: 11400000, guid: 1db41e560f4a41cc981bf1daa52bed43, type: 2} --- !u!70 &5479558928138216786 CapsuleCollider2D: m_ObjectHideFlags: 0 diff --git a/Assets/_Project/Prefabs/Pet/Pet_Devil.prefab b/Assets/_Project/Prefabs/Pet/Pet_Devil.prefab index 9758ce44..fdacba67 100644 --- a/Assets/_Project/Prefabs/Pet/Pet_Devil.prefab +++ b/Assets/_Project/Prefabs/Pet/Pet_Devil.prefab @@ -286,7 +286,7 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: a313c8d962bc67d4594340dda94bec83, type: 3} m_Name: m_EditorClassIdentifier: - _config: {fileID: 0} + _config: {fileID: 11400000, guid: 71ae029310f9d8449965a9e094ca2ed9, type: 2} _personality: {fileID: 11400000, guid: 844d776109915fc4092eeee43b3d825a, type: 2} _movementController: {fileID: 9100000, guid: 49dff8841cae4cc7ae3247d5c623213b, type: 2} _sideFramesFaceLeft: 1 @@ -297,6 +297,7 @@ MonoBehaviour: _interactionVisualStrategy: _useDetachedVisualByDefault: 0 _rules: [] + _behaviorConfig: {fileID: 11400000, guid: 06638d89187f4d659bf57dad62029624, type: 2} --- !u!70 &8393034162494385283 CapsuleCollider2D: m_ObjectHideFlags: 0 diff --git a/Assets/_Project/ScriptableObjects/PersonalityConfig/PersonalityMatrix_Angel.asset b/Assets/_Project/ScriptableObjects/PersonalityConfig/PersonalityMatrix_Angel.asset index 0e542f77..746fa7f7 100644 --- a/Assets/_Project/ScriptableObjects/PersonalityConfig/PersonalityMatrix_Angel.asset +++ b/Assets/_Project/ScriptableObjects/PersonalityConfig/PersonalityMatrix_Angel.asset @@ -11,11 +11,11 @@ MonoBehaviour: m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: 6660e5e8acf89ec42ad4b4f9235e3a96, type: 3} m_Name: PersonalityMatrix_Angel - m_EditorClassIdentifier: - Kindness: 0.7 - Evilness: -0.3 - Calmness: 0.3 - Bravery: 0.2 - Shyness: -0.1 - Integrity: 0.5 - Curiosity: 0.4 + m_EditorClassIdentifier: + Kindness: 0.68 + Evilness: -0.84 + Calmness: 0.8 + Bravery: 0.04 + Shyness: -0.24 + Integrity: 0.76 + Curiosity: -0.08 diff --git a/Assets/_Project/ScriptableObjects/PersonalityConfig/PersonalityMatrix_Devil.asset b/Assets/_Project/ScriptableObjects/PersonalityConfig/PersonalityMatrix_Devil.asset index 6dbf5b5c..90019448 100644 --- a/Assets/_Project/ScriptableObjects/PersonalityConfig/PersonalityMatrix_Devil.asset +++ b/Assets/_Project/ScriptableObjects/PersonalityConfig/PersonalityMatrix_Devil.asset @@ -11,11 +11,11 @@ MonoBehaviour: m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: 6660e5e8acf89ec42ad4b4f9235e3a96, type: 3} m_Name: PersonalityMatrix_Devil - m_EditorClassIdentifier: - Kindness: -0.2 - Evilness: 0.6 - Calmness: -0.1 - Bravery: 0.4 - Shyness: 0.1 - Integrity: -0.3 - Curiosity: 0.5 + m_EditorClassIdentifier: + Kindness: 0.12 + Evilness: 0.44 + Calmness: 0.36 + Bravery: 0.56 + Shyness: 0.28 + Integrity: -0.32 + Curiosity: 0.64 diff --git a/Assets/_Project/ScriptableObjects/PetConfig.meta b/Assets/_Project/ScriptableObjects/PetConfig.meta new file mode 100644 index 00000000..5c899f5a --- /dev/null +++ b/Assets/_Project/ScriptableObjects/PetConfig.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4a9f6d288ca5e8544ab4de87cb9b4af6 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/ScriptableObjects/PetConfig/BehaviorConfig_Angel.asset b/Assets/_Project/ScriptableObjects/PetConfig/BehaviorConfig_Angel.asset new file mode 100644 index 00000000..20a99d95 --- /dev/null +++ b/Assets/_Project/ScriptableObjects/PetConfig/BehaviorConfig_Angel.asset @@ -0,0 +1,126 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: ed572c5aa5a44937ae33760d2600d779, type: 3} + m_Name: BehaviorConfig_Angel + m_EditorClassIdentifier: + Behaviors: + - BehaviorId: idle + Label: "待机" + BaseWeight: 15 + Category: 0 + PersonalityTags: [] + PreferredEnergy: 50 + EnergyDelta: 0 + MoodDelta: 0 + CooldownSeconds: 0 + Tags: 0 + BindingInteractionType: 0 + OverrideDurationSeconds: 0 + - BehaviorId: sleep + Label: "睡觉" + BaseWeight: 10 + Category: 1 + PersonalityTags: + - Trait: 2 + Direction: 1 + PreferredEnergy: 20 + EnergyDelta: 25 + MoodDelta: 1 + CooldownSeconds: 300 + Tags: 0 + BindingInteractionType: 5 + OverrideDurationSeconds: 30 + - BehaviorId: water_flowers + Label: "浇花" + BaseWeight: 20 + Category: 3 + PersonalityTags: + - Trait: 0 + Direction: 1 + - Trait: 2 + Direction: 1 + PreferredEnergy: 70 + EnergyDelta: -3 + MoodDelta: 1 + CooldownSeconds: 180 + Tags: 1 + BindingInteractionType: 13 + OverrideDurationSeconds: 0 + - BehaviorId: read_books + Label: "看书" + BaseWeight: 20 + Category: 2 + PersonalityTags: + - Trait: 2 + Direction: 1 + - Trait: 5 + Direction: 1 + PreferredEnergy: 55 + EnergyDelta: -1 + MoodDelta: 1 + CooldownSeconds: 90 + Tags: 0 + BindingInteractionType: 6 + OverrideDurationSeconds: 0 + - BehaviorId: play_harp + Label: "弹琴" + BaseWeight: 15 + Category: 2 + PersonalityTags: + - Trait: 2 + Direction: 1 + - Trait: 0 + Direction: 1 + PreferredEnergy: 60 + EnergyDelta: -2 + MoodDelta: 2 + CooldownSeconds: 180 + Tags: 0 + BindingInteractionType: 9 + OverrideDurationSeconds: 0 + - BehaviorId: journaling + Label: "写字" + BaseWeight: 15 + Category: 2 + PersonalityTags: + - Trait: 5 + Direction: 1 + - Trait: 2 + Direction: 1 + PreferredEnergy: 50 + EnergyDelta: -1 + MoodDelta: 1 + CooldownSeconds: 120 + Tags: 0 + BindingInteractionType: 1 + OverrideDurationSeconds: 0 + - BehaviorId: door_gaze + Label: "门前驻足" + BaseWeight: 5 + Category: 4 + PersonalityTags: + - Trait: 6 + Direction: 1 + - Trait: 3 + Direction: 1 + - Trait: 4 + Direction: -1 + PreferredEnergy: 75 + EnergyDelta: -4 + MoodDelta: 0 + CooldownSeconds: 240 + Tags: 1 + BindingInteractionType: 3 + OverrideDurationSeconds: 0 + IdleMinSeconds: 3 + IdleMaxSeconds: 8 + TransitionPauseSeconds: 2 diff --git a/Assets/_Project/ScriptableObjects/PetConfig/BehaviorConfig_Angel.asset.meta b/Assets/_Project/ScriptableObjects/PetConfig/BehaviorConfig_Angel.asset.meta new file mode 100644 index 00000000..00fc3f17 --- /dev/null +++ b/Assets/_Project/ScriptableObjects/PetConfig/BehaviorConfig_Angel.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1db41e560f4a41cc981bf1daa52bed43 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/ScriptableObjects/PetConfig/BehaviorConfig_Devil.asset b/Assets/_Project/ScriptableObjects/PetConfig/BehaviorConfig_Devil.asset new file mode 100644 index 00000000..7dd50013 --- /dev/null +++ b/Assets/_Project/ScriptableObjects/PetConfig/BehaviorConfig_Devil.asset @@ -0,0 +1,94 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: ed572c5aa5a44937ae33760d2600d779, type: 3} + m_Name: BehaviorConfig_Devil + m_EditorClassIdentifier: + Behaviors: + - BehaviorId: idle + Label: "待机" + BaseWeight: 20 + Category: 0 + PersonalityTags: [] + PreferredEnergy: 50 + EnergyDelta: 0 + MoodDelta: 0 + CooldownSeconds: 0 + Tags: 0 + BindingInteractionType: 0 + OverrideDurationSeconds: 0 + - BehaviorId: sleep + Label: "睡觉" + BaseWeight: 15 + Category: 1 + PersonalityTags: + - Trait: 2 + Direction: 1 + PreferredEnergy: 20 + EnergyDelta: 25 + MoodDelta: 1 + CooldownSeconds: 300 + Tags: 0 + BindingInteractionType: 5 + OverrideDurationSeconds: 30 + - BehaviorId: play_games + Label: "玩掌机" + BaseWeight: 30 + Category: 3 + PersonalityTags: + - Trait: 1 + Direction: 1 + - Trait: 6 + Direction: 1 + PreferredEnergy: 80 + EnergyDelta: -4 + MoodDelta: 2 + CooldownSeconds: 150 + Tags: 1 + BindingInteractionType: 19 + OverrideDurationSeconds: 0 + - BehaviorId: paint + Label: "画画" + BaseWeight: 30 + Category: 2 + PersonalityTags: + - Trait: 6 + Direction: 1 + - Trait: 1 + Direction: 1 + PreferredEnergy: 65 + EnergyDelta: -2 + MoodDelta: 2 + CooldownSeconds: 120 + Tags: 0 + BindingInteractionType: 11 + OverrideDurationSeconds: 0 + - BehaviorId: door_gaze + Label: "门前驻足" + BaseWeight: 5 + Category: 4 + PersonalityTags: + - Trait: 6 + Direction: 1 + - Trait: 3 + Direction: 1 + - Trait: 4 + Direction: -1 + PreferredEnergy: 75 + EnergyDelta: -4 + MoodDelta: 0 + CooldownSeconds: 240 + Tags: 1 + BindingInteractionType: 3 + OverrideDurationSeconds: 0 + IdleMinSeconds: 3 + IdleMaxSeconds: 8 + TransitionPauseSeconds: 2 diff --git a/Assets/_Project/ScriptableObjects/PetConfig/BehaviorConfig_Devil.asset.meta b/Assets/_Project/ScriptableObjects/PetConfig/BehaviorConfig_Devil.asset.meta new file mode 100644 index 00000000..14116bdd --- /dev/null +++ b/Assets/_Project/ScriptableObjects/PetConfig/BehaviorConfig_Devil.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 06638d89187f4d659bf57dad62029624 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/ScriptableObjects/PetStateValueConfig.asset b/Assets/_Project/ScriptableObjects/PetStateValueConfig.asset index cd6d8230..8edaf571 100644 --- a/Assets/_Project/ScriptableObjects/PetStateValueConfig.asset +++ b/Assets/_Project/ScriptableObjects/PetStateValueConfig.asset @@ -11,10 +11,21 @@ MonoBehaviour: m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: b1fa3a54ddfbc7c49beb25460f5dd1d5, type: 3} m_Name: PetStateValueConfig - m_EditorClassIdentifier: - InitialMood: 60 + m_EditorClassIdentifier: + InitialMood: 50 InitialEnergy: 100 InitialSatiety: 75 + AwakeEnergyDrainMinutesPerPoint: 3 + MoodNeutralReturnIntervalSeconds: 300 + MoodNeutralTarget: 50 + SatietyDecayPerSecond: 0.005 + ForcedSleepEnergyThreshold: 5 + SleepExcludedAboveEnergy: 70 + ActiveBehaviorMinEnergy: 10 + MoveEnergyCost: 1 + MoveEnergyCostMinDistance: 1.5 + ForceWakeMoodPenalty: 30 + WorkStateTimeoutSeconds: 30 SleepEnterEnergyThreshold: 25 SleepExitEnergyThreshold: 70 DaytimeBedSeekEnergyThreshold: 15 @@ -22,12 +33,3 @@ MonoBehaviour: LeisureSeekMoodThreshold: 85 NightHourStart: 22 NightHourEnd: 6 - AwakeEnergyDecayPerSecond: 0.005 - SleepingEnergyRecoveryPerSecond: 0.1 - MoodRecoveryPerSecond: 0.005 - SatietyDecayPerSecond: 0.005 - LowSatietyMoodPenaltyPerSecond: 0.3 - LowEnergyMoodPenaltyPerSecond: 0.3 - MoodRecoveryPenaltyFactor: 0.2 - ForceWakeMoodPenalty: 30 - WorkStateTimeoutSeconds: 30 diff --git a/Assets/_Project/Scripts/Modules/HubUI/Panels/ProfilePanelStub.cs b/Assets/_Project/Scripts/Modules/HubUI/Panels/ProfilePanelStub.cs index 17def242..1353d1bf 100644 --- a/Assets/_Project/Scripts/Modules/HubUI/Panels/ProfilePanelStub.cs +++ b/Assets/_Project/Scripts/Modules/HubUI/Panels/ProfilePanelStub.cs @@ -5,6 +5,7 @@ using GeminiLab.Core.UI; using GeminiLab.Modules.Pet; using GeminiLab.Modules.Pet.Personality; +using GeminiLab.Modules.Pet.Social; using System.Collections.Generic; using TMPro; using UnityEngine; @@ -51,13 +52,16 @@ public sealed class ProfilePanelStub : StubPanelBase [SerializeField] private Image? _evilPetImage; private IPetRoster? _roster; + private IPetSocialService? _social; private IDisposable? _snapshotSub; private IDisposable? _matrixSub; + private IDisposable? _friendshipSub; protected override void OnDestroy() { _snapshotSub?.Dispose(); _matrixSub?.Dispose(); + _friendshipSub?.Dispose(); base.OnDestroy(); } @@ -76,11 +80,14 @@ public override void OnClose() _snapshotSub = null; _matrixSub?.Dispose(); _matrixSub = null; + _friendshipSub?.Dispose(); + _friendshipSub = null; } private void ResolveServicesIfNeeded() { if (_roster == null) ServiceLocator.TryResolve(out _roster); + if (_social == null) ServiceLocator.TryResolve(out _social); } private void SubscribeSnapshotIfNeeded() @@ -99,6 +106,21 @@ private void SubscribeSnapshotIfNeeded() evolution.MatrixChanged += OnMatrixChanged; _matrixSub = new ActionDisposable(() => evolution.MatrixChanged -= OnMatrixChanged); } + + // 订阅亲密度变化(数值规则文档 §14),与性格订阅同模式。 + if (_friendshipSub == null && _social != null) + { + _social.FriendshipChanged += OnFriendshipChanged; + _friendshipSub = new ActionDisposable(() => + { + if (_social != null) _social.FriendshipChanged -= OnFriendshipChanged; + }); + } + } + + private void OnFriendshipChanged(float _) + { + RefreshRelation(); } private void OnMatrixChanged(PetId _, PersonalityVector __) @@ -159,21 +181,23 @@ private void RefreshPet(PetId id, } } + /// + /// 显示双宠亲密度(数值规则文档 §14):读 。 + /// 旧数据源 PetRuntimeData.Relation 没有写入路径(永远初始值),已废弃显示。 + /// private void RefreshRelation() { - float? relation = null; - if (_roster != null) - { - var data = _roster.TryGet(PetId.Angel) ?? _roster.TryGet(PetId.Devil); - if (data != null) relation = data.Relation; - } + float? friendship = _social?.Friendship; + string label = friendship.HasValue + ? $"{Mathf.RoundToInt(friendship.Value)}·{PetSocialService.GetStageLabel(friendship.Value)}" + : "--"; if (_angelRelationText != null) - _angelRelationText.text = relation.HasValue ? Mathf.RoundToInt(relation.Value).ToString() : "--"; + _angelRelationText.text = label; if (_evilRelationText != null) - _evilRelationText.text = relation.HasValue ? Mathf.RoundToInt(relation.Value).ToString() : "--"; + _evilRelationText.text = label; if (_relationFill != null) - _relationFill.fillAmount = (relation ?? 0f) / 100f; + _relationFill.fillAmount = (friendship ?? 0f) / 100f; } private static void SetUnavailableText(TMP_Text? moodText, TMP_Text? energyText) diff --git a/Assets/_Project/Scripts/Modules/Pet/Behavior.meta b/Assets/_Project/Scripts/Modules/Pet/Behavior.meta new file mode 100644 index 00000000..fa182bce --- /dev/null +++ b/Assets/_Project/Scripts/Modules/Pet/Behavior.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 092cf87b0f1ef964198151276898f244 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorCategory.cs b/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorCategory.cs new file mode 100644 index 00000000..5a2f3ca7 --- /dev/null +++ b/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorCategory.cs @@ -0,0 +1,24 @@ +#nullable enable +namespace GeminiLab.Modules.Pet.Behavior +{ + /// + /// 行为类型(数值规则文档 §6)。心情倍率按此分类查表。 + /// + public enum BehaviorCategory + { + /// 中性:任何心情档位倍率都是 1.0。 + Neutral = 0, + + /// 休息:心情差时更倾向(1.4),心情好时降低(0.7)。 + Rest = 1, + + /// 安静:心情差时略增(1.15),心情好时略降(0.95)。 + Quiet = 2, + + /// 活跃:心情差时降低(0.7),心情好时提高(1.25)。 + Active = 3, + + /// 社交/探索:心情差时明显降低(0.6),心情好时明显提高(1.3)。 + Social = 4 + } +} diff --git a/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorCategory.cs.meta b/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorCategory.cs.meta new file mode 100644 index 00000000..18a5292c --- /dev/null +++ b/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorCategory.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9c53cc41d3f6ec840ae5c263e452729a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorConfigSO.cs b/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorConfigSO.cs new file mode 100644 index 00000000..0917b1ec --- /dev/null +++ b/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorConfigSO.cs @@ -0,0 +1,99 @@ +#nullable enable +using System; +using System.Collections.Generic; +using GeminiLab.Modules.Furniture; +using UnityEngine; + +namespace GeminiLab.Modules.Pet.Behavior +{ + /// + /// 每只宠物一张行为表(数值规则文档 §7/§22 BehaviorConfig)。 + /// FinalWeight = BaseWeight × Personality × Energy × Mood × Repeat(§1/§25)。 + /// + [CreateAssetMenu(menuName = "GeminiLab/Pet/Behavior Config", fileName = "BehaviorConfig")] + public sealed class BehaviorConfigSO : ScriptableObject + { + [Serializable] + public sealed class PersonalityTag + { + [Tooltip("作用的性格维度")] + public PetTrait Trait = PetTrait.Kindness; + + [Tooltip("+1 = 该维度越高越倾向此行为;-1 = 该维度越高越不倾向(如 -害羞 = 越害羞越少门前驻足)")] + public int Direction = 1; + } + + [Serializable] + public sealed class BehaviorEntry + { + [Tooltip("行为 Id,见 PetBehaviorIds")] + public string BehaviorId = string.Empty; + + [Tooltip("显示名(调试/UI)")] + public string Label = string.Empty; + + [Tooltip("基础权重(§1:原基础概率 ×100),不是最终百分比")] + [Min(0f)] public float BaseWeight = 10f; + + [Tooltip("行为类型,决定心情倍率(§6)")] + public BehaviorCategory Category = BehaviorCategory.Neutral; + + [Tooltip("性格标签列表(§2):逐条按 Direction × Trait × 0.25 累加进性格倍率")] + public PersonalityTag[] PersonalityTags = Array.Empty(); + + [Tooltip("偏好精力(§3):精力越接近该值,精力倍率越高(0.6~1.4)")] + [Range(0f, 100f)] public float PreferredEnergy = 50f; + + [Header("完成结算(§12)")] + public float EnergyDelta; + public float MoodDelta; + + [Tooltip("冷却秒数,从行为结束时开始计算(§10);0 = 无冷却")] + [Min(0f)] public float CooldownSeconds; + + [Tooltip("行为标签(§4):Active = 活跃行为,低精力时被硬规则过滤")] + public BehaviorTag Tags = BehaviorTag.None; + + [Header("场景绑定")] + [Tooltip("对应宠物交互绑定(PetPlayerFurnitureInteractionController)的 InteractionType;" + + "Unknown = 原地行为(待机),无需移动")] + public FurnitureInteractionType BindingInteractionType = FurnitureInteractionType.Unknown; + + [Tooltip(">0 时覆盖绑定时长(例如睡觉需要比绑定动画更久的持续时间);0 = 使用绑定时长")] + [Min(0f)] public float OverrideDurationSeconds; + + public bool RequiresMovement => BindingInteractionType != FurnitureInteractionType.Unknown; + } + + [Header("行为表")] + public List Behaviors = new(); + + [Header("待机(原地行为)时长")] + [Min(0.1f)] public float IdleMinSeconds = 3f; + [Min(0.1f)] public float IdleMaxSeconds = 8f; + + [Header("节奏")] + [Tooltip("一个行为完成后、抽取下一个行为前的过渡停顿秒数")] + [Min(0f)] public float TransitionPauseSeconds = 2f; + + public BehaviorEntry? Find(string behaviorId) + { + for (int i = 0; i < Behaviors.Count; i++) + { + if (string.Equals(Behaviors[i].BehaviorId, behaviorId, StringComparison.Ordinal)) + { + return Behaviors[i]; + } + } + + return null; + } + + public float RollIdleDurationSeconds() + { + float min = Mathf.Min(IdleMinSeconds, IdleMaxSeconds); + float max = Mathf.Max(IdleMinSeconds, IdleMaxSeconds); + return UnityEngine.Random.Range(min, max); + } + } +} diff --git a/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorConfigSO.cs.meta b/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorConfigSO.cs.meta new file mode 100644 index 00000000..06bffd6c --- /dev/null +++ b/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorConfigSO.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ed572c5aa5a44937ae33760d2600d779 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorRuntimeState.cs b/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorRuntimeState.cs new file mode 100644 index 00000000..e1e2cbd4 --- /dev/null +++ b/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorRuntimeState.cs @@ -0,0 +1,93 @@ +#nullable enable +using System.Collections.Generic; + +namespace GeminiLab.Modules.Pet.Behavior +{ + /// + /// 行为运行态(数值规则文档 §22 BehaviorRuntimeState): + /// 当前行为、最近 3 个行为、冷却表。只存活于运行期,不随存档持久化。 + /// + public sealed class BehaviorRuntimeState + { + /// 重复判定窗口长度(§9 "最近三个行为")。 + public const int RecentWindowSize = 3; + + private readonly Queue _recentBehaviors = new(); + private readonly Dictionary _cooldownUntilSeconds = new(); + + /// 正在执行的行为;空闲时为 Empty。 + public string CurrentBehaviorId = string.Empty; + + /// 上一次完成的行为;没有时为 null。 + public string? LastCompletedBehaviorId { get; private set; } + + public IReadOnlyCollection RecentBehaviors => _recentBehaviors; + + /// + /// 重复倍率(§9),判定优先级 Cooldown > LastAction > Recent3 > None。 + /// + public float RepeatMultiplierFor(string behaviorId, float nowSeconds) + { + if (IsOnCooldown(behaviorId, nowSeconds)) + { + return 0f; + } + + if (LastCompletedBehaviorId == behaviorId) + { + return 0.2f; + } + + return ContainsInRecent(behaviorId) ? 0.6f : 1f; + } + + public bool IsOnCooldown(string behaviorId, float nowSeconds) + { + return _cooldownUntilSeconds.TryGetValue(behaviorId, out float until) && nowSeconds < until; + } + + /// 行为完成时记录:推入最近窗口并从行为结束时刻起算冷却(§10)。 + public void RecordCompletion(string behaviorId, float nowSeconds, float cooldownSeconds) + { + if (string.IsNullOrEmpty(behaviorId)) + { + return; + } + + LastCompletedBehaviorId = behaviorId; + _recentBehaviors.Enqueue(behaviorId); + while (_recentBehaviors.Count > RecentWindowSize) + { + _recentBehaviors.Dequeue(); + } + + if (cooldownSeconds > 0f) + { + _cooldownUntilSeconds[behaviorId] = nowSeconds + cooldownSeconds; + } + else + { + _cooldownUntilSeconds.Remove(behaviorId); + } + } + + public bool ContainsInRecent(string behaviorId) + { + return _recentBehaviors.Contains(behaviorId); + } + + /// 测试/调试用:冷却截止时刻,未记录返回 null。 + public float? GetCooldownUntil(string behaviorId) + { + return _cooldownUntilSeconds.TryGetValue(behaviorId, out float until) ? until : null; + } + + public void Clear() + { + CurrentBehaviorId = string.Empty; + LastCompletedBehaviorId = null; + _recentBehaviors.Clear(); + _cooldownUntilSeconds.Clear(); + } + } +} diff --git a/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorRuntimeState.cs.meta b/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorRuntimeState.cs.meta new file mode 100644 index 00000000..3f0a705d --- /dev/null +++ b/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorRuntimeState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 27edf7bed5ee4b34281ce5f26f7e60d4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorSelector.cs b/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorSelector.cs new file mode 100644 index 00000000..400746b0 --- /dev/null +++ b/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorSelector.cs @@ -0,0 +1,156 @@ +#nullable enable +using System; +using System.Collections.Generic; +using GeminiLab.Modules.Pet.Personality; + +namespace GeminiLab.Modules.Pet.Behavior +{ + /// 行为选择输入快照(取自 PetRuntimeData + PetStateValueSO 阈值)。 + public readonly struct BehaviorSelectorInput + { + public BehaviorSelectorInput( + float energy, + float mood, + PersonalityVector personality, + float nowSeconds, + float forcedSleepEnergyThreshold, + float sleepExcludedAboveEnergy, + float activeBehaviorMinEnergy) + { + Energy = energy; + Mood = mood; + Personality = personality; + NowSeconds = nowSeconds; + ForcedSleepEnergyThreshold = forcedSleepEnergyThreshold; + SleepExcludedAboveEnergy = sleepExcludedAboveEnergy; + ActiveBehaviorMinEnergy = activeBehaviorMinEnergy; + } + + public float Energy { get; } + public float Mood { get; } + public PersonalityVector Personality { get; } + public float NowSeconds { get; } + + /// 精力 ≤ 该值时强制睡觉(§4 "0~5 强制进入睡觉")。 + public float ForcedSleepEnergyThreshold { get; } + + /// 精力 > 该值时睡觉不进入候选池(§4 "Energy > 70 睡觉不可进入候选池")。 + public float SleepExcludedAboveEnergy { get; } + + /// 精力 < 该值时活跃行为(BehaviorTag.Active)被禁止(§4 "6~9 禁止活跃行为")。 + public float ActiveBehaviorMinEnergy { get; } + } + + public readonly struct BehaviorPickResult + { + public BehaviorPickResult(BehaviorConfigSO.BehaviorEntry entry, float finalWeight, bool isForced) + { + Entry = entry; + FinalWeight = finalWeight; + IsForced = isForced; + } + + public BehaviorConfigSO.BehaviorEntry Entry { get; } + public float FinalWeight { get; } + + /// 强制行为(如低精力强制睡觉):绕过权重与冷却。 + public bool IsForced { get; } + } + + /// + /// 行为选择器(数值规则文档 §25 执行流程): + /// 候选生成 → 精力硬规则过滤 → 冷却过滤(Repeat=0)→ 四倍率 → 归一化加权随机。 + /// 纯逻辑、无 Unity 依赖(随机源注入),可完整 EditMode 测试。 + /// + public static class BehaviorSelector + { + /// + /// 抽取下一个行为。random01 返回 [0,1) 均匀随机数。 + /// 返回 null 表示配置里没有任何可执行行为(调用方应保持待机)。 + /// + public static BehaviorPickResult? Pick( + BehaviorConfigSO config, + BehaviorSelectorInput input, + BehaviorRuntimeState runtimeState, + Func random01) + { + // 1. 精力硬规则:0~5 强制睡觉(§4)。绕过冷却与权重——避免 + // "睡觉在冷却中 → 低精力宠物无事可做 → 精力归零" 的死锁。 + if (input.Energy <= input.ForcedSleepEnergyThreshold) + { + BehaviorConfigSO.BehaviorEntry? sleep = config.Find(PetBehaviorIds.Sleep); + if (sleep != null) + { + return new BehaviorPickResult(sleep, float.PositiveInfinity, isForced: true); + } + } + + float totalWeight = 0f; + var weights = new List(config.Behaviors.Count); + for (int i = 0; i < config.Behaviors.Count; i++) + { + BehaviorConfigSO.BehaviorEntry entry = config.Behaviors[i]; + float weight = 0f; + if (PassesEnergyHardRules(entry, input)) + { + weight = BehaviorWeightCalculator.FinalWeight( + entry, input.Personality, input.Energy, input.Mood, runtimeState, input.NowSeconds); + } + + weights.Add(weight); + totalWeight += weight; + } + + // 全部候选权重为 0(例如全部在冷却中):兜底回待机,保证宠物始终有事可做。 + if (totalWeight <= 0f) + { + BehaviorConfigSO.BehaviorEntry? idle = config.Find(PetBehaviorIds.Idle); + return idle != null ? new BehaviorPickResult(idle, 0f, isForced: false) : null; + } + + // 2. 归一化加权随机。跳过 0 权重候选(冷却中/被重复倍率清零), + // 否则 random01()=0 时 roll=0 会错误命中第一个 0 权重行为。 + float roll = random01() * totalWeight; + for (int i = 0; i < weights.Count; i++) + { + if (weights[i] <= 0f) + { + continue; + } + + roll -= weights[i]; + if (roll <= 0f) + { + return new BehaviorPickResult(config.Behaviors[i], weights[i], isForced: false); + } + } + + int last = weights.Count - 1; + return new BehaviorPickResult(config.Behaviors[last], weights[last], isForced: false); + } + + /// + /// 精力硬规则(§4): + /// - Energy > 70:睡觉不可进入候选池; + /// - Energy 10~29:正常计算("不能主动发起高强度特殊交互" 当前版本无此类行为,预留); + /// - Energy 6~9(< ActiveBehaviorMinEnergy):禁止活跃行为(BehaviorTag.Active); + /// - Energy 0~5:由强制睡觉分支处理,不走这里。 + /// + private static bool PassesEnergyHardRules(BehaviorConfigSO.BehaviorEntry entry, BehaviorSelectorInput input) + { + if (entry.BehaviorId == PetBehaviorIds.Sleep && + input.Energy > input.SleepExcludedAboveEnergy) + { + return false; + } + + if ((entry.Tags & BehaviorTag.Active) != 0 && + input.Energy < input.ActiveBehaviorMinEnergy) + { + return false; + } + + return true; + } + } +} diff --git a/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorSelector.cs.meta b/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorSelector.cs.meta new file mode 100644 index 00000000..f55cb6b6 --- /dev/null +++ b/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorSelector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a3f7a8b2df5b0af4d9e77774ccc332bb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorTag.cs b/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorTag.cs new file mode 100644 index 00000000..596e6824 --- /dev/null +++ b/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorTag.cs @@ -0,0 +1,17 @@ +#nullable enable +using System; + +namespace GeminiLab.Modules.Pet.Behavior +{ + /// + /// 行为标签(数值规则文档 §4)。新增行为时用标签而非硬编码名称参与硬规则过滤。 + /// + [Flags] + public enum BehaviorTag + { + None = 0, + + /// 活跃行为:精力低于阈值(默认 10,文档 §4 "6~9 禁止活跃行为")时被过滤。 + Active = 1 << 0 + } +} diff --git a/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorTag.cs.meta b/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorTag.cs.meta new file mode 100644 index 00000000..74bedb0e --- /dev/null +++ b/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorTag.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 520d5dcf364109e4ba8a0f7d426ee69b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorWeightCalculator.cs b/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorWeightCalculator.cs new file mode 100644 index 00000000..1728cb86 --- /dev/null +++ b/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorWeightCalculator.cs @@ -0,0 +1,116 @@ +#nullable enable +using GeminiLab.Modules.Pet.Personality; +using UnityEngine; + +namespace GeminiLab.Modules.Pet.Behavior +{ + /// + /// 行为权重四倍率(数值规则文档 §1~§6、§9)。全部为纯函数,便于 EditMode 测试。 + /// 最终公式(§25):FinalWeight = BaseWeight × Personality × Energy × Mood × Repeat。 + /// + public static class BehaviorWeightCalculator + { + /// 性格倍率上下限(§2:0.5 ~ 1.5)。 + public const float PersonalityMultiplierMin = 0.5f; + public const float PersonalityMultiplierMax = 1.5f; + + /// 单维性格对倍率的影响系数(§2:×0.25)。 + public const float TraitInfluenceScale = 0.25f; + + /// 精力倍率底数/幅值(§3:0.6 + 0.8 × proximity,范围 0.6~1.4)。 + public const float EnergyMultiplierBase = 0.6f; + public const float EnergyMultiplierSpan = 0.8f; + + /// + /// 性格倍率(§2):Clamp(1 + Σ(Direction × (Trait-50)/50 × 0.25), 0.5, 1.5)。 + /// 内部性格以 -1..1 存储,与文档 0~100 量表的 (Trait-50)/50 完全等价。 + /// + public static float PersonalityMultiplier(BehaviorConfigSO.BehaviorEntry entry, PersonalityVector personality) + { + float sum = 0f; + for (int i = 0; i < entry.PersonalityTags.Length; i++) + { + BehaviorConfigSO.PersonalityTag tag = entry.PersonalityTags[i]; + float direction = tag.Direction >= 0 ? 1f : -1f; + sum += direction * personality.GetTrait(tag.Trait) * TraitInfluenceScale; + } + + return Mathf.Clamp(1f + sum, PersonalityMultiplierMin, PersonalityMultiplierMax); + } + + /// + /// 精力倍率(§3):0.6 + 0.8 × (1 - |Energy - PreferredEnergy| / 100),范围 0.6 ~ 1.4。 + /// + public static float EnergyMultiplier(BehaviorConfigSO.BehaviorEntry entry, float energy) + { + float proximity = 1f - Mathf.Abs(energy - entry.PreferredEnergy) / 100f; + return EnergyMultiplierBase + EnergyMultiplierSpan * proximity; + } + + /// + /// 心情倍率(§6 倍率表):按行为类型 × 心情档位查表。 + /// + public static float MoodMultiplier(BehaviorCategory category, float mood) + { + return MoodMultiplier(category, MoodBandExtensions.FromMood(mood)); + } + + public static float MoodMultiplier(BehaviorCategory category, MoodBand band) + { + return category switch + { + BehaviorCategory.Neutral => 1f, + BehaviorCategory.Rest => band switch + { + MoodBand.Low => 1.4f, + MoodBand.High => 0.7f, + _ => 1f + }, + BehaviorCategory.Quiet => band switch + { + MoodBand.Low => 1.15f, + MoodBand.High => 0.95f, + _ => 1f + }, + BehaviorCategory.Active => band switch + { + MoodBand.Low => 0.7f, + MoodBand.High => 1.25f, + _ => 1f + }, + BehaviorCategory.Social => band switch + { + MoodBand.Low => 0.6f, + MoodBand.High => 1.3f, + _ => 1f + }, + _ => 1f + }; + } + + /// 重复倍率(§9):冷却 0 / 上一个 0.2 / 最近三个 0.6 / 未出现 1.0。 + public static float RepeatMultiplier( + BehaviorConfigSO.BehaviorEntry entry, + BehaviorRuntimeState runtimeState, + float nowSeconds) + { + return runtimeState.RepeatMultiplierFor(entry.BehaviorId, nowSeconds); + } + + /// 最终权重(§25)。任一倍率为 0 时结果为 0(冷却中的行为被排除)。 + public static float FinalWeight( + BehaviorConfigSO.BehaviorEntry entry, + PersonalityVector personality, + float energy, + float mood, + BehaviorRuntimeState runtimeState, + float nowSeconds) + { + return entry.BaseWeight + * PersonalityMultiplier(entry, personality) + * EnergyMultiplier(entry, energy) + * MoodMultiplier(entry.Category, mood) + * RepeatMultiplier(entry, runtimeState, nowSeconds); + } + } +} diff --git a/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorWeightCalculator.cs.meta b/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorWeightCalculator.cs.meta new file mode 100644 index 00000000..d1e11d0f --- /dev/null +++ b/Assets/_Project/Scripts/Modules/Pet/Behavior/BehaviorWeightCalculator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: db835b91da65c3941ac35e9c614b58a0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Modules/Pet/Behavior/MoodBand.cs b/Assets/_Project/Scripts/Modules/Pet/Behavior/MoodBand.cs new file mode 100644 index 00000000..97caa37f --- /dev/null +++ b/Assets/_Project/Scripts/Modules/Pet/Behavior/MoodBand.cs @@ -0,0 +1,31 @@ +#nullable enable +namespace GeminiLab.Modules.Pet.Behavior +{ + /// + /// 心情分档(数值规则文档 §5)。只在运行时计算,不额外保存。 + /// + public enum MoodBand + { + /// 0 ~ 29 + Low = 0, + + /// 30 ~ 69 + Normal = 1, + + /// 70 ~ 100 + High = 2 + } + + public static class MoodBandExtensions + { + public static MoodBand FromMood(float mood) + { + if (mood < 30f) + { + return MoodBand.Low; + } + + return mood >= 70f ? MoodBand.High : MoodBand.Normal; + } + } +} diff --git a/Assets/_Project/Scripts/Modules/Pet/Behavior/MoodBand.cs.meta b/Assets/_Project/Scripts/Modules/Pet/Behavior/MoodBand.cs.meta new file mode 100644 index 00000000..6cdcb1e5 --- /dev/null +++ b/Assets/_Project/Scripts/Modules/Pet/Behavior/MoodBand.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5096640b4efcd3e4292ed88cecc9d6a7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Modules/Pet/Behavior/PetBehaviorIds.cs b/Assets/_Project/Scripts/Modules/Pet/Behavior/PetBehaviorIds.cs new file mode 100644 index 00000000..3e2baf77 --- /dev/null +++ b/Assets/_Project/Scripts/Modules/Pet/Behavior/PetBehaviorIds.cs @@ -0,0 +1,19 @@ +#nullable enable +namespace GeminiLab.Modules.Pet.Behavior +{ + /// + /// 行为 Id 常量(数值规则文档 §7)。行走不是行为,只是状态机中间状态(§8),不在此列。 + /// + public static class PetBehaviorIds + { + public const string Idle = "idle"; + public const string Sleep = "sleep"; + public const string WaterFlowers = "water_flowers"; + public const string ReadBooks = "read_books"; + public const string PlayHarp = "play_harp"; + public const string Journaling = "journaling"; + public const string DoorGaze = "door_gaze"; + public const string PlayGames = "play_games"; + public const string Paint = "paint"; + } +} diff --git a/Assets/_Project/Scripts/Modules/Pet/Behavior/PetBehaviorIds.cs.meta b/Assets/_Project/Scripts/Modules/Pet/Behavior/PetBehaviorIds.cs.meta new file mode 100644 index 00000000..88d8a1a4 --- /dev/null +++ b/Assets/_Project/Scripts/Modules/Pet/Behavior/PetBehaviorIds.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c98756b406659484aabd0b1d0c3791fe +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Modules/Pet/Behavior/PetTrait.cs b/Assets/_Project/Scripts/Modules/Pet/Behavior/PetTrait.cs new file mode 100644 index 00000000..6ae77367 --- /dev/null +++ b/Assets/_Project/Scripts/Modules/Pet/Behavior/PetTrait.cs @@ -0,0 +1,39 @@ +#nullable enable +using GeminiLab.Modules.Pet.Personality; + +namespace GeminiLab.Modules.Pet.Behavior +{ + /// + /// 性格维度枚举,与 字段一一对应。 + /// 用于行为配置里的性格标签(数值规则文档 §2)。 + /// + public enum PetTrait + { + Kindness = 0, + Evilness = 1, + Calmness = 2, + Bravery = 3, + Shyness = 4, + Integrity = 5, + Curiosity = 6 + } + + public static class PetTraitExtensions + { + /// 取性格向量对应维度的值(-1..1,等价于文档 0~100 量表的 (Trait-50)/50)。 + public static float GetTrait(this PersonalityVector vector, PetTrait trait) + { + return trait switch + { + PetTrait.Kindness => vector.Kindness, + PetTrait.Evilness => vector.Evilness, + PetTrait.Calmness => vector.Calmness, + PetTrait.Bravery => vector.Bravery, + PetTrait.Shyness => vector.Shyness, + PetTrait.Integrity => vector.Integrity, + PetTrait.Curiosity => vector.Curiosity, + _ => 0f + }; + } + } +} diff --git a/Assets/_Project/Scripts/Modules/Pet/Behavior/PetTrait.cs.meta b/Assets/_Project/Scripts/Modules/Pet/Behavior/PetTrait.cs.meta new file mode 100644 index 00000000..0c0619f9 --- /dev/null +++ b/Assets/_Project/Scripts/Modules/Pet/Behavior/PetTrait.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 58930410adc84234a89c9834d4386556 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Modules/Pet/Personality/IPersonalityEvolutionService.cs b/Assets/_Project/Scripts/Modules/Pet/IPersonalityEvolutionService.cs similarity index 100% rename from Assets/_Project/Scripts/Modules/Pet/Personality/IPersonalityEvolutionService.cs rename to Assets/_Project/Scripts/Modules/Pet/IPersonalityEvolutionService.cs diff --git a/Assets/_Project/Scripts/Modules/Pet/Personality/IPersonalityEvolutionService.cs.meta b/Assets/_Project/Scripts/Modules/Pet/IPersonalityEvolutionService.cs.meta similarity index 100% rename from Assets/_Project/Scripts/Modules/Pet/Personality/IPersonalityEvolutionService.cs.meta rename to Assets/_Project/Scripts/Modules/Pet/IPersonalityEvolutionService.cs.meta diff --git a/Assets/_Project/Scripts/Modules/Pet/Personality/PersonalityVector.cs b/Assets/_Project/Scripts/Modules/Pet/PersonalityVector.cs similarity index 100% rename from Assets/_Project/Scripts/Modules/Pet/Personality/PersonalityVector.cs rename to Assets/_Project/Scripts/Modules/Pet/PersonalityVector.cs diff --git a/Assets/_Project/Scripts/Modules/Pet/Personality/PersonalityVector.cs.meta b/Assets/_Project/Scripts/Modules/Pet/PersonalityVector.cs.meta similarity index 100% rename from Assets/_Project/Scripts/Modules/Pet/Personality/PersonalityVector.cs.meta rename to Assets/_Project/Scripts/Modules/Pet/PersonalityVector.cs.meta diff --git a/Assets/_Project/Scripts/Modules/Pet/PetClickReactionController.cs b/Assets/_Project/Scripts/Modules/Pet/PetClickReactionController.cs index b4cb29b4..d74999fd 100644 --- a/Assets/_Project/Scripts/Modules/Pet/PetClickReactionController.cs +++ b/Assets/_Project/Scripts/Modules/Pet/PetClickReactionController.cs @@ -39,6 +39,9 @@ public sealed class ClickReactionAnimationOption [SerializeField] private string[] _responseCorpus = PetClickResponseLibrary.CreateDefaultResponses(); [SerializeField] private string[] _expressionLabels = PetClickResponseLibrary.CreateDefaultExpressions(); [SerializeField] private ClickReactionAnimationOption[] _clickReactionAnimations = Array.Empty(); + [Header("交流反馈(数值规则文档 §15-16);留空则该情况走随机语料")] + [SerializeField] private string _needSpaceFeedback = "(想自己待一会儿…)"; + [SerializeField] private string _initiatorExhaustedFeedback = "(它累得不想动了…)"; private const int BubbleSortingOffset = 250; private const float BubbleMinWidth = 3.6f; @@ -156,6 +159,14 @@ public bool TryHandleWorldPoint(Vector2 worldPoint) return true; } + // 交流结算(数值规则文档 §15-18):被点击的宠物是“对方”,另一只是“发起者”。 + // NEED_SPACE / 发起者精力不足时用固定气泡反馈,其余情况走原有随机语料。 + if (TryResolveSocialFeedback(out string socialFeedback)) + { + ShowBubble(socialFeedback); + return true; + } + string expression = ResolveCurrentExpression(); string animatorStateName = ResolveReactionAnimatorStateName(ref expression); string response = PetClickResponseLibrary.GetRandomResponse(_responseCorpus); @@ -338,6 +349,56 @@ private void UpdateBubbleTransform() _bubbleRoot.transform.localScale = new Vector3(inverseX, inverseY, 1f); } + /// + /// 点击交流结算(数值规则文档 §15-18)。 + /// 返回 true 表示应使用 作为气泡文本(NEED_SPACE / 发起者精力不足); + /// 返回 false 表示走原有随机语料(NORMAL / WARM 已静默结算,或场景里只有一只宠)。 + /// + private bool TryResolveSocialFeedback(out string feedback) + { + feedback = string.Empty; + if (_petController == null) + { + return false; + } + + if (!ServiceLocator.TryResolve(out Social.IPetSocialService? social) || social == null || + !ServiceLocator.TryResolve(out IPetRoster? roster) || roster == null) + { + return false; + } + + PetId target = _petController.PetId; + PetId initiator = target == PetId.Angel ? PetId.Devil : PetId.Angel; + if (roster.TryGet(initiator) == null) + { + // 另一只宠不在场(如世界地图单宠场景),不发生交流。 + return false; + } + + // §16:发起者精力不足,交流不发生,给玩家反馈。 + if (!social.CanInitiate(initiator)) + { + if (!string.IsNullOrWhiteSpace(_initiatorExhaustedFeedback)) + { + feedback = _initiatorExhaustedFeedback; + return true; + } + return false; + } + + var outcome = social.TrySocialize(initiator, target); + if (outcome.Initiated && + outcome.ResponseType == Social.SocialResponseType.NeedSpace && + !string.IsNullOrWhiteSpace(_needSpaceFeedback)) + { + feedback = _needSpaceFeedback; + return true; + } + + return false; + } + private string ResolveCurrentExpression() { if (_petController == null) diff --git a/Assets/_Project/Scripts/Modules/Pet/PetController.cs b/Assets/_Project/Scripts/Modules/Pet/PetController.cs index 11a4cd8a..ec498087 100644 --- a/Assets/_Project/Scripts/Modules/Pet/PetController.cs +++ b/Assets/_Project/Scripts/Modules/Pet/PetController.cs @@ -4,6 +4,8 @@ using GeminiLab.Core.FSM; using GeminiLab.Modules.Furniture; using GeminiLab.Modules.Navigation; +using GeminiLab.Modules.Pet.Behavior; +using GeminiLab.Modules.Pet.Personality; using System.Collections.Generic; using UnityEngine; using UnityEngine.Rendering; @@ -69,6 +71,9 @@ public sealed class PetController : MonoBehaviour [SerializeField] private bool _ignoreOtherPetCollisions = true; [SerializeField] private PetInteractionVisualStrategy _interactionVisualStrategy = new(); + [Header("行为权重系统(数值规则文档 §25);为空则回退旧随机漫游")] + [SerializeField] private BehaviorConfigSO? _behaviorConfig; + public PetId PetId => _petId; /// 初始性格矩阵(Inspector 绑定;可为空)。启动时由 PersonalityEvolutionBootstrap 补种读取。 @@ -123,6 +128,35 @@ public sealed class PetController : MonoBehaviour private const float WanderInteractionRetryCooldownSeconds = 1f; private const float WanderInteractionPostSuccessCooldown = 5f; private float _wanderInteractionRetryCooldown; + + // 行为权重系统驱动状态(数值规则文档 §22 BehaviorRuntimeState 的宿主侧)。 + // 配置了 _behaviorConfig 后,非玩家控制路径不再随机漫游,改为 + // "选行为 → 走到绑定交互点 → 执行 → 结算 → 记录 Recent/冷却" 的循环。 + private enum BehaviorPhase + { + IdleWait, + MovingToBehavior + } + + private BehaviorPhase _behaviorPhase = BehaviorPhase.IdleWait; + private float _behaviorIdleTimer = 1f; + private bool _behaviorIdleIsActiveBehavior; + private BehaviorConfigSO.BehaviorEntry? _activeBehaviorEntry; + private AutoInteractionCandidate? _pendingBehaviorCandidate; + private Vector2 _behaviorMoveStartPosition; + private bool _behaviorHasMoveStart; + private int _behaviorFailureCount; + private string _activeBehaviorTargetName = string.Empty; + private FurnitureCategory _activeBehaviorCategory = FurnitureCategory.Unknown; + private FurnitureInteractionType _activeBehaviorInteractionType = FurnitureInteractionType.Unknown; + + /// TickWanderMovement 的单帧移动结果。 + private enum WanderMoveOutcome + { + None, + Arrived, + Abandoned + } private PetRuntimeSnapshotChangedEvent? _lastPublishedSnapshot; private readonly List _hiddenInteractionRenderers = new(); private readonly List _hiddenInteractionRendererStates = new(); @@ -241,6 +275,12 @@ private void Awake() _tickService = new StatTickService(); _stateMachine = PetStateMachineBuilder.Build(_context); _stateMachine.StateChanged += PublishStateChanged; + + // 行为权重系统接管非玩家控制路径的目标选择:RandomWander 不再随机选点。 + if (GetComponent() is { } wanderComponent) + { + wanderComponent.ExternalControlEnabled = _behaviorConfig != null; + } } private void Update() @@ -738,6 +778,9 @@ private void TickPlayerControlled(PetContext context, float deltaTime) RestoreInteractionPose(); } + // 玩家接管同样打断行为驱动循环(未完成的移动/行为按"未完成"处理,不结算不记录)。 + CancelBehaviorDrivenLoop(context); + if (TickPlayerInteraction(context, deltaTime)) { return; @@ -776,6 +819,14 @@ private void TickInactivePlayerControlled(PetContext context, float deltaTime) CancelPlayerInteraction(context); _hasPlayerAnimationDirection = false; + // 配置了行为表:非玩家控制路径由行为权重系统驱动(数值规则文档 §25), + // 不再使用随机漫游 + 碰撞触发交互的旧循环。 + if (_behaviorConfig != null) + { + TickBehaviorDriven(context, deltaTime); + return; + } + if (_wanderInteractionActive) { TickWanderInteraction(context, deltaTime); @@ -787,132 +838,479 @@ private void TickInactivePlayerControlled(PetContext context, float deltaTime) RandomWander? wander = GetComponent(); bool isWandering = false; - if (wander != null && wander.IsMoving) + if (wander != null) + { + TickWanderMovement(context, wander, deltaTime, allowBumpInteraction: true, out isWandering); + } + else + { + SetWanderVelocity(Vector2.zero); + _wanderStuckTimer = 0f; + _hasWanderPrevActualPosition = false; + _hasWanderLastTargetDistance = false; + } + + SetPlayerControlledState( + context, + _wanderInteractionActive + ? InteractingState.StateName + : isWandering ? MovingState.StateName : IdleState.StateName); + context.Advance(deltaTime); + _tickService?.Tick(context, deltaTime); + ResetPlayerControlledRuntime(context); + + if (_wanderInteractionActive) { - isWandering = true; - Vector2 current = GetCurrentWorldPosition(); - Vector2 toTarget = wander.TargetPosition - current; - if (wander.HorizontalOnly) + // 交互启动帧:扣减剩余时长,结束后恢复正常漫游等待。 + _wanderInteractionRemaining -= deltaTime; + if (_wanderInteractionRemaining <= 0f) { - toTarget.y = 0f; + EndWanderInteraction(); + // 同帧切回 Idle,避免 UpdateMovementAnimation 仍以 Interacting 状态 + // 播放一次 Move_Front 兜底动画。 + SetPlayerControlledState(context, IdleState.StateName); } + } + else if (!isWandering) + { + context.RuntimeData.TargetPosition = context.RuntimeData.Position; + context.RuntimeData.TargetReached = true; + } + } - if (toTarget.sqrMagnitude <= wander.ArrivalThreshold * wander.ArrivalThreshold) + /// + /// 漫游/行为移动的统一位移推进:朝 移动, + /// 处理到达、物理卡住与超时放弃。旧漫游路径(allowBumpInteraction=true)在物理卡住时 + /// 会立即尝试最近家具交互;行为驱动路径(false)不允许这种"误触",行为只能由选择器选出。 + /// + private WanderMoveOutcome TickWanderMovement( + PetContext context, + RandomWander wander, + float deltaTime, + bool allowBumpInteraction, + out bool isWandering) + { + isWandering = false; + if (!wander.IsMoving) + { + SetWanderVelocity(Vector2.zero); + _wanderStuckTimer = 0f; + _hasWanderPrevActualPosition = false; + _hasWanderLastTargetDistance = false; + return WanderMoveOutcome.None; + } + + isWandering = true; + Vector2 current = GetCurrentWorldPosition(); + Vector2 toTarget = wander.TargetPosition - current; + if (wander.HorizontalOnly) + { + toTarget.y = 0f; + } + + if (toTarget.sqrMagnitude <= wander.ArrivalThreshold * wander.ArrivalThreshold) + { + SetWanderVelocity(Vector2.zero); + context.RuntimeData.Position = ResolveWanderArrivedPosition(wander); + wander.NotifyArrived(); + isWandering = false; + _wanderStuckTimer = 0f; + _hasWanderPrevActualPosition = false; + _hasWanderLastTargetDistance = false; + return WanderMoveOutcome.Arrived; + } + + float step = wander.MoveSpeed * deltaTime; + + // 检测是否卡住(本帧实际位置 vs 上帧实际位置) + bool stuckThisFrame = false; + if (_hasWanderPrevActualPosition) + { + float actualMove = Vector2.Distance(current, _wanderPrevActualPosition); + stuckThisFrame = step > WanderStuckMoveThreshold && actualMove < WanderStuckMoveThreshold; + } + + // 受阻 = 撞上家具:要么物理上卡住不动,要么沿表面滑动但无法接近漫游目标。 + // 物理卡住帧用于立即触发家具交互;距离未缩短(含滑行)仅用于累计 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 (allowBumpInteraction && stuckThisFrame && _wanderInteractionRetryCooldown <= 0f) + { + if (TryStartWanderInteraction(context)) + { + // 交互已启动:本帧立即进入 Interacting 状态,后续由 TickWanderInteraction 处理。 + isWandering = false; + } + else + { + // 半径内没有可交互家具:短暂冷却避免每帧重试,之后靠 2s 放弃逻辑换目标。 + _wanderInteractionRetryCooldown = WanderInteractionRetryCooldownSeconds; + } + } + + if (!_wanderInteractionActive && _wanderStuckTimer >= WanderStuckTimeout) { + // 卡住 2s 且未触发家具交互(没有可用家具 / 交互冷却中): + // 放弃当前目标,避免一直原地走。 SetWanderVelocity(Vector2.zero); - context.RuntimeData.Position = ResolveWanderArrivedPosition(wander); - wander.NotifyArrived(); + wander.AbandonTarget(); isWandering = false; _wanderStuckTimer = 0f; _hasWanderPrevActualPosition = false; _hasWanderLastTargetDistance = false; + return WanderMoveOutcome.Abandoned; } - else - { - float step = wander.MoveSpeed * deltaTime; + // 受阻但未放弃:保持 velocity,继续朝向目标播放移动动画 + } + else + { + // 未受阻:衰减而非清零。撞上家具后受阻/未受阻帧交替抖动, + // 清零会让计时器永远到不了阈值;真正自由移动时衰减回 0 不会误触发。 + _wanderStuckTimer = Mathf.Max(0f, _wanderStuckTimer - deltaTime * WanderUnblockedDecayFactor); + SetWanderVelocity(toTarget.normalized * wander.MoveSpeed); + } - // 检测是否卡住(本帧实际位置 vs 上帧实际位置) - bool stuckThisFrame = false; - if (_hasWanderPrevActualPosition) - { - float actualMove = Vector2.Distance(current, _wanderPrevActualPosition); - stuckThisFrame = step > WanderStuckMoveThreshold && actualMove < WanderStuckMoveThreshold; - } + _wanderPrevActualPosition = current; + _hasWanderPrevActualPosition = true; + context.RuntimeData.TargetPosition = wander.TargetPosition; + context.RuntimeData.TargetReached = false; + return WanderMoveOutcome.None; + } - // 受阻 = 撞上家具:要么物理上卡住不动,要么沿表面滑动但无法接近漫游目标。 - // 物理卡住帧用于立即触发家具交互;距离未缩短(含滑行)仅用于累计 2s 放弃计时。 - bool blockedThisFrame = stuckThisFrame; - if (!blockedThisFrame && _hasWanderLastTargetDistance) - { - float targetDistance = toTarget.magnitude; - blockedThisFrame = step > WanderStuckMoveThreshold && - targetDistance > _wanderLastTargetDistance - WanderProgressThreshold; - } - _wanderLastTargetDistance = toTarget.magnitude; - _hasWanderLastTargetDistance = true; + // ------------------------------------------------------------------ + // 行为权重驱动循环(数值规则文档 §25): + // 待机/抽取 → 必要时 Moving(§8 移动是中间状态不是行为)→ 执行绑定交互 + // → 结算 Energy/Mood(§12)→ 记录 RecentActions/Cooldown(§9/§10)。 + // ------------------------------------------------------------------ - if (blockedThisFrame) + private void TickBehaviorDriven(PetContext context, float deltaTime) + { + RandomWander? wander = GetComponent(); + if (wander != null && !wander.ExternalControlEnabled) + { + wander.ExternalControlEnabled = true; + } + + if (_wanderInteractionActive) + { + TickBehaviorPerforming(context, deltaTime); + return; + } + + bool isWandering = false; + switch (_behaviorPhase) + { + case BehaviorPhase.IdleWait: + SetWanderVelocity(Vector2.zero); + context.RuntimeData.TargetPosition = context.RuntimeData.Position; + context.RuntimeData.TargetReached = true; + _behaviorIdleTimer -= deltaTime; + if (_behaviorIdleTimer <= 0f) { - _wanderStuckTimer += deltaTime; - // 物理真正卡住(本帧实际无位移)时立即尝试家具交互,不依赖累计计时。 - // 计时方案在「受阻/未受阻」交替抖动下不可靠:实测卡住帧与滑动帧交替, - // 计时器反复被清零,从未稳定到触发阈值。改为用 stuckThisFrame(真实卡住) - // 而非 blockedThisFrame(含沿表面滑行),避免正常绕行时误触发。 - if (stuckThisFrame && _wanderInteractionRetryCooldown <= 0f) + if (_behaviorIdleIsActiveBehavior) { - if (TryStartWanderInteraction(context)) - { - // 交互已启动:本帧立即进入 Interacting 状态,后续由 TickWanderInteraction 处理。 - isWandering = false; - } - else - { - // 半径内没有可交互家具:短暂冷却避免每帧重试,之后靠 2s 放弃逻辑换目标。 - _wanderInteractionRetryCooldown = WanderInteractionRetryCooldownSeconds; - } + // 原地行为(待机)时长结束 = 行为完成。 + CompleteActiveBehavior(context, completedNaturally: true); } - - if (!_wanderInteractionActive && _wanderStuckTimer >= WanderStuckTimeout) + else { - // 卡住 2s 且未触发家具交互(没有可用家具 / 交互冷却中): - // 放弃当前目标,避免一直原地走。 - SetWanderVelocity(Vector2.zero); - wander.AbandonTarget(); - isWandering = false; - _wanderStuckTimer = 0f; - _hasWanderPrevActualPosition = false; - _hasWanderLastTargetDistance = false; + StartNextBehavior(context, wander); } - // 受阻但未放弃:保持 velocity,继续朝向目标播放移动动画 } - else + break; + + case BehaviorPhase.MovingToBehavior: + if (wander == null) { - // 未受阻:衰减而非清零。撞上家具后受阻/未受阻帧交替抖动, - // 清零会让计时器永远到不了阈值;真正自由移动时衰减回 0 不会误触发。 - _wanderStuckTimer = Mathf.Max(0f, _wanderStuckTimer - deltaTime * WanderUnblockedDecayFactor); - SetWanderVelocity(toTarget.normalized * wander.MoveSpeed); + AbortActiveBehavior(context, 1f); + break; } - _wanderPrevActualPosition = current; - _hasWanderPrevActualPosition = true; - context.RuntimeData.TargetPosition = wander.TargetPosition; - context.RuntimeData.TargetReached = false; - } - } - else - { - SetWanderVelocity(Vector2.zero); - _wanderStuckTimer = 0f; - _hasWanderPrevActualPosition = false; - _hasWanderLastTargetDistance = false; + WanderMoveOutcome outcome = TickWanderMovement(context, wander, deltaTime, allowBumpInteraction: false, out isWandering); + if (outcome == WanderMoveOutcome.Arrived) + { + OnBehaviorMoveArrived(context); + } + else if (outcome == WanderMoveOutcome.Abandoned) + { + AbortActiveBehavior(context, 1f); + } + break; } SetPlayerControlledState( context, _wanderInteractionActive - ? InteractingState.StateName + ? ResolvePerformingStateName() : isWandering ? MovingState.StateName : IdleState.StateName); context.Advance(deltaTime); _tickService?.Tick(context, deltaTime); ResetPlayerControlledRuntime(context); - if (_wanderInteractionActive) + if (!isWandering && _behaviorPhase != BehaviorPhase.MovingToBehavior) { - // 交互启动帧:扣减剩余时长,结束后恢复正常漫游等待。 - _wanderInteractionRemaining -= deltaTime; - if (_wanderInteractionRemaining <= 0f) + context.RuntimeData.TargetPosition = context.RuntimeData.Position; + context.RuntimeData.TargetReached = true; + } + } + + /// 行为执行中:沿用漫游交互的可视/pose/动画机制;自然结束时结算并记录。 + private void TickBehaviorPerforming(PetContext context, float deltaTime) + { + SetWanderVelocity(Vector2.zero); + SetPlayerControlledState(context, ResolvePerformingStateName()); + context.Advance(deltaTime); + _tickService?.Tick(context, deltaTime); + ResetPlayerControlledRuntime(context); + context.RuntimeData.TargetReached = true; + + _wanderInteractionRemaining -= deltaTime; + if (_wanderInteractionRemaining > 0f) + { + return; + } + + EndWanderInteraction(); + CompleteActiveBehavior(context, completedNaturally: true); + // 同帧切回 Idle,避免 UpdateMovementAnimation 仍以 Interacting 状态 + // 播放一次 Move_Front 兜底动画。 + SetPlayerControlledState(context, IdleState.StateName); + } + + /// 睡觉行为执行期间状态名报 Sleeping(§11 睡觉停止精力自然衰减),其余报 Interacting。 + private string ResolvePerformingStateName() + { + return _activeBehaviorEntry != null && _activeBehaviorEntry.BehaviorId == PetBehaviorIds.Sleep + ? SleepingState.StateName + : InteractingState.StateName; + } + + /// + /// 抽取下一个行为(§25:候选 → 精力硬规则 → 冷却 → 四倍率 → 加权随机)。 + /// 精力 ≤ 强制阈值时由选择器直接返回睡觉(§4)。 + /// + private void StartNextBehavior(PetContext context, RandomWander? wander) + { + if (_behaviorConfig == null) + { + _behaviorIdleTimer = 1f; + return; + } + + PersonalityVector personality = default; + if (ServiceLocator.TryResolve(out IPersonalityEvolutionService? personalityService) && personalityService is not null) + { + personality = personalityService.GetMatrix(context.RuntimeData.PetId); + } + + BehaviorSelectorInput input = new( + context.RuntimeData.Energy, + context.RuntimeData.Mood, + personality, + context.RuntimeData.RuntimeTimeSeconds, + context.Config.ForcedSleepEnergyThreshold, + context.Config.SleepExcludedAboveEnergy, + context.Config.ActiveBehaviorMinEnergy); + + BehaviorPickResult? pick = BehaviorSelector.Pick( + _behaviorConfig, + input, + context.RuntimeData.BehaviorState, + static () => UnityEngine.Random.value); + + if (pick is null) + { + _behaviorIdleTimer = 1f; + return; + } + + BehaviorConfigSO.BehaviorEntry entry = pick.Value.Entry; + + if (!entry.RequiresMovement) + { + // 原地行为(待机):原地停留一段时长,到时按完成结算。 + _activeBehaviorEntry = entry; + _pendingBehaviorCandidate = null; + _behaviorPhase = BehaviorPhase.IdleWait; + _behaviorIdleIsActiveBehavior = true; + _behaviorIdleTimer = Mathf.Max(0.1f, _behaviorConfig.RollIdleDurationSeconds()); + context.RuntimeData.CurrentBehaviorId = entry.BehaviorId; + context.RuntimeData.BehaviorState.CurrentBehaviorId = entry.BehaviorId; + return; + } + + _behaviorIdleIsActiveBehavior = false; + + if (wander == null || + !TryGetComponent(out PetPlayerFurnitureInteractionController interactionController) || + !interactionController.TryGetInteractionCandidateByType(entry.BindingInteractionType, out AutoInteractionCandidate candidate)) + { + Debug.LogWarning( + $"[PetBehavior] {_petId} 行为 '{entry.BehaviorId}' 找不到 InteractionType={entry.BindingInteractionType} " + + "的交互绑定,按已尝试记录冷却后重新选择。"); + // 防死循环:绑定缺失的行为按已尝试记录,避免每帧重复选中同一个不可执行行为。 + context.RuntimeData.BehaviorState.RecordCompletion( + entry.BehaviorId, context.RuntimeData.RuntimeTimeSeconds, entry.CooldownSeconds); + _behaviorFailureCount++; + _behaviorIdleTimer = Mathf.Min(1f * _behaviorFailureCount, 15f); + return; + } + + _activeBehaviorEntry = entry; + _pendingBehaviorCandidate = candidate; + _behaviorPhase = BehaviorPhase.MovingToBehavior; + _behaviorMoveStartPosition = GetCurrentWorldPosition(); + _behaviorHasMoveStart = true; + context.RuntimeData.CurrentBehaviorId = entry.BehaviorId; + context.RuntimeData.BehaviorState.CurrentBehaviorId = entry.BehaviorId; + wander.SetExternalTarget(candidate.InteractionPoint); + } + + /// 到达行为目标点:结算移动能耗(§8),启动该行为绑定的交互。 + private void OnBehaviorMoveArrived(PetContext context) + { + // §8:单次普通移动 Energy -1,仅当移动距离超过策划配置阈值时结算一次(短距离白走)。 + if (_behaviorHasMoveStart && context.Config.MoveEnergyCost > 0f) + { + float distance = Vector2.Distance(_behaviorMoveStartPosition, context.RuntimeData.Position); + if (distance >= context.Config.MoveEnergyCostMinDistance) { - EndWanderInteraction(); - // 同帧切回 Idle,避免 UpdateMovementAnimation 仍以 Interacting 状态 - // 播放一次 Move_Front 兜底动画。 - SetPlayerControlledState(context, IdleState.StateName); + StatTickService.ApplyEnvironmentalBuff(context.RuntimeData, 0f, -context.Config.MoveEnergyCost); } } - else if (!isWandering) + _behaviorHasMoveStart = false; + + BehaviorConfigSO.BehaviorEntry? entry = _activeBehaviorEntry; + AutoInteractionCandidate? candidate = _pendingBehaviorCandidate; + if (entry == null || candidate == null || !StartBehaviorInteraction(context, entry, candidate.Value)) { - context.RuntimeData.TargetPosition = context.RuntimeData.Position; - context.RuntimeData.TargetReached = true; + AbortActiveBehavior(context, 1f); + } + } + + /// 启动选中行为的绑定交互:复用漫游交互的动画/可视/pose 机制。 + private bool StartBehaviorInteraction( + PetContext context, + BehaviorConfigSO.BehaviorEntry entry, + AutoInteractionCandidate candidate) + { + PetPlayerInteractionRequest request = candidate.Request; + _activeBehaviorTargetName = request.TargetName; + _activeBehaviorCategory = request.Category; + _activeBehaviorInteractionType = request.InteractionType; + + FurnitureInteractionTarget target = new( + request.TargetName, + request.TargetName, + request.Category, + request.InteractionType, + request.InteractionDurationSeconds, + candidate.InteractionPoint, + 0f); + + StartWanderInteractionFromRequest( + context, + target, + request, + hasRequestData: true, + durationOverrideSeconds: entry.OverrideDurationSeconds, + alwaysPublishCompletionEvent: true); + return true; + } + + /// + /// 行为收尾:清理驱动状态;自然完成时按 §12 结算 Energy/Mood、按 §9/§10 记录 + /// 最近行为与冷却(冷却从行为结束时起算),随后进入短暂过渡停顿再抽下一个行为。 + /// + private void CompleteActiveBehavior(PetContext context, bool completedNaturally) + { + BehaviorConfigSO.BehaviorEntry? entry = _activeBehaviorEntry; + _activeBehaviorEntry = null; + _pendingBehaviorCandidate = null; + _behaviorIdleIsActiveBehavior = false; + _behaviorHasMoveStart = false; + _behaviorPhase = BehaviorPhase.IdleWait; + context.RuntimeData.CurrentBehaviorId = string.Empty; + context.RuntimeData.BehaviorState.CurrentBehaviorId = string.Empty; + + if (completedNaturally && entry != null) + { + // §12:行为完成一次性结算(普通行为 -1~-4 精力、+0~+2 心情;睡觉 +25 精力)。 + StatTickService.ApplyEnvironmentalBuff(context.RuntimeData, entry.MoodDelta, entry.EnergyDelta); + context.RuntimeData.BehaviorState.RecordCompletion( + entry.BehaviorId, context.RuntimeData.RuntimeTimeSeconds, entry.CooldownSeconds); + + if (entry.RequiresMovement) + { + context.RuntimeData.LastInteractionFurnitureId = _activeBehaviorTargetName; + context.RuntimeData.LastInteractionSummary = + $"{entry.Label} / {_activeBehaviorCategory} (Mood {FormatSigned(entry.MoodDelta)}, Energy {FormatSigned(entry.EnergyDelta)})"; + } + + _behaviorFailureCount = 0; + _behaviorIdleTimer = _behaviorConfig != null ? _behaviorConfig.TransitionPauseSeconds : 1f; } + else + { + _behaviorIdleTimer = 0.5f; + } + + _activeBehaviorTargetName = string.Empty; + _activeBehaviorCategory = FurnitureCategory.Unknown; + _activeBehaviorInteractionType = FurnitureInteractionType.Unknown; + } + + /// + /// 行为未能执行(绑定缺失/移动被放弃/玩家接管):不结算,但按"已尝试"记录冷却, + /// 避免反复选中同一个不可达行为;连续失败时退避拉长重试间隔。 + /// + private void AbortActiveBehavior(PetContext context, float idleSeconds) + { + BehaviorConfigSO.BehaviorEntry? entry = _activeBehaviorEntry; + CompleteActiveBehavior(context, completedNaturally: false); + if (entry != null) + { + context.RuntimeData.BehaviorState.RecordCompletion( + entry.BehaviorId, context.RuntimeData.RuntimeTimeSeconds, entry.CooldownSeconds); + } + + _behaviorFailureCount++; + _behaviorIdleTimer = Mathf.Min(idleSeconds * _behaviorFailureCount, 15f); + } + + /// 玩家接管时打断行为驱动循环:移动中放弃目标,未完成的行为不结算不记录。 + private void CancelBehaviorDrivenLoop(PetContext context) + { + if (_behaviorConfig == null) + { + return; + } + + if (_activeBehaviorEntry == null && _behaviorPhase == BehaviorPhase.IdleWait) + { + return; + } + + if (_behaviorPhase == BehaviorPhase.MovingToBehavior && + GetComponent() is { } wander) + { + wander.AbandonTarget(); + } + + CompleteActiveBehavior(context, completedNaturally: false); } private void TickWanderInteraction(PetContext context, float deltaTime) @@ -981,8 +1379,30 @@ private bool TryStartWanderInteraction(PetContext context) return false; } + StartWanderInteractionFromRequest(context, target, request, hasRequestData); + return true; + } + + /// + /// 启动一次自动交互:动画状态解析、可视/排序/pose 覆盖、时长与 buff/事件广播。 + /// 旧漫游碰撞触发与行为权重驱动共用。durationOverrideSeconds > 0 时覆盖绑定时长 + /// (例如睡觉行为需要比绑定动画更久的持续时间)。alwaysPublishCompletionEvent 用于 + /// 行为驱动路径——性格演化(文档 §2 家具交互规则)要求行为交互总是广播完成事件; + /// 旧漫游路径保持原样,只在消耗到家具 buff 时广播。 + /// + private void StartWanderInteractionFromRequest( + PetContext context, + FurnitureInteractionTarget target, + PetPlayerInteractionRequest request, + bool hasRequestData, + float durationOverrideSeconds = 0f, + bool alwaysPublishCompletionEvent = false) + { _wanderInteractionActive = true; - _wanderInteractionRemaining = Mathf.Max(0.1f, target.InteractionDurationSeconds); + float durationSeconds = durationOverrideSeconds > 0f + ? durationOverrideSeconds + : target.InteractionDurationSeconds; + _wanderInteractionRemaining = Mathf.Max(0.1f, durationSeconds); if (hasRequestData) { // 绑定命中:显式动画状态名优先(如 Interact_PlayGame);缺省时用变体映射兜底(如 "devil sleep" → Interact_DevilSleep)。 @@ -1022,22 +1442,26 @@ private bool TryStartWanderInteraction(PetContext context) $"[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)) + // 与 InteractingState.Enter 一致:应用环境加成(仅当通过 _placedFurniture 命中时)。 + EnvironmentalBuff buff = default; + bool buffConsumed = context.FurnitureService is not null && + context.FurnitureService.TryConsumeInteractionBuff(target.FurnitureId, out buff); + if (buffConsumed) { 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)})"; + } + + if (buffConsumed || alwaysPublishCompletionEvent) + { context.EventBus?.Publish(new PetInteractionCompletedEvent( context.RuntimeData.PetId, target.FurnitureId, target.Category, target.InteractionType)); } - - return true; } private bool TryFindNearbyFurniture(PetContext context, out FurnitureInteractionTarget target) @@ -2233,7 +2657,8 @@ private void PublishSnapshotIfChanged(PetContext context) runtime.IsTraveling, runtime.LastInteractionFurnitureId, runtime.LastInteractionSummary, - petId: runtime.PetId); + petId: runtime.PetId, + currentBehaviorId: runtime.CurrentBehaviorId); if (_lastPublishedSnapshot.HasValue && AreSnapshotsEquivalent(_lastPublishedSnapshot.Value, snapshot)) { @@ -2257,7 +2682,8 @@ private static bool AreSnapshotsEquivalent(PetRuntimeSnapshotChangedEvent previo previous.TargetFurnitureInteractionType == current.TargetFurnitureInteractionType && previous.IsTraveling == current.IsTraveling && previous.LastInteractionFurnitureId == current.LastInteractionFurnitureId && - previous.LastInteractionSummary == current.LastInteractionSummary; + previous.LastInteractionSummary == current.LastInteractionSummary && + previous.CurrentBehaviorId == current.CurrentBehaviorId; } private void EnsureAnimatorBinding() diff --git a/Assets/_Project/Scripts/Modules/Pet/PetPlayerFurnitureInteractionController.cs b/Assets/_Project/Scripts/Modules/Pet/PetPlayerFurnitureInteractionController.cs index f06e7e8e..8bd89d7c 100644 --- a/Assets/_Project/Scripts/Modules/Pet/PetPlayerFurnitureInteractionController.cs +++ b/Assets/_Project/Scripts/Modules/Pet/PetPlayerFurnitureInteractionController.cs @@ -306,6 +306,41 @@ public bool TryGetAutoInteractionCandidate(out AutoInteractionCandidate candidat return true; } + /// + /// 行为权重驱动专用:按 精确查找绑定 + /// (不做距离检查——行为系统会先走到交互点再启动交互)。 + /// 与 返回同样的完整请求与固定交互点。 + /// + public bool TryGetInteractionCandidateByType(FurnitureInteractionType interactionType, out AutoInteractionCandidate candidate) + { + candidate = default; + if (!_enableInteraction || !isActiveAndEnabled) + { + return false; + } + + for (int i = 0; i < _bindings.Length; i++) + { + InteractionBinding binding = _bindings[i]; + if (binding.InteractionType != interactionType) + { + continue; + } + + if (!TryResolveInteractionPoint(binding, out Vector2 interactionPoint, out string targetName, out GameObject? targetObject)) + { + continue; + } + + candidate = new AutoInteractionCandidate( + BuildInteractionRequest(binding, targetName, targetObject), + interactionPoint); + return true; + } + + return false; + } + private bool TryEnsurePetController(out PetController? petController) { if (_petController == null) diff --git a/Assets/_Project/Scripts/Modules/Pet/PetRuntimeBootstrap.cs b/Assets/_Project/Scripts/Modules/Pet/PetRuntimeBootstrap.cs index 299203e8..d8a13645 100644 --- a/Assets/_Project/Scripts/Modules/Pet/PetRuntimeBootstrap.cs +++ b/Assets/_Project/Scripts/Modules/Pet/PetRuntimeBootstrap.cs @@ -45,6 +45,19 @@ private static void EnsurePetServices() Debug.Log("[PetRuntimeBootstrap] PetRuntimeSaveService registered."); } + // 交流系统(数值规则文档 §14-19):对子亲密度 + 点击交流结算。 + if (!ServiceLocator.TryResolve(out Social.IPetSocialService? socialService) || socialService is null) + { + ServiceLocator.TryResolve(out Core.Events.EventBus? eventBus); + var social = new Social.PetSocialService(roster!, eventBus); + ServiceLocator.Register(social); + if (registry is not null && registry.TryGet(social.Key) is null) + { + registry.Register(social); + } + Debug.Log("[PetRuntimeBootstrap] PetSocialService registered."); + } + EnsurePlaceholderPet(); } diff --git a/Assets/_Project/Scripts/Modules/Pet/PetRuntimeData.cs b/Assets/_Project/Scripts/Modules/Pet/PetRuntimeData.cs index d714a766..5dae0986 100644 --- a/Assets/_Project/Scripts/Modules/Pet/PetRuntimeData.cs +++ b/Assets/_Project/Scripts/Modules/Pet/PetRuntimeData.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using GeminiLab.Modules.Furniture; +using GeminiLab.Modules.Pet.Behavior; using UnityEngine; namespace GeminiLab.Modules.Pet @@ -16,7 +17,7 @@ public sealed class PetRuntimeData public PetId PetId = PetId.Angel; [Range(0f, 100f)] - public float Mood = 60f; + public float Mood = 50f; [Range(0f, 100f)] public float Energy = 100f; @@ -31,8 +32,17 @@ public sealed class PetRuntimeData public float RuntimeTimeSeconds; + /// 心情回归中性的累计计时(§13),秒。 + public float NeutralReturnTimerSeconds; + public string CurrentState = "None"; + /// 当前正在执行的行为 Id(行为权重系统,§25);无行为时为 Empty。 + public string CurrentBehaviorId = string.Empty; + + /// 行为运行态:当前行为、最近 3 个行为、冷却表(§22 BehaviorRuntimeState)。仅运行期,不持久化。 + public BehaviorRuntimeState BehaviorState = new(); + public bool WorkRequested; public Vector2 Position; diff --git a/Assets/_Project/Scripts/Modules/Pet/PetRuntimeSaveService.cs b/Assets/_Project/Scripts/Modules/Pet/PetRuntimeSaveService.cs index 64784a42..559cad86 100644 --- a/Assets/_Project/Scripts/Modules/Pet/PetRuntimeSaveService.cs +++ b/Assets/_Project/Scripts/Modules/Pet/PetRuntimeSaveService.cs @@ -8,14 +8,29 @@ namespace GeminiLab.Modules.Pet /// /// 让 Pet 的两只运行态数据随 SaveBundle 走。 /// 实现 ,由 PetRuntimeBootstrap 注册。 + /// + /// Payload v2(数值规则文档 §20 离线规则): + /// - 附带保存时刻 savedAtUtcTicks + /// - 恢复时按离线时长让心情向 50 轻量回归:每离线 5 分钟回归 1 点(与在线 §13 同节奏), + /// 最多修正 6 点(即最多模拟 30 分钟);不越过 50 + /// - 精力离线不衰减,原样恢复 + /// - v1 存档(无 savedAtUtcTicks)兼容恢复,不做离线回归 /// public sealed class PetRuntimeSaveService : IPersistentService { + /// 离线心情回归速率(秒/点),与在线 §13 的 5 分钟/点一致。 + public const float OfflineMoodRegressionSecondsPerPoint = 300f; + + /// 离线心情回归最大修正点数(§20:最多模拟 30 分钟)。 + public const int OfflineMoodRegressionMaxPoints = 6; + private readonly IPetRoster _roster; + private readonly Func _utcNow; - public PetRuntimeSaveService(IPetRoster roster) + public PetRuntimeSaveService(IPetRoster roster, Func? utcNow = null) { _roster = roster ?? throw new ArgumentNullException(nameof(roster)); + _utcNow = utcNow ?? (() => DateTime.UtcNow); } public string Key => "pet_runtime"; @@ -39,6 +54,7 @@ private struct SavePayload { public int version; public Entry[] entries; + public long savedAtUtcTicks; // v2 新增;v1 存档中为 0 } public string CaptureJson() @@ -67,7 +83,12 @@ public string CaptureJson() lastInteractionSummary = d.LastInteractionSummary ?? string.Empty }; } - return JsonUtility.ToJson(new SavePayload { version = 1, entries = entries }); + return JsonUtility.ToJson(new SavePayload + { + version = 2, + entries = entries, + savedAtUtcTicks = _utcNow().Ticks + }); } public bool RestoreJson(string json) @@ -78,13 +99,24 @@ public bool RestoreJson(string json) var payload = JsonUtility.FromJson(json); if (payload.entries == null) return true; + // §20:v2 起按离线时长计算心情回归点数;v1 存档无时间戳,不回归。 + int regressionPoints = 0; + if (payload.version >= 2 && payload.savedAtUtcTicks > 0L) + { + var savedAtUtc = new DateTime(payload.savedAtUtcTicks, DateTimeKind.Utc); + double offlineSeconds = Math.Max(0.0, (_utcNow() - savedAtUtc).TotalSeconds); + regressionPoints = Math.Min( + OfflineMoodRegressionMaxPoints, + (int)(offlineSeconds / OfflineMoodRegressionSecondsPerPoint)); + } + foreach (var e in payload.entries) { var petId = (PetId)e.petId; var d = _roster.TryGet(petId); if (d == null) continue; - d.Mood = e.mood; - d.Energy = e.energy; + d.Mood = ApplyOfflineMoodRegression(e.mood, regressionPoints); + d.Energy = e.energy; // §20:离线不进行自然精力衰减,原样恢复。 d.Satiety = e.satiety; d.RuntimeTimeSeconds = e.runtimeTime; d.TravelCompletedCount = e.travelCompletedCount; @@ -99,5 +131,15 @@ public bool RestoreJson(string json) return false; } } + + /// 让心情向 50 回归 点,不越过 50。 + private static float ApplyOfflineMoodRegression(float mood, int points) + { + if (points <= 0) return mood; + const float neutral = 50f; + return mood > neutral + ? Mathf.Max(neutral, mood - points) + : Mathf.Min(neutral, mood + points); + } } } diff --git a/Assets/_Project/Scripts/Modules/Pet/PetRuntimeSnapshotChangedEvent.cs b/Assets/_Project/Scripts/Modules/Pet/PetRuntimeSnapshotChangedEvent.cs index 5db3065f..d3e24504 100644 --- a/Assets/_Project/Scripts/Modules/Pet/PetRuntimeSnapshotChangedEvent.cs +++ b/Assets/_Project/Scripts/Modules/Pet/PetRuntimeSnapshotChangedEvent.cs @@ -21,7 +21,8 @@ public PetRuntimeSnapshotChangedEvent( bool isTraveling, string lastInteractionFurnitureId, string lastInteractionSummary, - PetId petId = PetId.Angel) + PetId petId = PetId.Angel, + string currentBehaviorId = "") { CurrentState = currentState; Mood = mood; @@ -36,6 +37,7 @@ public PetRuntimeSnapshotChangedEvent( LastInteractionFurnitureId = lastInteractionFurnitureId; LastInteractionSummary = lastInteractionSummary; PetId = petId; + CurrentBehaviorId = currentBehaviorId; } public string CurrentState { get; } @@ -64,5 +66,8 @@ public PetRuntimeSnapshotChangedEvent( /// 发布方所属宠物;默认 Angel 以兼容旧构造。 public PetId PetId { get; } + + /// 当前正在执行的行为 Id(行为权重系统);无行为时为 Empty。 + public string CurrentBehaviorId { get; } } } diff --git a/Assets/_Project/Scripts/Modules/Pet/PetStateValueSO.cs b/Assets/_Project/Scripts/Modules/Pet/PetStateValueSO.cs index f6155993..5c1a6b4f 100644 --- a/Assets/_Project/Scripts/Modules/Pet/PetStateValueSO.cs +++ b/Assets/_Project/Scripts/Modules/Pet/PetStateValueSO.cs @@ -4,44 +4,63 @@ namespace GeminiLab.Modules.Pet { /// - /// Configurable phase-1 pet stat thresholds and defaults. + /// Configurable pet stat thresholds and defaults. + /// 数值口径见 docs/数值规则_策划补齐版.md: + /// - 精力自然消耗 §11;心情回归 §13;精力硬规则 §4;移动能耗 §8。 + /// 旧版 FSM 阈值(Sleep*/BedSeek*/LeisureSeek*)仅保留给非公寓场景(WorldMap)的 + /// FSM 寻路分支使用,公寓桌宠的行为由 BehaviorConfigSO 权重系统驱动。 /// [CreateAssetMenu(menuName = "GeminiLab/Pet/PetStateValueConfig", fileName = "PetStateValueConfig")] public sealed class PetStateValueSO : ScriptableObject { - [Header("Initial")] - [Range(0f, 100f)] public float InitialMood = 60f; + [Header("Initial(§23)")] + [Range(0f, 100f)] public float InitialMood = 50f; [Range(0f, 100f)] public float InitialEnergy = 100f; [Range(0f, 100f)] public float InitialSatiety = 75f; - [Header("State Thresholds")] - [Range(0f, 100f)] public float SleepEnterEnergyThreshold = 25f; - [Range(0f, 100f)] public float SleepExitEnergyThreshold = 70f; + [Header("Energy Drain(§11)")] + [Tooltip("清醒状态下每现实多少分钟 Energy -1(文档推荐 3 分钟;睡觉期间不自然衰减)")] + [Min(0.01f)] public float AwakeEnergyDrainMinutesPerPoint = 3f; - [Header("Autonomous Targeting")] - [Range(0f, 100f)] public float DaytimeBedSeekEnergyThreshold = 15f; - [Range(0f, 100f)] public float NighttimeBedSeekEnergyThreshold = 40f; - [Range(0f, 100f)] public float LeisureSeekMoodThreshold = 85f; - [Range(0, 23)] public int NightHourStart = 22; - [Range(0, 23)] public int NightHourEnd = 6; + /// 折算成每秒消耗,供 Tick 使用。 + public float AwakeEnergyDrainPerRealMinute => 1f / AwakeEnergyDrainMinutesPerPoint; + + [Header("Mood Neutral Return(§13)")] + [Tooltip("每隔多少现实秒心情向中性值回归 1 点(文档:5 分钟)")] + [Min(1f)] public float MoodNeutralReturnIntervalSeconds = 300f; + [Tooltip("心情回归的中性目标值(文档:50)")] + [Range(0f, 100f)] public float MoodNeutralTarget = 50f; + + [Header("Satiety(文档未定义,保留既有缓慢衰减)")] + [Min(0f)] public float SatietyDecayPerSecond = 0.005f; - [Header("Tick Speeds (Per Second)")] - [Min(0f)] public float AwakeEnergyDecayPerSecond = 8f; - [Min(0f)] public float SleepingEnergyRecoveryPerSecond = 15f; - [Min(0f)] public float MoodRecoveryPerSecond = 4f; - [Tooltip("饱食度每秒自然下降(被饥饿曲线驱动)")] - [Min(0f)] public float SatietyDecayPerSecond = 1.2f; - [Tooltip("Hunger(饱食 <=30)时心情每秒扣除")] - [Min(0f)] public float LowSatietyMoodPenaltyPerSecond = 0.6f; - [Tooltip("Exhausted(精力 <=20)时心情每秒扣除")] - [Min(0f)] public float LowEnergyMoodPenaltyPerSecond = 0.8f; - [Tooltip("饱食或精力不足时 MoodRecoveryPerSecond 的倍率;0 = 完全停止恢复")] - [Range(0f, 1f)] public float MoodRecoveryPenaltyFactor = 0.2f; + [Header("Energy Hard Rules(§4)")] + [Tooltip("精力 ≤ 该值时强制进入睡觉(绕过权重与冷却)")] + [Range(0f, 100f)] public float ForcedSleepEnergyThreshold = 5f; + [Tooltip("精力 > 该值时睡觉不进入候选池")] + [Range(0f, 100f)] public float SleepExcludedAboveEnergy = 70f; + [Tooltip("精力 < 该值时禁止活跃行为(BehaviorTag.Active)与主动交流")] + [Range(0f, 100f)] public float ActiveBehaviorMinEnergy = 10f; + + [Header("Movement Cost(§8)")] + [Tooltip("单次普通移动的精力消耗(移动距离超过阈值时结算一次,不逐帧扣)")] + [Min(0f)] public float MoveEnergyCost = 1f; + [Tooltip("移动距离超过该值(世界单位)才结算移动能耗;短距离白走")] + [Min(0f)] public float MoveEnergyCostMinDistance = 1.5f; [Header("Command Penalty")] [Range(0f, 100f)] public float ForceWakeMoodPenalty = 30f; [Header("Work")] [Min(1f)] public float WorkStateTimeoutSeconds = 30f; + + [Header("Legacy FSM Thresholds(仅 WorldMap FSM 寻路分支使用)")] + [Range(0f, 100f)] public float SleepEnterEnergyThreshold = 25f; + [Range(0f, 100f)] public float SleepExitEnergyThreshold = 70f; + [Range(0f, 100f)] public float DaytimeBedSeekEnergyThreshold = 15f; + [Range(0f, 100f)] public float NighttimeBedSeekEnergyThreshold = 40f; + [Range(0f, 100f)] public float LeisureSeekMoodThreshold = 85f; + [Range(0, 23)] public int NightHourStart = 22; + [Range(0, 23)] public int NightHourEnd = 6; } } diff --git a/Assets/_Project/Scripts/Modules/Pet/README.md b/Assets/_Project/Scripts/Modules/Pet/README.md index e8d9bf2e..137ce206 100644 --- a/Assets/_Project/Scripts/Modules/Pet/README.md +++ b/Assets/_Project/Scripts/Modules/Pet/README.md @@ -1,37 +1,54 @@ -# Modules/Pet/ — 宠物养成与 FSM 绑定 +# Modules/Pet/ — 宠物养成与行为驱动 ## 文件夹职责 -负责**宠物实体**的运行时表现:状态值/性格值数据、HFSM 状态节点宿主、需求权重评估与对外事件广播。 +负责**宠物实体**的运行时表现:状态值/性格值数据、行为权重决策(公寓桌宠)、HFSM 状态节点宿主(WorldMap)、交流系统与对外事件广播。 -## 核心文件/类(预估) +## 数值口径(docs/数值规则_策划补齐版.md) +公寓场景的双宠自主行为由**行为权重系统**驱动(§25): + +```text +FinalWeight = BaseWeight × PersonalityMultiplier × EnergyMultiplier × MoodMultiplier × RepeatMultiplier +``` + +- **性格倍率(§2)**:`Clamp(1 + Σ(Direction × Trait × 0.25), 0.5, 1.5)`。内部性格以 **-1..1** 存储,与文档 0~100 量表的 `(Trait-50)/50` 完全等价(如文档 Integrity=88 → 内部 0.76)。 +- **精力倍率(§3)**:`0.6 + 0.8 × (1 - |Energy - PreferredEnergy|/100)`(0.6~1.4)。 +- **精力硬规则(§4)**:>70 不睡觉;<10 禁活跃行为(`BehaviorTag.Active`);≤5 强制睡觉(绕过冷却与权重,避免死锁)。 +- **心情分档/倍率(§5/§6)**:<30 低 / 30~69 中 / ≥70 高,按 `BehaviorCategory` 查表。 +- **重复倍率(§9/§10)**:冷却 0 > 上一个 0.2 > 最近三个 0.6 > 1.0;冷却从行为**结束**时起算。 +- **精力自然变化(§11)**:清醒每现实 3 分钟 -1;睡觉期间不自然衰减(恢复由睡觉行为完成时 +25 一次性结算,§12)。 +- **心情回归(§13)**:每 5 分钟向 50 回归 1 点(不越过)。 +- **离线规则(§20)**:精力不衰减;心情按离线时长向 50 回归(每 5 分钟 1 点,最多 6 点)。 + +## 核心文件/类 | 文件 | 说明 | | :--- | :--- | -| `PetService.cs` | 对外门面(`IPetService`),提供查询状态/下发指令 API。 | -| `PetController.cs` | `MonoBehaviour` 运行时宿主,驱动 FSM Tick 与 2D 动画,并确保存在 `SpriteRenderer` / `Animator` / `Rigidbody2D` / `CapsuleCollider2D`。 | -| `PetContext.cs` | FSM 上下文数据包(引用 StateValues、Personality、2D NavAgent 桥接)。 | -| `States/IdleState.cs` | 空闲态,扫描家具需求权重。 | -| `States/MovingState.cs` | 调用 Navigation 模块寻路;位置推进统一通过 `Rigidbody2D.MovePosition`,并根据速度方向翻转 Sprite (`SpriteRenderer.flipX`)。 | -| `States/InteractingState.cs` | 播放 2D Animator 指定家具交互动画 + 状态值增减。 | -| `States/WorkingState.cs` | 被 Gateway 锁定,等待"任务完成"事件。 | -| `States/SleepingState.cs` | 精力耗尽或指令触发,强制唤醒触发惩罚。 | -| `Data/PetStateValues.cs` | 心情/饱食/精力/经验值结构。 | -| `Data/PersonalityMatrix.cs` | 正直/冷静/邪恶/善良/勇敢/懦弱/害羞 7 维。 | -| `Services/StatTickService.cs` | 每秒衰减/恢复数值。 | -| `Events/PetEvents.cs` | `PetStateChangedEvent`、`PetMoodChangedEvent` 等。 | +| `PetController.cs` | `MonoBehaviour` 运行时宿主。玩家可控宠走输入链;非玩家可控宠在配置了 `_behaviorConfig` 时走**行为驱动循环**(待机/抽取 → Moving → 执行绑定交互 → §12 结算 → §9/§10 记录),否则回退旧随机漫游;无输入控制器的宠(WorldMap)走 FSM。 | +| `Behavior/BehaviorConfigSO.cs` | 每只宠一张行为表:BaseWeight/Category/性格标签/偏好精力/结算增量/冷却/绑定 InteractionType。资产位于 `_Project/ScriptableObjects/PetConfig/BehaviorConfig_{Angel,Devil}.asset`。 | +| `Behavior/BehaviorWeightCalculator.cs` | 四倍率纯函数(§2/§3/§6/§9)。 | +| `Behavior/BehaviorSelector.cs` | 候选 → 精力硬规则 → 冷却 → 四倍率 → 归一化加权随机;强制睡觉与全零兜底待机。 | +| `Behavior/BehaviorRuntimeState.cs` | 当前行为、最近 3 个行为、冷却表(运行期,不持久化)。 | +| `Social/PetSocialService.cs` | 交流系统(§14-19):对子亲密度(初始 30)、ResponseType(NEED_SPACE/WARM/NORMAL)、结算表、300s 防刷冷却、`IPersistentService`(Key=`pet_social`)。点击宠物触发:被点击者为“对方”,另一只为“发起者”(`PetClickReactionController`)。 | +| `PetRuntimeSaveService.cs` | 双宠运行态存档(Key=`pet_runtime`,v2 附保存时刻,恢复时按 §20 做离线心情回归)。 | +| `PetStateValueSO.cs` | 数值阈值配置(§4/§8/§11/§13 + WorldMap FSM 旧阈值)。 | +| `Personality/PersonalityEvolutionService.cs` | 7 维性格运行态演化(-1..1)。初始值资产 `PersonalityMatrix_{Angel,Devil}.asset` 已按 §23 配置。 | +| `StatTickService.cs` | 每秒数值 Tick(§11/§13 + 饱食缓慢衰减)。 | +| `PetRuntimeBootstrap.cs` | 场景加载后兜底注册 Roster / SaveService / SocialService。 | ## 依赖关系 -- **依赖**:`Core`(FSM、EventBus、ServiceLocator)、`Navigation`(仅接口 `INavigationService`)。 +- **依赖**:`Core`(FSM、EventBus、ServiceLocator)、`Navigation`(仅接口 `INavigationService`)、`Furniture`。 - **被依赖**:`Gateway`(读取状态值组装 Prompt)、`Furniture`(交互回调写入 StateValues)、`UI`(订阅状态变更事件)、`Travel`、`Persistence`。 -- asmdef:`GeminiLab.Modules.Pet`,仅引用 `GeminiLab.Core`、`GeminiLab.Modules.Navigation.Abstractions`。 +- asmdef:`GeminiLab.Modules.Pet`,仅引用 `GeminiLab.Core`、`GeminiLab.Modules.Navigation.Abstractions`、`GeminiLab.Modules.Furniture`。 ## 代码规范/注意事项 -1. **所有状态值变更必须走 `StatTickService`**,不允许在其他地方直接赋值 `StateValues.Mood = xxx`,否则事件无法广播。 +1. **所有状态值变更必须走 `StatTickService`**(`Tick` / `ApplyEnvironmentalBuff` / `ApplySatietyDelta`),不允许在其他地方直接赋值,否则钳制与事件无法保证。 2. 性格值演化遵循"事件 → 累积 → 阶跃"三段式:记录事件、累加权重、达到阈值再改变性格等级。 3. `PetController` **禁止** `using UnityEngine.UI`;UI 只能订阅事件。 -4. FSM 状态转移条件集中配置于 `PetStateMachineBuilder.cs`,便于可视化审查。 -5. 强制唤醒惩罚、打断权重等"魔法数字"必须以 ScriptableObject 暴露到 `_Project/ScriptableObjects/PetConfig/`。 +4. 行为表的"魔法数字"全部在 `BehaviorConfigSO` / `PetStateValueSO` 资产上调整,不写死在代码里;数值口径以策划文档章节号注释为准。 +5. 行为→家具交互走宠物 prefab 上 `PetPlayerFurnitureInteractionController` 的手动绑定(`BindingInteractionType` 唯一对应一条绑定);公寓 `_placedFurniture` 为空,不要依赖 FurnitureService 查询。 6. 2D 渲染顺序:宠物根节点挂 `SortingGroup`,`sortingLayer = "Pet"`;`sortingOrder` 按 Y 轴动态刷新以实现与家具的正确遮挡。 7. 所有物理位移走 `Rigidbody2D` (`bodyType = Dynamic`, `gravityScale = 0`, `freezeRotation = Z`),严禁直接修改 `transform.position` 作为常规移动链,否则 NavAgent 与 Physics2D 都会错位。 8. 当前玩家控制链与运行时位置回写都应通过 `PetController` 内部统一的物理位置入口推进,不允许在别的脚本里绕过刚体直接推角色。 9. 桌宠活动范围当前采用场景内独立的 `PetMovementBounds`(`BoxCollider2D` + `isTrigger = true`)表示矩形可行动区域;`PetController` 在移动推进前会按这个矩形范围做位置钳制,后续只需在 Inspector 里调整该 `BoxCollider2D` 的位置和尺寸即可。 +10. 行为驱动模式下 `RandomWander.ExternalControlEnabled = true`:漫游组件只负责移动执行,目标由行为驱动循环通过 `SetExternalTarget` 下发;旧随机选目标逻辑被跳过。 + diff --git a/Assets/_Project/Scripts/Modules/Pet/RandomWander.cs b/Assets/_Project/Scripts/Modules/Pet/RandomWander.cs index 5f65aa07..79477eac 100644 --- a/Assets/_Project/Scripts/Modules/Pet/RandomWander.cs +++ b/Assets/_Project/Scripts/Modules/Pet/RandomWander.cs @@ -38,6 +38,20 @@ public sealed class RandomWander : MonoBehaviour public float MoveSpeed => _moveSpeed; public float ArrivalThreshold => _arrivalThreshold; public bool HorizontalOnly => _horizontalOnly; + + /// + /// 外部(行为权重系统)接管目标选择时置 true:Update 不再随机选点, + /// 移动目标只通过 设置。 + /// + public bool ExternalControlEnabled { get; set; } + + /// 行为驱动模式:设置明确的移动目标并立即进入移动状态。 + public void SetExternalTarget(Vector2 target) + { + _targetPosition = target; + _isMoving = true; + _waitTimer = 0f; + } public float HorizontalBaselineY { get @@ -96,6 +110,12 @@ private void Update() return; } + // 行为权重系统接管:目标由 PetController 的行为驱动循环设置,这里不随机选点。 + if (ExternalControlEnabled) + { + return; + } + if (_isMoving) return; // 移动中,等 PetController 通知到达 _waitTimer -= Time.deltaTime; diff --git a/Assets/_Project/Scripts/Modules/Pet/Social.meta b/Assets/_Project/Scripts/Modules/Pet/Social.meta new file mode 100644 index 00000000..697bf489 --- /dev/null +++ b/Assets/_Project/Scripts/Modules/Pet/Social.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2d94f12e458293d458dd3905dc346be4 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Modules/Pet/Social/IPetSocialService.cs b/Assets/_Project/Scripts/Modules/Pet/Social/IPetSocialService.cs new file mode 100644 index 00000000..74e4d339 --- /dev/null +++ b/Assets/_Project/Scripts/Modules/Pet/Social/IPetSocialService.cs @@ -0,0 +1,41 @@ +#nullable enable +using System; + +namespace GeminiLab.Modules.Pet.Social +{ + /// + /// 双宠物交流系统(数值规则文档 §14-19)。 + /// 亲密度为“对子”级数据(Angel ↔ Devil 共享一个值),初始 30,无自然衰减。 + /// + public interface IPetSocialService + { + /// 当前亲密度 0-100(§14)。 + float Friendship { get; } + + /// 亲密度阶段标签:疏远/普通/熟悉/亲近/高亲密(§14)。 + string FriendshipStageLabel { get; } + + /// 发起者精力是否足够发起交流(§16:Initiator.Energy < 10 禁止发起)。 + bool CanInitiate(PetId initiator); + + /// 仅计算对方回应类型,不产生任何数值变化(§15)。 + SocialResponseType ResolveResponseType(PetId target); + + /// + /// 尝试发起一次交流并完整结算(§15-18): + /// 发起者精力不足时交流不发生(Initiated=false); + /// 否则按回应类型结算双方精力/心情,亲密度受 300s 防刷冷却限制。 + /// 玩家选择“不打扰了”时不要调用本方法(所有变化为 0)。 + /// + PetSocialOutcome TrySocialize(PetId initiator, PetId target); + + /// 特殊事件直接增减亲密度(§19),不受防刷冷却限制,也不刷新冷却时间。 + void ApplySpecialEventFriendship(float delta); + + /// 交流完成结算后广播(交流未发生时不触发)。 + event Action? SocialInteractionCompleted; + + /// 亲密度实际变化后触发(含特殊事件);参数为变化后的值。 + event Action? FriendshipChanged; + } +} diff --git a/Assets/_Project/Scripts/Modules/Pet/Social/IPetSocialService.cs.meta b/Assets/_Project/Scripts/Modules/Pet/Social/IPetSocialService.cs.meta new file mode 100644 index 00000000..dcdee510 --- /dev/null +++ b/Assets/_Project/Scripts/Modules/Pet/Social/IPetSocialService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 12107095a393e204489753712ae4a7bc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Modules/Pet/Social/PetSocialInteractionEvent.cs b/Assets/_Project/Scripts/Modules/Pet/Social/PetSocialInteractionEvent.cs new file mode 100644 index 00000000..21cce3cb --- /dev/null +++ b/Assets/_Project/Scripts/Modules/Pet/Social/PetSocialInteractionEvent.cs @@ -0,0 +1,36 @@ +#nullable enable +namespace GeminiLab.Modules.Pet.Social +{ + /// 一次交流完成后的广播(UI/剧情系统可订阅)。 + public readonly struct PetSocialInteractionEvent + { + public PetSocialInteractionEvent( + PetId initiator, + PetId target, + SocialResponseType responseType, + float friendshipDelta, + float friendship, + bool friendshipGainApplied) + { + Initiator = initiator; + Target = target; + ResponseType = responseType; + FriendshipDelta = friendshipDelta; + Friendship = friendship; + FriendshipGainApplied = friendshipGainApplied; + } + + public PetId Initiator { get; } + public PetId Target { get; } + public SocialResponseType ResponseType { get; } + + /// 实际生效的亲密度变化(防刷冷却中为 0,§18)。 + public float FriendshipDelta { get; } + + /// 结算后的亲密度。 + public float Friendship { get; } + + /// 本次是否真正写入了亲密度(false = 处于 300s 防刷冷却中)。 + public bool FriendshipGainApplied { get; } + } +} diff --git a/Assets/_Project/Scripts/Modules/Pet/Social/PetSocialInteractionEvent.cs.meta b/Assets/_Project/Scripts/Modules/Pet/Social/PetSocialInteractionEvent.cs.meta new file mode 100644 index 00000000..3ca72be3 --- /dev/null +++ b/Assets/_Project/Scripts/Modules/Pet/Social/PetSocialInteractionEvent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5d3f2a3d9ea29d34f9f177cd49a3cafc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Modules/Pet/Social/PetSocialOutcome.cs b/Assets/_Project/Scripts/Modules/Pet/Social/PetSocialOutcome.cs new file mode 100644 index 00000000..4ca21c5a --- /dev/null +++ b/Assets/_Project/Scripts/Modules/Pet/Social/PetSocialOutcome.cs @@ -0,0 +1,47 @@ +#nullable enable +namespace GeminiLab.Modules.Pet.Social +{ + /// 一次交流的结算结果(数值规则文档 §15-18)。 + public readonly struct PetSocialOutcome + { + public PetSocialOutcome( + bool initiated, + SocialResponseType responseType, + float initiatorEnergyDelta, + float initiatorMoodDelta, + float targetEnergyDelta, + float targetMoodDelta, + float friendshipDelta, + bool friendshipGainApplied) + { + Initiated = initiated; + ResponseType = responseType; + InitiatorEnergyDelta = initiatorEnergyDelta; + InitiatorMoodDelta = initiatorMoodDelta; + TargetEnergyDelta = targetEnergyDelta; + TargetMoodDelta = targetMoodDelta; + FriendshipDelta = friendshipDelta; + FriendshipGainApplied = friendshipGainApplied; + } + + /// 交流是否实际发生(false = 发起者精力不足,§16)。 + public bool Initiated { get; } + + public SocialResponseType ResponseType { get; } + + public float InitiatorEnergyDelta { get; } + public float InitiatorMoodDelta { get; } + public float TargetEnergyDelta { get; } + public float TargetMoodDelta { get; } + + /// 实际生效的亲密度变化(防刷冷却中为 0)。 + public float FriendshipDelta { get; } + + /// 本次是否真正写入了亲密度。 + public bool FriendshipGainApplied { get; } + + /// 交流未发生时的空结果。 + public static PetSocialOutcome NotInitiated { get; } = + new PetSocialOutcome(false, SocialResponseType.NeedSpace, 0f, 0f, 0f, 0f, 0f, false); + } +} diff --git a/Assets/_Project/Scripts/Modules/Pet/Social/PetSocialOutcome.cs.meta b/Assets/_Project/Scripts/Modules/Pet/Social/PetSocialOutcome.cs.meta new file mode 100644 index 00000000..741303f7 --- /dev/null +++ b/Assets/_Project/Scripts/Modules/Pet/Social/PetSocialOutcome.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 135953dcfa5addd46ab68ec7c45b4547 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Modules/Pet/Social/PetSocialService.cs b/Assets/_Project/Scripts/Modules/Pet/Social/PetSocialService.cs new file mode 100644 index 00000000..dc1979c5 --- /dev/null +++ b/Assets/_Project/Scripts/Modules/Pet/Social/PetSocialService.cs @@ -0,0 +1,202 @@ +#nullable enable +using System; +using GeminiLab.Core.Events; +using GeminiLab.Core.Persistence; +using UnityEngine; + +namespace GeminiLab.Modules.Pet.Social +{ + /// + /// 双宠物交流系统默认实现(数值规则文档 §14-19)。 + /// - 亲密度为“对子”级数据,0-100,初始 30,无自然衰减(§14) + /// - 回应类型:对方 Energy<30 或 Mood<30 → NEED_SPACE(最高优先级); + /// 否则 Friendship>=60 → WARM;其余 NORMAL(§15) + /// - 发起者 Energy<10 不允许发起(§16) + /// - 结算表(§17):NEED_SPACE 双方 E-1/M-1、F+0;NORMAL 双方 E-2/M+2、F+1;WARM 双方 E-2/M+3、F+2 + /// - 防刷:获得亲密度后 300s 内再次交流 FriendshipDelta=0(心情/精力照常结算,§18) + /// - 特殊事件直接增减亲密度,不受防刷限制(§19) + /// 实现 (Key = "pet_social"),由 PetRuntimeBootstrap 注册。 + /// + public sealed class PetSocialService : IPetSocialService, IPersistentService + { + public const float FriendshipMin = 0f; + public const float FriendshipMax = 100f; + public const float InitialFriendship = 30f; + public const float NeedSpaceEnergyThreshold = 30f; + public const float NeedSpaceMoodThreshold = 30f; + public const float WarmFriendshipThreshold = 60f; + public const float InitiatorMinEnergy = 10f; + public const float FriendshipGainCooldownSeconds = 300f; + + private readonly IPetRoster _roster; + private readonly EventBus? _eventBus; + private readonly Func _utcNow; + + private float _friendship = InitialFriendship; + private DateTime? _lastFriendshipGainUtc; + + public PetSocialService(IPetRoster roster, EventBus? eventBus = null, Func? utcNow = null) + { + _roster = roster ?? throw new ArgumentNullException(nameof(roster)); + _eventBus = eventBus; + _utcNow = utcNow ?? (() => DateTime.UtcNow); + } + + public float Friendship => _friendship; + + public string FriendshipStageLabel => GetStageLabel(_friendship); + + /// §14 阶段划分:0-19 疏远 / 20-39 普通 / 40-59 熟悉 / 60-79 亲近 / 80-100 高亲密。 + public static string GetStageLabel(float friendship) + { + if (friendship < 20f) return "疏远"; + if (friendship < 40f) return "普通"; + if (friendship < 60f) return "熟悉"; + if (friendship < 80f) return "亲近"; + return "高亲密"; + } + + public bool CanInitiate(PetId initiator) + { + var data = _roster.TryGet(initiator); + return data != null && data.Energy >= InitiatorMinEnergy; + } + + public SocialResponseType ResolveResponseType(PetId target) + { + var data = _roster.TryGet(target); + if (data == null) return SocialResponseType.NeedSpace; + if (data.Energy < NeedSpaceEnergyThreshold || data.Mood < NeedSpaceMoodThreshold) + { + return SocialResponseType.NeedSpace; + } + return _friendship >= WarmFriendshipThreshold ? SocialResponseType.Warm : SocialResponseType.Normal; + } + + public PetSocialOutcome TrySocialize(PetId initiator, PetId target) + { + if (initiator == target) return PetSocialOutcome.NotInitiated; + + var initiatorData = _roster.TryGet(initiator); + var targetData = _roster.TryGet(target); + if (initiatorData == null || targetData == null) return PetSocialOutcome.NotInitiated; + + // §16:发起者精力不足,交流不发生,所有数值不变。 + if (initiatorData.Energy < InitiatorMinEnergy) return PetSocialOutcome.NotInitiated; + + SocialResponseType response = ResolveResponseType(target); + + // §17 结算表。 + float initiatorEnergyDelta, initiatorMoodDelta, targetEnergyDelta, targetMoodDelta, friendshipGain; + switch (response) + { + case SocialResponseType.NeedSpace: + initiatorEnergyDelta = -1f; initiatorMoodDelta = -1f; + targetEnergyDelta = -1f; targetMoodDelta = -1f; + friendshipGain = 0f; + break; + case SocialResponseType.Warm: + initiatorEnergyDelta = -2f; initiatorMoodDelta = 3f; + targetEnergyDelta = -2f; targetMoodDelta = 3f; + friendshipGain = 2f; + break; + default: // Normal + initiatorEnergyDelta = -2f; initiatorMoodDelta = 2f; + targetEnergyDelta = -2f; targetMoodDelta = 2f; + friendshipGain = 1f; + break; + } + + StatTickService.ApplyEnvironmentalBuff(initiatorData, initiatorMoodDelta, initiatorEnergyDelta); + StatTickService.ApplyEnvironmentalBuff(targetData, targetMoodDelta, targetEnergyDelta); + + // §18:防刷冷却只影响亲密度获得,心情/精力上面已照常结算。 + bool gainAllowed = friendshipGain > 0f && IsFriendshipGainAllowed(); + float appliedDelta = gainAllowed ? friendshipGain : 0f; + if (gainAllowed) + { + SetFriendship(_friendship + appliedDelta); + _lastFriendshipGainUtc = _utcNow(); + } + + var outcome = new PetSocialOutcome( + true, response, + initiatorEnergyDelta, initiatorMoodDelta, + targetEnergyDelta, targetMoodDelta, + appliedDelta, gainAllowed); + + var evt = new PetSocialInteractionEvent( + initiator, target, response, appliedDelta, _friendship, gainAllowed); + SocialInteractionCompleted?.Invoke(evt); + _eventBus?.Publish(evt); + + return outcome; + } + + public void ApplySpecialEventFriendship(float delta) + { + if (Mathf.Approximately(delta, 0f)) return; + // §19:特殊事件不受防刷冷却限制,也不刷新冷却时间。 + SetFriendship(_friendship + delta); + } + + public event Action? SocialInteractionCompleted; + + /// 亲密度实际变化后触发(含特殊事件);参数为变化后的值。 + public event Action? FriendshipChanged; + + private bool IsFriendshipGainAllowed() + { + if (_lastFriendshipGainUtc is not { } lastGain) return true; + return (_utcNow() - lastGain).TotalSeconds >= FriendshipGainCooldownSeconds; + } + + private void SetFriendship(float value) + { + float clamped = Mathf.Clamp(value, FriendshipMin, FriendshipMax); + if (Mathf.Approximately(clamped, _friendship)) return; + _friendship = clamped; + FriendshipChanged?.Invoke(_friendship); + } + + // ---- IPersistentService ---- + + public string Key => "pet_social"; + + [Serializable] + private struct SavePayload + { + public int version; + public float friendship; + public long lastFriendshipGainUtcTicks; // 0 = 从未获得过 + } + + public string CaptureJson() + { + return JsonUtility.ToJson(new SavePayload + { + version = 1, + friendship = _friendship, + lastFriendshipGainUtcTicks = _lastFriendshipGainUtc?.Ticks ?? 0L + }); + } + + public bool RestoreJson(string json) + { + if (string.IsNullOrEmpty(json)) return false; + try + { + var payload = JsonUtility.FromJson(json); + _friendship = Mathf.Clamp(payload.friendship, FriendshipMin, FriendshipMax); + _lastFriendshipGainUtc = payload.lastFriendshipGainUtcTicks > 0L + ? new DateTime(payload.lastFriendshipGainUtcTicks, DateTimeKind.Utc) + : null; + return true; + } + catch + { + return false; + } + } + } +} diff --git a/Assets/_Project/Scripts/Modules/Pet/Social/PetSocialService.cs.meta b/Assets/_Project/Scripts/Modules/Pet/Social/PetSocialService.cs.meta new file mode 100644 index 00000000..dd78d003 --- /dev/null +++ b/Assets/_Project/Scripts/Modules/Pet/Social/PetSocialService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 656913bef26801b429d8dc611486a9a6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Modules/Pet/Social/SocialResponseType.cs b/Assets/_Project/Scripts/Modules/Pet/Social/SocialResponseType.cs new file mode 100644 index 00000000..2eb419bc --- /dev/null +++ b/Assets/_Project/Scripts/Modules/Pet/Social/SocialResponseType.cs @@ -0,0 +1,18 @@ +#nullable enable +namespace GeminiLab.Modules.Pet.Social +{ + /// + /// 交流回应类型(数值规则文档 §15)。 + /// + public enum SocialResponseType + { + /// 对方需要空间:Target.Energy < 30 或 Target.Mood < 30。优先级最高。 + NeedSpace = 0, + + /// 普通交流。 + Normal = 1, + + /// 亲密交流:Friendship >= 60。 + Warm = 2 + } +} diff --git a/Assets/_Project/Scripts/Modules/Pet/Social/SocialResponseType.cs.meta b/Assets/_Project/Scripts/Modules/Pet/Social/SocialResponseType.cs.meta new file mode 100644 index 00000000..18a87ba8 --- /dev/null +++ b/Assets/_Project/Scripts/Modules/Pet/Social/SocialResponseType.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 13df9a56a29e8d34499ff0a509f30873 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Modules/Pet/StatTickService.cs b/Assets/_Project/Scripts/Modules/Pet/StatTickService.cs index 6c0e7225..1292ea00 100644 --- a/Assets/_Project/Scripts/Modules/Pet/StatTickService.cs +++ b/Assets/_Project/Scripts/Modules/Pet/StatTickService.cs @@ -5,58 +5,53 @@ namespace GeminiLab.Modules.Pet { /// /// Centralized stat ticking to avoid scattered direct value writes. - /// 行为: - /// - 睡眠中:Energy 按 回复; - /// Mood 稳定少量回复;Satiety 仍持续衰减(睡觉不会管饱) - /// - 清醒时:Energy 持续衰减;Satiety 持续衰减;Mood 在精力/饱食充裕时回复; - /// 在任一触底时反向扣除并把回复速度降到 倍 + /// 数值规则文档口径: + /// - §11 精力自然变化:清醒每现实 + /// 分钟 Energy -1;睡觉期间停止自然衰减(睡眠恢复由睡觉行为完成时一次性结算)。 + /// - §13 心情回归中性:每现实 + /// 秒向 回归 1 点。 + /// - 饱食:文档未定义,保留既有的缓慢自然衰减(喂食玩法预留 ApplySatietyDelta)。 + /// 行为带来的 Energy/Mood 增减不在此处:由行为完成结算(§12)与交互 buff 走 + /// 。 /// public sealed class StatTickService { - private const float LowSatietyThreshold = 30f; - private const float LowEnergyThreshold = 20f; - public void Tick(PetContext context, float deltaTime) { PetRuntimeData data = context.RuntimeData; PetStateValueSO config = context.Config; - // Satiety 始终衰减(睡觉也会饿) + // 饱食缓慢自然衰减(文档未提及,保留既有系统)。 data.Satiety -= config.SatietyDecayPerSecond * deltaTime; - if (context.IsSleeping) + // §11:清醒时精力自然消耗;睡觉期间不自然衰减。 + if (!context.IsSleeping) { - data.Energy += config.SleepingEnergyRecoveryPerSecond * deltaTime; - data.Mood += config.MoodRecoveryPerSecond * 0.5f * deltaTime; + data.Energy -= config.AwakeEnergyDrainPerRealMinute / 60f * deltaTime; } - else - { - data.Energy -= config.AwakeEnergyDecayPerSecond * deltaTime; - - bool lowSatiety = data.Satiety <= LowSatietyThreshold; - bool lowEnergy = data.Energy <= LowEnergyThreshold; - - float moodRecovery = config.MoodRecoveryPerSecond; - if (lowSatiety || lowEnergy) - { - moodRecovery *= config.MoodRecoveryPenaltyFactor; - } - - data.Mood += moodRecovery * deltaTime; - if (lowSatiety) - { - data.Mood -= config.LowSatietyMoodPenaltyPerSecond * deltaTime; - } - if (lowEnergy) + // §13:心情定时向中性值回归(每间隔 ±1,不越过目标值)。 + if (config.MoodNeutralReturnIntervalSeconds > 0f) + { + data.NeutralReturnTimerSeconds += deltaTime; + while (data.NeutralReturnTimerSeconds >= config.MoodNeutralReturnIntervalSeconds) { - data.Mood -= config.LowEnergyMoodPenaltyPerSecond * deltaTime; + data.NeutralReturnTimerSeconds -= config.MoodNeutralReturnIntervalSeconds; + if (data.Mood < config.MoodNeutralTarget) + { + data.Mood = Mathf.Min(data.Mood + 1f, config.MoodNeutralTarget); + } + else if (data.Mood > config.MoodNeutralTarget) + { + data.Mood = Mathf.Max(data.Mood - 1f, config.MoodNeutralTarget); + } } } ClampStateValues(data); } + /// 一次性结算交互/行为带来的 Mood/Energy 增减(§12 行为结算也走这里)。 public static void ApplyEnvironmentalBuff(PetRuntimeData data, float moodDelta, float energyDelta) { data.Mood += moodDelta; diff --git a/Assets/_Project/Tests/EditMode/BehaviorSelectorTests.cs b/Assets/_Project/Tests/EditMode/BehaviorSelectorTests.cs new file mode 100644 index 00000000..ff75214a --- /dev/null +++ b/Assets/_Project/Tests/EditMode/BehaviorSelectorTests.cs @@ -0,0 +1,170 @@ +#nullable enable +using GeminiLab.Modules.Pet.Behavior; +using GeminiLab.Modules.Pet.Personality; +using NUnit.Framework; +using UnityEngine; + +namespace GeminiLab.Tests.EditMode +{ + /// 行为选择器测试(数值规则文档 §4 硬规则 / §9 重复 / §25 加权随机)。 + public sealed class BehaviorSelectorTests + { + private static BehaviorConfigSO.BehaviorEntry Entry( + string id, + float baseWeight = 10f, + BehaviorCategory category = BehaviorCategory.Neutral, + float preferredEnergy = 50f, + BehaviorTag tags = BehaviorTag.None) + { + return new BehaviorConfigSO.BehaviorEntry + { + BehaviorId = id, + BaseWeight = baseWeight, + Category = category, + PreferredEnergy = preferredEnergy, + Tags = tags + }; + } + + private static BehaviorConfigSO Config(params BehaviorConfigSO.BehaviorEntry[] entries) + { + var config = ScriptableObject.CreateInstance(); + config.Behaviors.AddRange(entries); + return config; + } + + private static BehaviorSelectorInput Input(float energy, float mood = 50f, float now = 0f) => + new(energy, mood, default(PersonalityVector), now, + forcedSleepEnergyThreshold: 5f, + sleepExcludedAboveEnergy: 70f, + activeBehaviorMinEnergy: 10f); + + [Test] + public void Pick_ForcedSleep_WhenEnergyAtOrBelowThreshold() + { + var config = Config(Entry(PetBehaviorIds.Idle), Entry(PetBehaviorIds.Sleep)); + + var result = BehaviorSelector.Pick(config, Input(energy: 5f), new BehaviorRuntimeState(), () => 0.99f); + + Assert.NotNull(result); + Assert.AreEqual(PetBehaviorIds.Sleep, result!.Value.Entry.BehaviorId); + Assert.IsTrue(result.Value.IsForced); + } + + [Test] + public void Pick_ForcedSleep_BypassesCooldown() + { + // 睡觉在冷却中也不能拦住强制睡觉,否则低精力宠物会死锁到精力归零。 + var config = Config(Entry(PetBehaviorIds.Idle), Entry(PetBehaviorIds.Sleep)); + var state = new BehaviorRuntimeState(); + state.RecordCompletion(PetBehaviorIds.Sleep, nowSeconds: 0f, cooldownSeconds: 300f); + + var result = BehaviorSelector.Pick(config, Input(energy: 3f, now: 10f), state, () => 0.99f); + + Assert.NotNull(result); + Assert.AreEqual(PetBehaviorIds.Sleep, result!.Value.Entry.BehaviorId); + Assert.IsTrue(result.Value.IsForced); + } + + [Test] + public void Pick_ExcludesSleep_WhenEnergyAbove70() + { + var config = Config(Entry(PetBehaviorIds.Idle), Entry(PetBehaviorIds.Sleep)); + + var result = BehaviorSelector.Pick(config, Input(energy: 80f), new BehaviorRuntimeState(), () => 0.999f); + + Assert.NotNull(result); + Assert.AreEqual(PetBehaviorIds.Idle, result!.Value.Entry.BehaviorId); + } + + [Test] + public void Pick_ExcludesActiveTagged_WhenEnergyBelow10() + { + var config = Config( + Entry("quiet", category: BehaviorCategory.Quiet), + Entry("sport", category: BehaviorCategory.Active, tags: BehaviorTag.Active)); + + // 精力 8:高于强制睡觉线 5,但低于活跃行为线 10 → 活跃行为被硬规则过滤。 + var result = BehaviorSelector.Pick(config, Input(energy: 8f), new BehaviorRuntimeState(), () => 0.999f); + + Assert.NotNull(result); + Assert.AreEqual("quiet", result!.Value.Entry.BehaviorId); + } + + [Test] + public void Pick_ZeroRollNeverSelectsZeroWeightEntry() + { + // random01()=0 时 roll=0,冷却中的 0 权重候选也不能被命中。 + var config = Config(Entry("a"), Entry("b")); + var state = new BehaviorRuntimeState(); + state.RecordCompletion("a", nowSeconds: 0f, cooldownSeconds: 100f); + + var result = BehaviorSelector.Pick(config, Input(energy: 50f, now: 10f), state, () => 0f); + + Assert.NotNull(result); + Assert.AreEqual("b", result!.Value.Entry.BehaviorId); + } + + [Test] + public void Pick_PrefersNonRepeatedBehavior() + { + // 两个完全相同的行为,a 是上一个完成的(重复倍率 0.2)→ 应选 b。 + var config = Config(Entry("a"), Entry("b")); + var state = new BehaviorRuntimeState(); + state.RecordCompletion("a", nowSeconds: 0f, cooldownSeconds: 0f); + + var result = BehaviorSelector.Pick(config, Input(energy: 50f, now: 1f), state, () => 0.5f); + + Assert.NotNull(result); + Assert.AreEqual("b", result!.Value.Entry.BehaviorId); + } + + [Test] + public void Pick_WeightedDistribution_MatchesWeights() + { + var config = Config(Entry("heavy", baseWeight: 30f), Entry("light", baseWeight: 10f)); + var rng = new System.Random(12345); + var state = new BehaviorRuntimeState(); + + int heavy = 0, light = 0; + const int trials = 4000; + for (int i = 0; i < trials; i++) + { + var result = BehaviorSelector.Pick( + config, Input(energy: 50f), state, () => (float)rng.NextDouble()); + Assert.NotNull(result); + if (result!.Value.Entry.BehaviorId == "heavy") heavy++; + else light++; + } + + // 期望 3:1 → 3000/1000;容差 ±10%。 + Assert.AreEqual(3000, heavy, 300); + Assert.AreEqual(1000, light, 300); + } + + [Test] + public void Pick_FallbacksToIdle_WhenAllCandidatesOnCooldown() + { + var config = Config(Entry(PetBehaviorIds.Idle), Entry("a")); + var state = new BehaviorRuntimeState(); + state.RecordCompletion(PetBehaviorIds.Idle, nowSeconds: 0f, cooldownSeconds: 100f); + state.RecordCompletion("a", nowSeconds: 0f, cooldownSeconds: 100f); + + var result = BehaviorSelector.Pick(config, Input(energy: 50f, now: 10f), state, () => 0.5f); + + Assert.NotNull(result); + Assert.AreEqual(PetBehaviorIds.Idle, result!.Value.Entry.BehaviorId); + Assert.IsFalse(result.Value.IsForced); + } + + [Test] + public void Pick_ReturnsNull_WhenConfigHasNoBehaviors() + { + var config = Config(); + + var result = BehaviorSelector.Pick(config, Input(energy: 50f), new BehaviorRuntimeState(), () => 0.5f); + + Assert.IsNull(result); + } + } +} diff --git a/Assets/_Project/Tests/EditMode/BehaviorSelectorTests.cs.meta b/Assets/_Project/Tests/EditMode/BehaviorSelectorTests.cs.meta new file mode 100644 index 00000000..0d127b52 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/BehaviorSelectorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 325daaee3aff8974c85a4752add96b50 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Tests/EditMode/BehaviorWeightCalculatorTests.cs b/Assets/_Project/Tests/EditMode/BehaviorWeightCalculatorTests.cs new file mode 100644 index 00000000..75360238 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/BehaviorWeightCalculatorTests.cs @@ -0,0 +1,168 @@ +#nullable enable +using GeminiLab.Modules.Pet.Behavior; +using GeminiLab.Modules.Pet.Personality; +using NUnit.Framework; + +namespace GeminiLab.Tests.EditMode +{ + /// 四倍率纯函数测试(数值规则文档 §2/§3/§6/§9/§25)。 + public sealed class BehaviorWeightCalculatorTests + { + private static BehaviorConfigSO.BehaviorEntry Entry( + float baseWeight = 10f, + BehaviorCategory category = BehaviorCategory.Neutral, + float preferredEnergy = 50f, + params BehaviorConfigSO.PersonalityTag[] tags) + { + return new BehaviorConfigSO.BehaviorEntry + { + BehaviorId = "test", + BaseWeight = baseWeight, + Category = category, + PreferredEnergy = preferredEnergy, + PersonalityTags = tags + }; + } + + private static BehaviorConfigSO.PersonalityTag Tag(PetTrait trait, int direction) => + new() { Trait = trait, Direction = direction }; + + [Test] + public void PersonalityMultiplier_NoTags_ReturnsOne() + { + float multiplier = BehaviorWeightCalculator.PersonalityMultiplier(Entry(), default); + Assert.AreEqual(1f, multiplier, 0.001f); + } + + [Test] + public void PersonalityMultiplier_PositiveTag_RaisesWithTrait() + { + // §2:1 + 0.8 × 0.25 = 1.2 + var vector = new PersonalityVector { Kindness = 0.8f }; + float multiplier = BehaviorWeightCalculator.PersonalityMultiplier( + Entry(tags: new[] { Tag(PetTrait.Kindness, 1) }), vector); + Assert.AreEqual(1.2f, multiplier, 0.001f); + } + + [Test] + public void PersonalityMultiplier_NegativeDirectionTag_LowersWithTrait() + { + // “-害羞”标签:害羞越高倍率越低。1 - 0.8 × 0.25 = 0.8 + var vector = new PersonalityVector { Shyness = 0.8f }; + float multiplier = BehaviorWeightCalculator.PersonalityMultiplier( + Entry(tags: new[] { Tag(PetTrait.Shyness, -1) }), vector); + Assert.AreEqual(0.8f, multiplier, 0.001f); + } + + [Test] + public void PersonalityMultiplier_ClampedToDocBounds() + { + var vector = new PersonalityVector { Kindness = 1f, Calmness = 1f, Integrity = 1f, Curiosity = 1f }; + float high = BehaviorWeightCalculator.PersonalityMultiplier( + Entry(tags: new[] + { + Tag(PetTrait.Kindness, 1), Tag(PetTrait.Calmness, 1), + Tag(PetTrait.Integrity, 1), Tag(PetTrait.Curiosity, 1) + }), vector); + Assert.AreEqual(BehaviorWeightCalculator.PersonalityMultiplierMax, high, 0.001f); + + var negative = new PersonalityVector { Kindness = -1f, Calmness = -1f, Integrity = -1f, Curiosity = -1f }; + float low = BehaviorWeightCalculator.PersonalityMultiplier( + Entry(tags: new[] + { + Tag(PetTrait.Kindness, 1), Tag(PetTrait.Calmness, 1), + Tag(PetTrait.Integrity, 1), Tag(PetTrait.Curiosity, 1) + }), negative); + Assert.AreEqual(BehaviorWeightCalculator.PersonalityMultiplierMin, low, 0.001f); + } + + [Test] + public void EnergyMultiplier_AtPreferred_IsMax() + { + // §3:0.6 + 0.8 × 1 = 1.4 + float multiplier = BehaviorWeightCalculator.EnergyMultiplier(Entry(preferredEnergy: 60f), 60f); + Assert.AreEqual(1.4f, multiplier, 0.001f); + } + + [Test] + public void EnergyMultiplier_AtMaxDistance_IsMin() + { + // §3:|100-0|/100 = 1 → 0.6 + 0.8 × 0 = 0.6 + float multiplier = BehaviorWeightCalculator.EnergyMultiplier(Entry(preferredEnergy: 0f), 100f); + Assert.AreEqual(0.6f, multiplier, 0.001f); + } + + [Test] + public void MoodMultiplier_MatchesDocTable() + { + // §6:Rest 低 1.4 / 高 0.7;Active 低 0.7 / 高 1.25;Social 高 1.3;Neutral 恒 1。 + Assert.AreEqual(1.4f, BehaviorWeightCalculator.MoodMultiplier(BehaviorCategory.Rest, 10f), 0.001f); + Assert.AreEqual(0.7f, BehaviorWeightCalculator.MoodMultiplier(BehaviorCategory.Rest, 90f), 0.001f); + Assert.AreEqual(0.7f, BehaviorWeightCalculator.MoodMultiplier(BehaviorCategory.Active, 10f), 0.001f); + Assert.AreEqual(1.25f, BehaviorWeightCalculator.MoodMultiplier(BehaviorCategory.Active, 90f), 0.001f); + Assert.AreEqual(1.3f, BehaviorWeightCalculator.MoodMultiplier(BehaviorCategory.Social, 90f), 0.001f); + Assert.AreEqual(0.6f, BehaviorWeightCalculator.MoodMultiplier(BehaviorCategory.Social, 10f), 0.001f); + Assert.AreEqual(1f, BehaviorWeightCalculator.MoodMultiplier(BehaviorCategory.Neutral, 10f), 0.001f); + Assert.AreEqual(1f, BehaviorWeightCalculator.MoodMultiplier(BehaviorCategory.Rest, 50f), 0.001f); + } + + [Test] + public void MoodMultiplier_BandEdges() + { + // §5:<30 低 / 30~69 中 / >=70 高 + Assert.AreEqual(1.4f, BehaviorWeightCalculator.MoodMultiplier(BehaviorCategory.Rest, 29.99f), 0.001f); + Assert.AreEqual(1f, BehaviorWeightCalculator.MoodMultiplier(BehaviorCategory.Rest, 30f), 0.001f); + Assert.AreEqual(1f, BehaviorWeightCalculator.MoodMultiplier(BehaviorCategory.Rest, 69.99f), 0.001f); + Assert.AreEqual(0.7f, BehaviorWeightCalculator.MoodMultiplier(BehaviorCategory.Rest, 70f), 0.001f); + } + + [Test] + public void FinalWeight_IsProductOfFourMultipliers() + { + // §25:10 × 1.2(性格)× 1.4(精力)× 1.25(心情-活跃高档)× 1.0(无重复)= 21 + var entry = Entry(10f, BehaviorCategory.Active, 50f, Tag(PetTrait.Kindness, 1)); + var vector = new PersonalityVector { Kindness = 0.8f }; + var state = new BehaviorRuntimeState(); + + float weight = BehaviorWeightCalculator.FinalWeight(entry, vector, 50f, 80f, state, 0f); + + Assert.AreEqual(21f, weight, 0.01f); + } + + [Test] + public void FinalWeight_OnCooldown_IsZero() + { + var entry = Entry(); + entry.BehaviorId = "cooling"; + var state = new BehaviorRuntimeState(); + state.RecordCompletion("cooling", nowSeconds: 0f, cooldownSeconds: 100f); + + float weight = BehaviorWeightCalculator.FinalWeight(entry, default, 50f, 50f, state, 10f); + + Assert.AreEqual(0f, weight, 0.001f); + } + + [Test] + public void RepeatMultiplier_PriorityAndValues() + { + // §9:冷却 0 > 上一个 0.2 > 最近三个 0.6 > 1.0 + var state = new BehaviorRuntimeState(); + var entry = Entry(); + entry.BehaviorId = "a"; + + Assert.AreEqual(1f, BehaviorWeightCalculator.RepeatMultiplier(entry, state, 0f), 0.001f); + + state.RecordCompletion("a", 0f, 0f); + Assert.AreEqual(0.2f, BehaviorWeightCalculator.RepeatMultiplier(entry, state, 0f), 0.001f); + + state.RecordCompletion("b", 1f, 0f); + Assert.AreEqual(0.6f, BehaviorWeightCalculator.RepeatMultiplier(entry, state, 1f), 0.001f); + + state.RecordCompletion("a", 2f, 100f); + Assert.AreEqual(0f, BehaviorWeightCalculator.RepeatMultiplier(entry, state, 3f), 0.001f); + + // 冷却结束后仍受“上一个/最近”规则影响(这里是上一个 → 0.2)。 + Assert.AreEqual(0.2f, BehaviorWeightCalculator.RepeatMultiplier(entry, state, 200f), 0.001f); + } + } +} diff --git a/Assets/_Project/Tests/EditMode/BehaviorWeightCalculatorTests.cs.meta b/Assets/_Project/Tests/EditMode/BehaviorWeightCalculatorTests.cs.meta new file mode 100644 index 00000000..cd218917 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/BehaviorWeightCalculatorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0ff24d48ce9488f46a32bcf6f623976b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Tests/EditMode/PetRuntimeSaveServiceOfflineTests.cs b/Assets/_Project/Tests/EditMode/PetRuntimeSaveServiceOfflineTests.cs new file mode 100644 index 00000000..9221092d --- /dev/null +++ b/Assets/_Project/Tests/EditMode/PetRuntimeSaveServiceOfflineTests.cs @@ -0,0 +1,140 @@ +#nullable enable +using System; +using GeminiLab.Modules.Pet; +using NUnit.Framework; + +namespace GeminiLab.Tests.EditMode +{ + /// 离线规则测试(数值规则文档 §20)+ pet_runtime 存档 v2 兼容。 + public sealed class PetRuntimeSaveServiceOfflineTests + { + private PetRoster _roster = null!; + private PetRuntimeData _angel = null!; + private DateTime _now; + + [SetUp] + public void SetUp() + { + _roster = new PetRoster(); + _angel = new PetRuntimeData + { + Mood = 80f, + Energy = 40f, + Satiety = 33f, + RuntimeTimeSeconds = 1234f, + TravelCompletedCount = 2, + CurrentState = "Idle" + }; + _roster.Register(PetId.Angel, _angel); + _now = new DateTime(2026, 8, 20, 12, 0, 0, DateTimeKind.Utc); + } + + private PetRuntimeSaveService CreateService() => new(_roster, utcNow: () => _now); + + private string CaptureAndAdvance(TimeSpan offline) + { + string json = CreateService().CaptureJson(); + _now += offline; + + // 模拟“关闭期间运行态被重置”:恢复前先改成别的值。 + _angel.Mood = 11f; + _angel.Energy = 99f; + _angel.Satiety = 1f; + return json; + } + + [Test] + public void Offline_TwoHours_MoodRegressesByAtMost6Points() + { + // §20 文档示例:关闭时 Mood=80,离线 2 小时,回来后 Mood=74。 + string json = CaptureAndAdvance(TimeSpan.FromHours(2)); + + Assert.IsTrue(CreateService().RestoreJson(json)); + + Assert.AreEqual(74f, _angel.Mood, 0.001f); + Assert.AreEqual(40f, _angel.Energy, 0.001f); // 离线不衰减,原样恢复 + Assert.AreEqual(33f, _angel.Satiety, 0.001f); + } + + [Test] + public void Offline_TenMinutes_MoodRegresses2Points() + { + // 每 5 分钟 1 点(与在线 §13 同节奏)。 + string json = CaptureAndAdvance(TimeSpan.FromMinutes(10)); + + Assert.IsTrue(CreateService().RestoreJson(json)); + + Assert.AreEqual(78f, _angel.Mood, 0.001f); + } + + [Test] + public void Offline_BelowNeutral_RegressesUpward() + { + _angel.Mood = 20f; + string json = CaptureAndAdvance(TimeSpan.FromMinutes(15)); + + Assert.IsTrue(CreateService().RestoreJson(json)); + + Assert.AreEqual(23f, _angel.Mood, 0.001f); + } + + [Test] + public void Offline_RegressionDoesNotCrossNeutral() + { + _angel.Mood = 53f; + string json = CaptureAndAdvance(TimeSpan.FromHours(5)); + + Assert.IsTrue(CreateService().RestoreJson(json)); + + Assert.AreEqual(50f, _angel.Mood, 0.001f); + } + + [Test] + public void V1Payload_RestoresWithoutOfflineRegression() + { + // v1 存档没有 savedAtUtcTicks:按原值恢复,不做离线回归。 + const string v1Json = + "{\"version\":1,\"entries\":[{" + + "\"petId\":0,\"mood\":80,\"energy\":40,\"satiety\":33," + + "\"runtimeTime\":1234,\"travelCompletedCount\":2," + + "\"currentState\":\"Idle\",\"lastInteractionFurnitureId\":\"bed\"," + + "\"lastInteractionSummary\":\"睡觉\"}]}"; + _now += TimeSpan.FromHours(10); + + Assert.IsTrue(CreateService().RestoreJson(v1Json)); + + Assert.AreEqual(80f, _angel.Mood, 0.001f); + Assert.AreEqual(40f, _angel.Energy, 0.001f); + Assert.AreEqual("bed", _angel.LastInteractionFurnitureId); + } + + [Test] + public void CorruptJson_FailsAndKeepsState() + { + var service = CreateService(); + + Assert.IsFalse(service.RestoreJson("{broken")); + Assert.IsFalse(service.RestoreJson(string.Empty)); + Assert.AreEqual(80f, _angel.Mood, 0.001f); + } + + [Test] + public void RoundTrip_RestoresRuntimeFields() + { + _angel.CurrentState = "Sleeping"; + _angel.LastInteractionFurnitureId = "harp"; + _angel.LastInteractionSummary = "弹琴 / 安静 (Mood +2, Energy -2)"; + + string json = CaptureAndAdvance(TimeSpan.FromMinutes(1)); // 不足 5 分钟 → 不回归 + + Assert.IsTrue(CreateService().RestoreJson(json)); + + Assert.AreEqual(80f, _angel.Mood, 0.001f); + Assert.AreEqual(1234f, _angel.RuntimeTimeSeconds, 0.001f); + Assert.AreEqual(2, _angel.TravelCompletedCount); + Assert.AreEqual("Sleeping", _angel.CurrentState); + Assert.AreEqual("harp", _angel.LastInteractionFurnitureId); + Assert.AreEqual("弹琴 / 安静 (Mood +2, Energy -2)", _angel.LastInteractionSummary); + } + } +} diff --git a/Assets/_Project/Tests/EditMode/PetRuntimeSaveServiceOfflineTests.cs.meta b/Assets/_Project/Tests/EditMode/PetRuntimeSaveServiceOfflineTests.cs.meta new file mode 100644 index 00000000..1946e15a --- /dev/null +++ b/Assets/_Project/Tests/EditMode/PetRuntimeSaveServiceOfflineTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c1a0edbd4373c8445929aa7cbaf2ead3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Tests/EditMode/PetSocialServiceTests.cs b/Assets/_Project/Tests/EditMode/PetSocialServiceTests.cs new file mode 100644 index 00000000..03ef7ced --- /dev/null +++ b/Assets/_Project/Tests/EditMode/PetSocialServiceTests.cs @@ -0,0 +1,247 @@ +#nullable enable +using System; +using GeminiLab.Modules.Pet; +using GeminiLab.Modules.Pet.Social; +using NUnit.Framework; + +namespace GeminiLab.Tests.EditMode +{ + /// 交流系统测试(数值规则文档 §14-19)。 + public sealed class PetSocialServiceTests + { + private PetRoster _roster = null!; + private PetRuntimeData _angel = null!; + private PetRuntimeData _devil = null!; + private DateTime _now; + + [SetUp] + public void SetUp() + { + _roster = new PetRoster(); + _angel = new PetRuntimeData { Energy = 80f, Mood = 60f }; + _devil = new PetRuntimeData { Energy = 80f, Mood = 60f }; + _roster.Register(PetId.Angel, _angel); + _roster.Register(PetId.Devil, _devil); + _now = new DateTime(2026, 8, 20, 12, 0, 0, DateTimeKind.Utc); + } + + private PetSocialService CreateService() => new(_roster, utcNow: () => _now); + + [Test] + public void InitialState_MatchesDoc() + { + var service = CreateService(); + + Assert.AreEqual(30f, service.Friendship, 0.001f); // §14/§23:初始 30 + Assert.AreEqual("普通", service.FriendshipStageLabel); // 20~39 普通 + } + + [Test] + public void StageLabels_MatchDocBands() + { + Assert.AreEqual("疏远", PetSocialService.GetStageLabel(0f)); + Assert.AreEqual("疏远", PetSocialService.GetStageLabel(19.9f)); + Assert.AreEqual("普通", PetSocialService.GetStageLabel(20f)); + Assert.AreEqual("熟悉", PetSocialService.GetStageLabel(40f)); + Assert.AreEqual("亲近", PetSocialService.GetStageLabel(60f)); + Assert.AreEqual("高亲密", PetSocialService.GetStageLabel(80f)); + } + + [Test] + public void CanInitiate_ForbiddenBelowEnergy10() + { + var service = CreateService(); + _angel.Energy = 9.9f; + Assert.IsFalse(service.CanInitiate(PetId.Angel)); // §16 + + _angel.Energy = 10f; + Assert.IsTrue(service.CanInitiate(PetId.Angel)); + } + + [Test] + public void ResolveResponseType_NeedSpaceHasHighestPriority() + { + var service = CreateService(); + service.ApplySpecialEventFriendship(50f); // 亲密度 80,本应是 WARM + + _devil.Energy = 20f; // §15:对方 Energy<30 → NEED_SPACE 优先于 WARM + Assert.AreEqual(SocialResponseType.NeedSpace, service.ResolveResponseType(PetId.Devil)); + + _devil.Energy = 80f; + _devil.Mood = 20f; // 对方 Mood<30 → 同样 NEED_SPACE + Assert.AreEqual(SocialResponseType.NeedSpace, service.ResolveResponseType(PetId.Devil)); + } + + [Test] + public void ResolveResponseType_WarmRequiresFriendship60() + { + var service = CreateService(); + Assert.AreEqual(SocialResponseType.Normal, service.ResolveResponseType(PetId.Devil)); + + service.ApplySpecialEventFriendship(30f); // 30 → 60 + Assert.AreEqual(SocialResponseType.Warm, service.ResolveResponseType(PetId.Devil)); + } + + [Test] + public void TrySocialize_InitiatorTooTired_NothingHappens() + { + var service = CreateService(); + _angel.Energy = 5f; + + PetSocialOutcome outcome = service.TrySocialize(PetId.Angel, PetId.Devil); + + Assert.IsFalse(outcome.Initiated); + Assert.AreEqual(5f, _angel.Energy, 0.001f); + Assert.AreEqual(80f, _devil.Energy, 0.001f); + Assert.AreEqual(30f, service.Friendship, 0.001f); + } + + [Test] + public void TrySocialize_NeedSpace_AppliesDocDeltas() + { + var service = CreateService(); + _devil.Energy = 20f; // 触发 NEED_SPACE + + PetSocialOutcome outcome = service.TrySocialize(PetId.Angel, PetId.Devil); + + // §17:双方 E-1 / M-1 / F+0 + Assert.IsTrue(outcome.Initiated); + Assert.AreEqual(SocialResponseType.NeedSpace, outcome.ResponseType); + Assert.AreEqual(79f, _angel.Energy, 0.001f); + Assert.AreEqual(59f, _angel.Mood, 0.001f); + Assert.AreEqual(19f, _devil.Energy, 0.001f); + Assert.AreEqual(59f, _devil.Mood, 0.001f); + Assert.AreEqual(30f, service.Friendship, 0.001f); + Assert.IsFalse(outcome.FriendshipGainApplied); + } + + [Test] + public void TrySocialize_Normal_AppliesDocDeltasAndPublishesEvent() + { + var service = CreateService(); + PetSocialInteractionEvent? captured = null; + service.SocialInteractionCompleted += e => captured = e; + + PetSocialOutcome outcome = service.TrySocialize(PetId.Angel, PetId.Devil); + + // §17:双方 E-2 / M+2 / F+1 + Assert.AreEqual(SocialResponseType.Normal, outcome.ResponseType); + Assert.AreEqual(78f, _angel.Energy, 0.001f); + Assert.AreEqual(62f, _angel.Mood, 0.001f); + Assert.AreEqual(78f, _devil.Energy, 0.001f); + Assert.AreEqual(62f, _devil.Mood, 0.001f); + Assert.AreEqual(31f, service.Friendship, 0.001f); + Assert.IsTrue(outcome.FriendshipGainApplied); + + Assert.NotNull(captured); + Assert.AreEqual(PetId.Angel, captured!.Value.Initiator); + Assert.AreEqual(PetId.Devil, captured.Value.Target); + Assert.AreEqual(1f, captured.Value.FriendshipDelta, 0.001f); + Assert.AreEqual(31f, captured.Value.Friendship, 0.001f); + } + + [Test] + public void TrySocialize_Warm_AppliesDocDeltas() + { + var service = CreateService(); + service.ApplySpecialEventFriendship(40f); // 30 → 70 ≥ 60 → WARM + + PetSocialOutcome outcome = service.TrySocialize(PetId.Angel, PetId.Devil); + + // §17:双方 E-2 / M+3 / F+2 + Assert.AreEqual(SocialResponseType.Warm, outcome.ResponseType); + Assert.AreEqual(63f, _angel.Mood, 0.001f); + Assert.AreEqual(63f, _devil.Mood, 0.001f); + Assert.AreEqual(72f, service.Friendship, 0.001f); + } + + [Test] + public void TrySocialize_AntiFarmCooldown_OnlyBlocksFriendship() + { + var service = CreateService(); + + service.TrySocialize(PetId.Angel, PetId.Devil); // F 30 → 31,开始 300s 冷却 + _now = _now.AddSeconds(100); + + PetSocialOutcome second = service.TrySocialize(PetId.Angel, PetId.Devil); + + // §18:冷却中心情/精力照常结算,亲密度不变。 + Assert.IsTrue(second.Initiated); + Assert.IsFalse(second.FriendshipGainApplied); + Assert.AreEqual(0f, second.FriendshipDelta, 0.001f); + Assert.AreEqual(31f, service.Friendship, 0.001f); + Assert.AreEqual(64f, _angel.Mood, 0.001f); // 两次 M+2 都生效 + + _now = _now.AddSeconds(201); // 距上次获得 301s + PetSocialOutcome third = service.TrySocialize(PetId.Angel, PetId.Devil); + Assert.IsTrue(third.FriendshipGainApplied); + Assert.AreEqual(32f, service.Friendship, 0.001f); + } + + [Test] + public void SpecialEventFriendship_BypassesCooldownAndDoesNotRefreshIt() + { + var service = CreateService(); + + service.TrySocialize(PetId.Angel, PetId.Devil); // F 31,冷却从 t0 起算 + _now = _now.AddSeconds(10); + service.ApplySpecialEventFriendship(5f); // §19:不受冷却限制 + Assert.AreEqual(36f, service.Friendship, 0.001f); + + _now = _now.AddSeconds(10); + PetSocialOutcome outcome = service.TrySocialize(PetId.Angel, PetId.Devil); + // 特殊事件不刷新冷却:仍按 t0 起算,t0+20 在冷却中。 + Assert.IsFalse(outcome.FriendshipGainApplied); + Assert.AreEqual(36f, service.Friendship, 0.001f); + } + + [Test] + public void Friendship_ClampedToDocRange() + { + var service = CreateService(); + + service.ApplySpecialEventFriendship(500f); + Assert.AreEqual(100f, service.Friendship, 0.001f); + + service.ApplySpecialEventFriendship(-500f); + Assert.AreEqual(0f, service.Friendship, 0.001f); + } + + [Test] + public void Persistence_RoundTrip_PreservesFriendshipAndCooldown() + { + var service = CreateService(); + service.TrySocialize(PetId.Angel, PetId.Devil); // F 31 @ t0 + string json = service.CaptureJson(); + + var restored = new PetSocialService(_roster, utcNow: () => _now); + Assert.IsTrue(restored.RestoreJson(json)); + Assert.AreEqual(31f, restored.Friendship, 0.001f); + + // 冷却截止时刻也随存档恢复:t0+100 仍在冷却中。 + _now = _now.AddSeconds(100); + PetSocialOutcome outcome = restored.TrySocialize(PetId.Angel, PetId.Devil); + Assert.IsFalse(outcome.FriendshipGainApplied); + } + + [Test] + public void Persistence_CorruptJson_FailsAndKeepsState() + { + var service = CreateService(); + + Assert.IsFalse(service.RestoreJson("{not json")); + Assert.IsFalse(service.RestoreJson(string.Empty)); + Assert.AreEqual(30f, service.Friendship, 0.001f); + } + + [Test] + public void TrySocialize_MissingPet_NotInitiated() + { + var roster = new PetRoster(); // 空 roster + var service = new PetSocialService(roster, utcNow: () => _now); + + Assert.IsFalse(service.TrySocialize(PetId.Angel, PetId.Devil).Initiated); + Assert.IsFalse(service.CanInitiate(PetId.Angel)); + } + } +} diff --git a/Assets/_Project/Tests/EditMode/PetSocialServiceTests.cs.meta b/Assets/_Project/Tests/EditMode/PetSocialServiceTests.cs.meta new file mode 100644 index 00000000..50a6119a --- /dev/null +++ b/Assets/_Project/Tests/EditMode/PetSocialServiceTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7d0a1c894e2d26c4e90e5c2d37e8dee5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Tests/EditMode/StatTickServiceTests.cs b/Assets/_Project/Tests/EditMode/StatTickServiceTests.cs index c68e700d..6332c48e 100644 --- a/Assets/_Project/Tests/EditMode/StatTickServiceTests.cs +++ b/Assets/_Project/Tests/EditMode/StatTickServiceTests.cs @@ -5,51 +5,102 @@ namespace GeminiLab.Tests.EditMode { + /// + /// 数值规则文档口径: + /// - §11 清醒每现实 3 分钟 Energy -1;睡觉期间不自然衰减 + /// - §13 心情每 5 分钟向 50 回归 1 点(不越过目标) + /// - 饱食保留既有缓慢衰减 + /// public sealed class StatTickServiceTests { + private static PetContext CreateAwakeContext(PetRuntimeData data, PetStateValueSO config) + { + var context = new PetContext(data, config); + context.EnterState(IdleState.StateName); + return context; + } + [Test] - public void Tick_Awake_DecreasesEnergyAndRecoversMood() + public void Tick_Awake_DrainsEnergyAtDocRate() { PetStateValueSO config = ScriptableObject.CreateInstance(); - config.AwakeEnergyDecayPerSecond = 10f; - config.MoodRecoveryPerSecond = 5f; - config.SleepEnterEnergyThreshold = 20f; + config.AwakeEnergyDrainMinutesPerPoint = 3f; // §11:每 3 分钟 -1 → 180 秒 -1 - PetRuntimeData data = new() - { - Energy = 100f, - Mood = 20f - }; + PetRuntimeData data = new() { Energy = 100f, Mood = 50f }; + PetContext context = CreateAwakeContext(data, config); - PetContext context = new(data, config); - context.EnterState(IdleState.StateName); + new StatTickService().Tick(context, 180f); + + Assert.AreEqual(99f, data.Energy, 0.01f); + } - StatTickService service = new(); - service.Tick(context, 1f); + [Test] + public void Tick_Sleeping_DoesNotDrainEnergy() + { + PetStateValueSO config = ScriptableObject.CreateInstance(); + + PetRuntimeData data = new() { Energy = 40f, Mood = 50f }; + var context = new PetContext(data, config); + context.EnterState(SleepingState.StateName); - Assert.AreEqual(90f, data.Energy, 0.01f); - Assert.AreEqual(25f, data.Mood, 0.01f); + new StatTickService().Tick(context, 600f); + + Assert.AreEqual(40f, data.Energy, 0.01f); } [Test] - public void Tick_Sleeping_IncreasesEnergy() + public void Tick_MoodNeutralReturn_StepsOnePointPerInterval() { PetStateValueSO config = ScriptableObject.CreateInstance(); - config.SleepingEnergyRecoveryPerSecond = 12f; + config.MoodNeutralReturnIntervalSeconds = 300f; - PetRuntimeData data = new() - { - Energy = 40f, - Mood = 50f - }; + PetRuntimeData below = new() { Energy = 100f, Mood = 20f }; + new StatTickService().Tick(CreateAwakeContext(below, config), 300f); + Assert.AreEqual(21f, below.Mood, 0.01f); - PetContext context = new(data, config); - context.EnterState(SleepingState.StateName); + PetRuntimeData above = new() { Energy = 100f, Mood = 80f }; + new StatTickService().Tick(CreateAwakeContext(above, config), 300f); + Assert.AreEqual(79f, above.Mood, 0.01f); + } + + [Test] + public void Tick_MoodNeutralReturn_DoesNotCrossTarget() + { + PetStateValueSO config = ScriptableObject.CreateInstance(); + config.MoodNeutralReturnIntervalSeconds = 300f; + config.MoodNeutralTarget = 50f; + + PetRuntimeData below = new() { Energy = 100f, Mood = 49.5f }; + new StatTickService().Tick(CreateAwakeContext(below, config), 300f); + Assert.AreEqual(50f, below.Mood, 0.01f); + + PetRuntimeData above = new() { Energy = 100f, Mood = 50.5f }; + new StatTickService().Tick(CreateAwakeContext(above, config), 300f); + Assert.AreEqual(50f, above.Mood, 0.01f); + } + + [Test] + public void Tick_MoodNeutralReturn_AccumulatesMultipleIntervals() + { + PetStateValueSO config = ScriptableObject.CreateInstance(); + config.MoodNeutralReturnIntervalSeconds = 300f; + + PetRuntimeData data = new() { Energy = 100f, Mood = 20f }; + new StatTickService().Tick(CreateAwakeContext(data, config), 900f); + + Assert.AreEqual(23f, data.Mood, 0.01f); + } + + [Test] + public void Tick_Satiety_KeepsSlowDecay() + { + PetStateValueSO config = ScriptableObject.CreateInstance(); + config.SatietyDecayPerSecond = 0.005f; - StatTickService service = new(); - service.Tick(context, 1f); + PetRuntimeData data = new() { Energy = 100f, Mood = 50f, Satiety = 50f }; + new StatTickService().Tick(CreateAwakeContext(data, config), 100f); - Assert.AreEqual(52f, data.Energy, 0.01f); + Assert.AreEqual(49.5f, data.Satiety, 0.01f); } [Test]