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]