角色能力,存档

This commit is contained in:
2026-05-19 11:50:21 +08:00
parent d25f237e76
commit 2dcb7a961a
136 changed files with 36035 additions and 27551 deletions

View File

@@ -0,0 +1,111 @@
using System.Collections;
using UnityEngine;
using BaseGames.Core.Events;
namespace BaseGames.Core
{
/// <summary>
/// 自动存档服务:在关键游戏事件发生后自动写盘,减少玩家因意外中断损失进度。
///
/// 触发时机:
/// · 进入新房间 — EVT_SceneLoaded
/// · 击败 Boss — EVT_BossFightEndedbool=true 表示玩家获胜)
/// · 获得新能力 — EVT_AbilityUnlockedStr
/// · 购买物品 — EVT_ShopPurchase
/// · 拾取关键物品 — EVT_CollectiblePickup
/// · 拾取 HP 容器 — EVT_MaxHPContainerPickedUp
/// · 开门/交互机关 — EVT_DoorOpened使用钥匙、触发机关等
/// · 完成任务 — EVT_QuestStateChangedState == Completed
///
/// 自动存档使用与手动存档(存档点)相同的存档槽。
/// 自动存档不会更改复活位置——SavePoint.OnSave 的 _isActivated 守卫确保
/// 只有玩家已坐过的存档点才会写入 Player.Scene 和 Meta.SavePointId。
///
/// 防抖:同一冷却窗口内的多次触发合并为一次写盘操作,避免频繁 I/O。
///
/// 挂载位置Persistent 场景根对象(与 GameServiceRegistrar 同级)。
/// </summary>
public class AutoSaveService : MonoBehaviour
{
[Header("触发事件频道")]
[Tooltip("EVT_SceneLoaded — 进入新房间时自动存档")]
[SerializeField] private StringEventChannelSO _onSceneLoaded;
[Tooltip("EVT_BossFightEnded — bool=true 表示玩家获胜,此时触发存档")]
[SerializeField] private BoolEventChannelSO _onBossFightEnded;
[Tooltip("EVT_AbilityUnlockedStr — 获得新能力时触发存档")]
[SerializeField] private StringEventChannelSO _onAbilityUnlocked;
[Tooltip("EVT_ShopPurchase — 购买物品后触发存档")]
[SerializeField] private ShopPurchaseEventChannelSO _onShopPurchase;
[Tooltip("EVT_CollectiblePickup — 拾取关键物品(护符、道具等)后触发存档")]
[SerializeField] private StringEventChannelSO _onCollectiblePickup;
[Tooltip("EVT_MaxHPContainerPickedUp — 拾取 HP 容器后触发存档")]
[SerializeField] private StringEventChannelSO _onMaxHPContainerPickedUp;
[Tooltip("EVT_DoorOpened — 使用钥匙或触发机关开门后触发存档")]
[SerializeField] private StringEventChannelSO _onDoorOpened;
[Tooltip("EVT_QuestStateChanged — 任务完成State == Completed时触发存档")]
[SerializeField] private QuestStateChangedEventChannel _onQuestStateChanged;
[Header("防抖")]
[Tooltip("两次自动存档之间的最短间隔(秒)。防止短时间内多次触发导致频繁写盘。")]
[SerializeField] [Range(0.5f, 10f)] private float _cooldownSeconds = 2f;
private bool _onCooldown;
private readonly CompositeDisposable _subs = new();
private void OnEnable()
{
_onSceneLoaded? .Subscribe(OnSceneLoaded) .AddTo(_subs);
_onBossFightEnded? .Subscribe(OnBossFightEnded) .AddTo(_subs);
_onAbilityUnlocked? .Subscribe(_ => RequestAutoSave("ability")) .AddTo(_subs);
_onShopPurchase? .Subscribe(_ => RequestAutoSave("shop")) .AddTo(_subs);
_onCollectiblePickup? .Subscribe(_ => RequestAutoSave("collectible")) .AddTo(_subs);
_onMaxHPContainerPickedUp? .Subscribe(_ => RequestAutoSave("hp_container")) .AddTo(_subs);
_onDoorOpened? .Subscribe(_ => RequestAutoSave("door")) .AddTo(_subs);
_onQuestStateChanged? .Subscribe(OnQuestStateChanged) .AddTo(_subs);
}
private void OnDisable() => _subs.Clear();
// ── 触发处理 ───────────────────────────────────────────────────────────
private void OnSceneLoaded(string _) => RequestAutoSave("scene");
private void OnBossFightEnded(bool won) { if (won) RequestAutoSave("boss"); }
private void OnQuestStateChanged(QuestStateChangedEvent e)
{
if (e.State == QuestState.Completed) RequestAutoSave("quest_complete");
}
// ── 防抖存档 ───────────────────────────────────────────────────────────
private void RequestAutoSave(string reason)
{
if (_onCooldown) return;
StartCoroutine(DoAutoSave(reason));
}
private IEnumerator DoAutoSave(string reason)
{
_onCooldown = true;
var svc = ServiceLocator.GetOrDefault<ISaveService>();
if (svc != null)
{
#if UNITY_EDITOR || DEVELOPMENT_BUILD
Debug.Log($"[AutoSave] 触发:{reason}");
#endif
// 与手动存档使用相同槽位fire-and-forget
_ = svc.SaveAsync(svc.ActiveSlot);
}
yield return new WaitForSecondsRealtime(_cooldownSeconds);
_onCooldown = false;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9bdcd96d81e589642a250987222c25e2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,48 @@
using UnityEngine;
using BaseGames.Core.Events;
namespace BaseGames.Core
{
/// <summary>
/// 检查点服务实现。挂载在 Persistent 场景根对象上,由 GameServiceRegistrar 注册。
/// 订阅 EVT_SceneLoaded换房间时自动清空当前检查点。
/// </summary>
public class CheckpointService : MonoBehaviour, ICheckpointService
{
[Header("事件 - 监听")]
[Tooltip("EVT_SceneLoaded — 收到后自动清空检查点")]
[SerializeField] private StringEventChannelSO _onSceneLoaded;
private bool _hasCheckpoint;
private Vector2 _checkpointPosition;
private readonly CompositeDisposable _subs = new();
private void OnEnable()
{
_onSceneLoaded?.Subscribe(OnSceneLoaded).AddTo(_subs);
}
private void OnDisable() => _subs.Clear();
// ── ICheckpointService ────────────────────────────────────────────────
public bool HasCheckpoint => _hasCheckpoint;
public Vector2 CheckpointPosition => _checkpointPosition;
public void RegisterCheckpoint(Vector2 position)
{
_hasCheckpoint = true;
_checkpointPosition = position;
}
public void ClearCheckpoint()
{
_hasCheckpoint = false;
}
// ── Private ───────────────────────────────────────────────────────────
private void OnSceneLoaded(string _) => ClearCheckpoint();
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 486e785d32d1c4c468a4eb0fd4cf1822
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -25,6 +25,7 @@ namespace BaseGames.Core
[SerializeField] private SceneService _sceneService;
[SerializeField] private EventChannelRegistry _eventChannelRegistry;
[SerializeField] private GameSaveManager _saveManager;
[SerializeField] private CheckpointService _checkpointService;
/// <summary>
/// Persistent 场景中唯一保留的主 AudioListener通常挂在主相机上
/// 在 Inspector 中绑定后可完全跳过 Awake 时的 FindObjectsOfType 全场景扫描。
@@ -69,12 +70,18 @@ namespace BaseGames.Core
else
Debug.LogWarning("[GameServiceRegistrar] ⚠ _saveManager 未绑定ISaveService 未注册。", this);
if (_checkpointService)
ServiceLocator.Register<ICheckpointService>(_checkpointService);
else
Debug.LogWarning("[GameServiceRegistrar] ⚠ _checkpointService 未绑定ICheckpointService 未注册。", this);
#if UNITY_EDITOR
var sb = new System.Text.StringBuilder("[GameServiceRegistrar] ✅ 服务注册完成:");
if (_deathRespawnService) sb.Append(" IDeathRespawnService");
if (_sceneService) sb.Append(" | ISceneService");
if (_eventChannelRegistry) sb.Append(" | IEventChannelRegistry");
if (_saveManager) sb.Append(" | ISaveService");
if (_checkpointService) sb.Append(" | ICheckpointService");
sb.Append(" | IAudioService(Null→等待覆盖)");
Debug.Log(sb.ToString(), this);
#endif

View File

@@ -0,0 +1,30 @@
using UnityEngine;
namespace BaseGames.Core
{
/// <summary>
/// 检查点服务接口。
/// 运行时追踪玩家在当前房间内最近经过的检查点坐标,不持久化至存档。
/// 换房间时由 CheckpointService 自动清空。
/// </summary>
public interface ICheckpointService
{
/// <summary>当前场景内是否存在已激活的检查点。</summary>
bool HasCheckpoint { get; }
/// <summary>
/// 最近激活的检查点世界坐标。
/// <see cref="HasCheckpoint"/> 为 false 时无意义。
/// </summary>
Vector2 CheckpointPosition { get; }
/// <summary>
/// 将指定位置登记为当前检查点(由 CheckpointMarker 调用)。
/// 同一场景内多次调用时以最新值为准。
/// </summary>
void RegisterCheckpoint(Vector2 position);
/// <summary>清空当前检查点(换场景时自动调用)。</summary>
void ClearCheckpoint();
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 985eec4caa88c87428f183c8b6a6e6ae
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: