多轮审查和修复
This commit is contained in:
22
Assets/Scripts/Quest/BaseGames.Quest.asmdef
Normal file
22
Assets/Scripts/Quest/BaseGames.Quest.asmdef
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"precompiledReferences": [],
|
||||
"name": "BaseGames.Quest",
|
||||
"defineConstraints": [],
|
||||
"noEngineReferences": false,
|
||||
"versionDefines": [],
|
||||
"rootNamespace": "BaseGames.Quest",
|
||||
"references": [
|
||||
"BaseGames.Core.Events",
|
||||
"BaseGames.Core.Save",
|
||||
"BaseGames.Player",
|
||||
"BaseGames.World",
|
||||
"BaseGames.Enemies",
|
||||
"BaseGames.Dialogue",
|
||||
"Unity.Addressables"
|
||||
],
|
||||
"autoReferenced": true,
|
||||
"overrideReferences": false,
|
||||
"includePlatforms": []
|
||||
}
|
||||
7
Assets/Scripts/Quest/BaseGames.Quest.asmdef.meta
Normal file
7
Assets/Scripts/Quest/BaseGames.Quest.asmdef.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4af005f1c262d81418344eb0fd372871
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
24
Assets/Scripts/Quest/BossRushSequenceSO.cs
Normal file
24
Assets/Scripts/Quest/BossRushSequenceSO.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Challenge
|
||||
{
|
||||
/// <summary>
|
||||
/// Boss Rush 序列数据(架构 22_QuestChallengeModule §11)。
|
||||
/// 资产路径: Assets/ScriptableObjects/Challenge/BossRush_{challengeId}.asset
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Challenge/BossRushSequence")]
|
||||
public class BossRushSequenceSO : ScriptableObject
|
||||
{
|
||||
[Serializable]
|
||||
public struct BossEntry
|
||||
{
|
||||
public string bossSceneName; // Boss 所在场景(Additive 加载)
|
||||
public string bossId;
|
||||
[Range(0f, 1f)]
|
||||
public float hpRestoreRatio; // 击败本 Boss 后玩家恢复 HP 比例(默认 0.3)
|
||||
}
|
||||
|
||||
public BossEntry[] bosses;
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Quest/BossRushSequenceSO.cs.meta
Normal file
11
Assets/Scripts/Quest/BossRushSequenceSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e5bfd130b5898de4aa0f9cbf3b27c68b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
25
Assets/Scripts/Quest/ChallengeEncounterSO.cs
Normal file
25
Assets/Scripts/Quest/ChallengeEncounterSO.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Challenge
|
||||
{
|
||||
/// <summary>
|
||||
/// 单波敌人配置(架构 22_QuestChallengeModule §10)。
|
||||
/// 资产路径: Assets/ScriptableObjects/Challenge/ENC_{challengeId}_{wave}.asset
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Challenge/Encounter")]
|
||||
public class ChallengeEncounterSO : ScriptableObject
|
||||
{
|
||||
[Serializable]
|
||||
public struct SpawnEntry
|
||||
{
|
||||
[Tooltip("Addressables key")]
|
||||
public string enemyAddressKey;
|
||||
public Transform spawnPoint;
|
||||
public int count;
|
||||
}
|
||||
|
||||
public SpawnEntry[] enemies;
|
||||
public float waveDelay; // 上波清空后等待多少秒生成本波
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Quest/ChallengeEncounterSO.cs.meta
Normal file
11
Assets/Scripts/Quest/ChallengeEncounterSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 797d2837ca726464aaf59a3431c13c76
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
138
Assets/Scripts/Quest/ChallengeRoomManager.cs
Normal file
138
Assets/Scripts/Quest/ChallengeRoomManager.cs
Normal file
@@ -0,0 +1,138 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using UnityEngine.AddressableAssets;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.Enemies;
|
||||
using BaseGames.Player;
|
||||
|
||||
namespace BaseGames.Challenge
|
||||
{
|
||||
/// <summary>
|
||||
/// 挑战房间流程管理器(架构 22_QuestChallengeModule §12)。
|
||||
/// 挂在挑战房间场景的 [ChallengeManager] GameObject 上,场景加载时自动启动挑战。
|
||||
/// </summary>
|
||||
public class ChallengeRoomManager : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private ChallengeRoomSO _challengeData;
|
||||
[SerializeField] private PlayerStats _player; // 由场景 Inspector 绑定
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private StringEventChannelSO _onChallengeCompleted; // EVT_ChallengeCompleted
|
||||
[SerializeField] private StringEventChannelSO _onChallengeFailed; // EVT_ChallengeFailed
|
||||
|
||||
private int _currentEncounterIndex;
|
||||
private int _remainingEnemies;
|
||||
private float _elapsedTime;
|
||||
private bool _isRunning;
|
||||
private bool _noHitViolated; // 架构 §12:requireNoHit 挑战是否被破坏
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_player != null) _player.OnDamaged += OnPlayerDamaged;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_player != null) _player.OnDamaged -= OnPlayerDamaged;
|
||||
}
|
||||
|
||||
private void OnPlayerDamaged() => _noHitViolated = true;
|
||||
|
||||
private void Start() => StartChallenge();
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!_isRunning) return;
|
||||
_elapsedTime += Time.deltaTime;
|
||||
|
||||
if (_challengeData.timeLimit > 0 && _elapsedTime >= _challengeData.timeLimit)
|
||||
FailChallenge();
|
||||
}
|
||||
|
||||
private void StartChallenge()
|
||||
{
|
||||
// 自动快速存档(失败后读档返回挑战入口)
|
||||
ServiceLocator.GetOrDefault<ISaveService>()?.QuickSave();
|
||||
_isRunning = true;
|
||||
_currentEncounterIndex = 0;
|
||||
_elapsedTime = 0f;
|
||||
_noHitViolated = false;
|
||||
SpawnWave(0);
|
||||
}
|
||||
|
||||
private void SpawnWave(int index)
|
||||
{
|
||||
if (_challengeData.encounters == null || index >= _challengeData.encounters.Length)
|
||||
{
|
||||
CompleteChallenge();
|
||||
return;
|
||||
}
|
||||
|
||||
var enc = _challengeData.encounters[index];
|
||||
_remainingEnemies = 0;
|
||||
|
||||
foreach (var entry in enc.enemies)
|
||||
{
|
||||
for (int i = 0; i < entry.count; i++)
|
||||
{
|
||||
_remainingEnemies++;
|
||||
Vector3 pos = entry.spawnPoint != null ? entry.spawnPoint.position : Vector3.zero;
|
||||
Addressables.InstantiateAsync(entry.enemyAddressKey, pos, Quaternion.identity)
|
||||
.Completed += handle =>
|
||||
{
|
||||
if (handle.Result != null &&
|
||||
handle.Result.TryGetComponent<EnemyBase>(out var enemy))
|
||||
{
|
||||
enemy.OnDied += OnEnemyDefeated;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEnemyDefeated()
|
||||
{
|
||||
_remainingEnemies = Mathf.Max(0, _remainingEnemies - 1);
|
||||
if (_remainingEnemies > 0) return;
|
||||
|
||||
_currentEncounterIndex++;
|
||||
if (_currentEncounterIndex >= _challengeData.encounters.Length)
|
||||
CompleteChallenge();
|
||||
else
|
||||
StartCoroutine(DelayedNextWave(_challengeData.encounters[_currentEncounterIndex].waveDelay));
|
||||
}
|
||||
|
||||
private IEnumerator DelayedNextWave(float delay)
|
||||
{
|
||||
yield return new WaitForSeconds(delay);
|
||||
SpawnWave(_currentEncounterIndex);
|
||||
}
|
||||
|
||||
private void CompleteChallenge()
|
||||
{
|
||||
_isRunning = false;
|
||||
|
||||
// requireNoHit 挑战:受到伤害则判定失败(架构 §12)
|
||||
if (_challengeData.requireNoHit && _noHitViolated)
|
||||
{
|
||||
FailChallenge();
|
||||
return;
|
||||
}
|
||||
|
||||
var reward = ServiceLocator.GetOrDefault<ISaveService>() is { } sm && sm.IsFirstClear(_challengeData.challengeId)
|
||||
? _challengeData.firstClearReward
|
||||
: _challengeData.repeatedReward;
|
||||
reward?.Apply(_player);
|
||||
|
||||
_onChallengeCompleted?.Raise(_challengeData.challengeId);
|
||||
}
|
||||
|
||||
private void FailChallenge()
|
||||
{
|
||||
_isRunning = false;
|
||||
_onChallengeFailed?.Raise(_challengeData.challengeId);
|
||||
ServiceLocator.GetOrDefault<ISaveService>()?.QuickLoad();
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Quest/ChallengeRoomManager.cs.meta
Normal file
11
Assets/Scripts/Quest/ChallengeRoomManager.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8b925d8017ed2384296b07a482d927e4
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
38
Assets/Scripts/Quest/ChallengeRoomSO.cs
Normal file
38
Assets/Scripts/Quest/ChallengeRoomSO.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Challenge
|
||||
{
|
||||
/// <summary>
|
||||
/// 挑战房间定义 SO(架构 22_QuestChallengeModule §9)。
|
||||
/// 资产路径: Assets/ScriptableObjects/Challenge/CHR_{challengeId}.asset
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Challenge/ChallengeRoom")]
|
||||
public class ChallengeRoomSO : ScriptableObject
|
||||
{
|
||||
[Header("标识")]
|
||||
public string challengeId;
|
||||
public string displayName;
|
||||
public ChallengeType challengeType;
|
||||
|
||||
[Header("波次(非 BossRush)")]
|
||||
public ChallengeEncounterSO[] encounters;
|
||||
|
||||
[Header("Boss Rush(BossRush 类型专用)")]
|
||||
public BossRushSequenceSO bossRushSequence;
|
||||
|
||||
[Header("限制条件")]
|
||||
public float timeLimit; // 0 = 无时限
|
||||
public bool requireNoHit;
|
||||
public int minComboRequired; // 0 = 无要求
|
||||
|
||||
[Header("奖励")]
|
||||
public BaseGames.Quest.RewardSO firstClearReward; // 首次通关奖励
|
||||
public BaseGames.Quest.RewardSO repeatedReward; // 重复通关奖励
|
||||
|
||||
[Header("解锁条件")]
|
||||
public string[] prerequisiteBossIds; // 需击败的 Boss ID
|
||||
}
|
||||
|
||||
public enum ChallengeType { Survival, TimeTrial, BossRush, NoHit }
|
||||
}
|
||||
11
Assets/Scripts/Quest/ChallengeRoomSO.cs.meta
Normal file
11
Assets/Scripts/Quest/ChallengeRoomSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1c5d17a20c497134f941e3f864f51539
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
53
Assets/Scripts/Quest/ChallengeRoomTrigger.cs
Normal file
53
Assets/Scripts/Quest/ChallengeRoomTrigger.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.World;
|
||||
|
||||
namespace BaseGames.Challenge
|
||||
{
|
||||
/// <summary>
|
||||
/// 挑战房间入口触发物(架构 22_QuestChallengeModule §13)。
|
||||
/// 实现 IInteractable,玩家交互后通过事件频道触发挑战场景加载。
|
||||
/// ⚠️ SceneLoader 无 Instance;通过 EVT_SceneLoadRequest 频道触发加载(架构 03 §3)。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Collider2D))]
|
||||
public class ChallengeRoomTrigger : MonoBehaviour, IInteractable
|
||||
{
|
||||
[SerializeField] private ChallengeRoomSO _challengeData;
|
||||
[SerializeField] private string _challengeSceneName;
|
||||
[SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest; // EVT_SceneLoadRequest
|
||||
|
||||
// ── IInteractable ──────────────────────────────────────────────────
|
||||
public string InteractPrompt => _challengeData != null
|
||||
? $"进入挑战:{_challengeData.displayName}"
|
||||
: "进入挑战";
|
||||
public bool CanInteract => IsUnlocked();
|
||||
|
||||
public void Interact(Transform player)
|
||||
{
|
||||
if (!IsUnlocked()) return;
|
||||
_onSceneLoadRequest?.Raise(new SceneLoadRequest
|
||||
{
|
||||
SceneName = _challengeSceneName,
|
||||
EntryTransitionId = string.Empty,
|
||||
ShowLoadingScreen = false,
|
||||
IsRespawn = false,
|
||||
});
|
||||
}
|
||||
|
||||
public void OnPlayerEnterRange(Transform player) { }
|
||||
public void OnPlayerExitRange() { }
|
||||
|
||||
// ── 私有 ─────────────────────────────────────────────────────────
|
||||
|
||||
private bool IsUnlocked()
|
||||
{
|
||||
if (_challengeData?.prerequisiteBossIds == null) return true;
|
||||
var sm = ServiceLocator.GetOrDefault<ISaveService>();
|
||||
if (sm == null) return false;
|
||||
foreach (var bossId in _challengeData.prerequisiteBossIds)
|
||||
if (!sm.IsBossDefeated(bossId)) return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Quest/ChallengeRoomTrigger.cs.meta
Normal file
11
Assets/Scripts/Quest/ChallengeRoomTrigger.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3fa1ff94d6205904385e498e612517a8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
24
Assets/Scripts/Quest/IQuestManager.cs
Normal file
24
Assets/Scripts/Quest/IQuestManager.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using BaseGames.Player;
|
||||
using QuestStateEnum = BaseGames.Core.Events.QuestState;
|
||||
|
||||
namespace BaseGames.Quest
|
||||
{
|
||||
/// <summary>
|
||||
/// 任务管理器的公开契约。ServiceLocator.Get<IQuestManager>() 获取实例,
|
||||
/// 避免外部代码直接依赖 QuestManager 具体类型。
|
||||
/// </summary>
|
||||
public interface IQuestManager
|
||||
{
|
||||
/// <summary>接取任务(幂等)。</summary>
|
||||
void AcceptQuest(string questId);
|
||||
|
||||
/// <summary>完成任务并发放奖励。</summary>
|
||||
void CompleteQuest(string questId, PlayerStats player);
|
||||
|
||||
/// <summary>返回当前任务状态。未知 questId 返回 Unavailable。</summary>
|
||||
QuestStateEnum GetState(string questId);
|
||||
|
||||
/// <summary>判断任务是否满足完成条件。</summary>
|
||||
bool IsReadyToComplete(string questId);
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Quest/IQuestManager.cs.meta
Normal file
11
Assets/Scripts/Quest/IQuestManager.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e16bf761c10e04443b7ac32e5724c088
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
82
Assets/Scripts/Quest/QuestGiver.cs
Normal file
82
Assets/Scripts/Quest/QuestGiver.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Dialogue;
|
||||
using BaseGames.Player;
|
||||
using QuestStateEnum = BaseGames.Core.Events.QuestState;
|
||||
using SL = BaseGames.Core.ServiceLocator;
|
||||
|
||||
namespace BaseGames.Quest
|
||||
{
|
||||
/// <summary>
|
||||
/// 可发布/完成任务的 NPC(架构 22_QuestChallengeModule §6)。
|
||||
/// 继承 InteractableNPC,根据任务状态切换对话版本,在交互时处理任务接收/完成。
|
||||
/// </summary>
|
||||
public class QuestGiver : InteractableNPC
|
||||
{
|
||||
[Header("任务")]
|
||||
[SerializeField] private QuestSO[] _offeredQuests; // 该 NPC 可提供的所有任务(按优先级排列)
|
||||
|
||||
[Header("对话版本(根据任务状态切换)")]
|
||||
[SerializeField] private DialogueSequenceSO _availableDialogue; // 任务可接时
|
||||
[SerializeField] private DialogueSequenceSO _activeDialogue; // 任务进行中
|
||||
[SerializeField] private DialogueSequenceSO _readyDialogue; // 完成条件满足时
|
||||
[SerializeField] private DialogueSequenceSO _completedDialogue; // 任务已完成后
|
||||
|
||||
// ── InteractableNPC 覆盖 ──────────────────────────────────────────────
|
||||
|
||||
protected override void Interact_Internal(Transform player)
|
||||
{
|
||||
var qm = SL.GetOrDefault<IQuestManager>();
|
||||
var quest = GetCurrentQuest(qm);
|
||||
if (quest == null || qm == null) return;
|
||||
|
||||
var state = qm.GetState(quest.questId);
|
||||
|
||||
if (state == QuestStateEnum.Available)
|
||||
{
|
||||
qm.AcceptQuest(quest.questId);
|
||||
}
|
||||
else if (qm.IsReadyToComplete(quest.questId))
|
||||
{
|
||||
// 直接从 player 获取 PlayerStats,避免对 PlayerController 的程序集依赖
|
||||
var stats = player.GetComponentInParent<PlayerStats>();
|
||||
qm.CompleteQuest(quest.questId, stats);
|
||||
}
|
||||
}
|
||||
|
||||
protected override DialogueSequenceSO GetCurrentDialogue()
|
||||
{
|
||||
var qm = SL.GetOrDefault<IQuestManager>();
|
||||
var quest = GetCurrentQuest(qm);
|
||||
if (quest == null || qm == null) return base.GetCurrentDialogue();
|
||||
|
||||
var state = qm.GetState(quest.questId);
|
||||
|
||||
return state switch
|
||||
{
|
||||
QuestStateEnum.Available => _availableDialogue,
|
||||
QuestStateEnum.Active => qm.IsReadyToComplete(quest.questId)
|
||||
? _readyDialogue : _activeDialogue,
|
||||
QuestStateEnum.Completed => _completedDialogue,
|
||||
_ => base.GetCurrentDialogue(),
|
||||
};
|
||||
}
|
||||
|
||||
// ── 私有辅助 ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>返回当前处于 Available 或 Active 状态的第一个任务。</summary>
|
||||
private QuestSO GetCurrentQuest(IQuestManager qm = null)
|
||||
{
|
||||
if (_offeredQuests == null) return null;
|
||||
qm ??= SL.GetOrDefault<IQuestManager>();
|
||||
if (qm == null) return null;
|
||||
foreach (var q in _offeredQuests)
|
||||
{
|
||||
if (q == null) continue;
|
||||
var s = qm.GetState(q.questId);
|
||||
if (s == QuestStateEnum.Available || s == QuestStateEnum.Active) return q;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Quest/QuestGiver.cs.meta
Normal file
11
Assets/Scripts/Quest/QuestGiver.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f175814e1e840a24abe35de3e813abc6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
270
Assets/Scripts/Quest/QuestManager.cs
Normal file
270
Assets/Scripts/Quest/QuestManager.cs
Normal file
@@ -0,0 +1,270 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.Core.Save;
|
||||
using BaseGames.Player;
|
||||
using QuestStateEnum = BaseGames.Core.Events.QuestState;
|
||||
|
||||
namespace BaseGames.Quest
|
||||
{
|
||||
/// <summary>
|
||||
/// 运行时任务管理器(架构 22_QuestChallengeModule §5)。
|
||||
/// 挂在 Persistent 场景 [GameManagers] 下。
|
||||
/// 事件驱动追踪目标进度,不主动轮询。
|
||||
/// 实现 ISaveable,通过 SaveManager 持久化任务状态。
|
||||
/// </summary>
|
||||
public class QuestManager : MonoBehaviour, ISaveable, IQuestManager
|
||||
{
|
||||
// ── Inspector ────────────────────────────────────────────────────────
|
||||
[SerializeField] private QuestSO[] _allQuests;
|
||||
|
||||
[Header("Event Channels(监听)")]
|
||||
[SerializeField] private StringEventChannelSO _onEnemyDied; // EVT_EnemyDied(enemyId)
|
||||
[SerializeField] private StringEventChannelSO _onCollectiblePickup; // EVT_CollectiblePickup(itemId)
|
||||
[SerializeField] private StringEventChannelSO _onSceneLoaded; // EVT_SceneLoaded(sceneName)
|
||||
[SerializeField] private StringEventChannelSO _onNpcDialogueCompleted; // EVT_NpcDialogueCompleted(npcId)
|
||||
|
||||
[Header("Event Channels(广播)")]
|
||||
[SerializeField] private StringEventChannelSO _onQuestStarted; // questId
|
||||
[SerializeField] private StringEventChannelSO _onQuestCompleted; // questId
|
||||
[SerializeField] private StringEventChannelSO _onQuestFailed; // questId
|
||||
[SerializeField] private QuestObjectiveEventChannelSO _onObjectiveUpdated;
|
||||
|
||||
// ── Runtime State ────────────────────────────────────────────────────
|
||||
private readonly Dictionary<string, QuestStateEnum> _questStates = new();
|
||||
private readonly Dictionary<string, QuestObjectiveState> _objectiveStates = new();
|
||||
/// <summary>questId → QuestSO 快速查找表(由 Awake 构建,将 GetQuestSO O(n) 降为 O(1))。</summary>
|
||||
private Dictionary<string, QuestSO> _questIndex;
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
public StringEventChannelSO OnQuestStarted => _onQuestStarted;
|
||||
public StringEventChannelSO OnQuestCompleted => _onQuestCompleted;
|
||||
|
||||
/// <summary>供 SaveManager 迭代的任务状态字典(只读视图)。</summary>
|
||||
public IReadOnlyDictionary<string, QuestStateEnum> QuestStates => _questStates;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (BaseGames.Core.ServiceLocator.GetOrDefault<IQuestManager>() != null) { Destroy(gameObject); return; }
|
||||
BaseGames.Core.ServiceLocator.Register<IQuestManager>(this);
|
||||
|
||||
// 构建任务字典索引,将 GetQuestSO 变为 O(1)
|
||||
_questIndex = new Dictionary<string, QuestSO>(_allQuests?.Length ?? 0);
|
||||
if (_allQuests != null)
|
||||
foreach (var q in _allQuests)
|
||||
if (q != null && !string.IsNullOrEmpty(q.questId))
|
||||
_questIndex[q.questId] = q;
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_onEnemyDied?.Subscribe(HandleEnemyDefeated).AddTo(_subs);
|
||||
_onCollectiblePickup?.Subscribe(HandleItemCollected).AddTo(_subs);
|
||||
_onSceneLoaded?.Subscribe(HandleSceneLoaded).AddTo(_subs);
|
||||
_onNpcDialogueCompleted?.Subscribe(HandleNpcDialogue).AddTo(_subs);
|
||||
BaseGames.Core.ServiceLocator.GetOrDefault<BaseGames.Core.Save.ISaveableRegistry>()?.Register(this);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_subs.Clear();
|
||||
BaseGames.Core.ServiceLocator.GetOrDefault<BaseGames.Core.Save.ISaveableRegistry>()?.Unregister(this);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
BaseGames.Core.ServiceLocator.Unregister<IQuestManager>(this);
|
||||
}
|
||||
|
||||
// ── 公共 API ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>NPC 接受任务时调用。</summary>
|
||||
public void AcceptQuest(string questId)
|
||||
{
|
||||
if (!CanAccept(questId)) return;
|
||||
_questStates[questId] = QuestStateEnum.Active;
|
||||
_onQuestStarted?.Raise(questId);
|
||||
}
|
||||
|
||||
/// <summary>NPC 完成任务时调用。</summary>
|
||||
public void CompleteQuest(string questId, PlayerStats player)
|
||||
{
|
||||
if (!IsReadyToComplete(questId)) return;
|
||||
var quest = GetQuestSO(questId);
|
||||
quest.reward?.Apply(player);
|
||||
_questStates[questId] = QuestStateEnum.Completed;
|
||||
_onQuestCompleted?.Raise(questId);
|
||||
|
||||
// 解锁后续任务(分支)
|
||||
if (quest.branches != null)
|
||||
{
|
||||
foreach (var branch in quest.branches)
|
||||
{
|
||||
if (string.IsNullOrEmpty(branch.conditionQuestId) ||
|
||||
GetState(branch.conditionQuestId) == QuestStateEnum.Completed)
|
||||
{
|
||||
if (branch.nextQuest != null)
|
||||
_questStates[branch.nextQuest.questId] = QuestStateEnum.Available;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public QuestStateEnum GetState(string questId)
|
||||
=> _questStates.TryGetValue(questId, out var s) ? s : QuestStateEnum.Unavailable;
|
||||
|
||||
public bool IsReadyToComplete(string questId)
|
||||
{
|
||||
var quest = GetQuestSO(questId);
|
||||
if (quest == null || GetState(questId) != QuestStateEnum.Active) return false;
|
||||
if (quest.objectives == null) return true;
|
||||
foreach (var obj in quest.objectives)
|
||||
{
|
||||
if (!obj.IsOptional && !IsObjectiveComplete(obj)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── ISaveable ─────────────────────────────────────────────────────────
|
||||
|
||||
public void OnSave(SaveData data)
|
||||
{
|
||||
data.Quests.QuestStates.Clear();
|
||||
data.Quests.AvailableQuestIds.Clear();
|
||||
foreach (var (id, state) in _questStates)
|
||||
{
|
||||
data.Quests.QuestStates[id] = new BaseGames.Core.Save.QuestState
|
||||
{
|
||||
Status = state.ToString(),
|
||||
ObjectiveIndex = 0,
|
||||
ProgressCounts = BuildProgressList(id),
|
||||
};
|
||||
if (state == QuestStateEnum.Available) data.Quests.AvailableQuestIds.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnLoad(SaveData data)
|
||||
{
|
||||
_questStates.Clear();
|
||||
_objectiveStates.Clear();
|
||||
foreach (var (id, saved) in data.Quests.QuestStates)
|
||||
{
|
||||
if (System.Enum.TryParse<QuestStateEnum>(saved.Status, out var parsedState))
|
||||
_questStates[id] = parsedState;
|
||||
|
||||
// 恢复各目标进度
|
||||
var quest = GetQuestSO(id);
|
||||
if (quest?.objectives != null && saved.ProgressCounts != null)
|
||||
{
|
||||
for (int i = 0; i < quest.objectives.Length && i < saved.ProgressCounts.Count; i++)
|
||||
{
|
||||
var obj = quest.objectives[i];
|
||||
if (obj == null) continue;
|
||||
if (!_objectiveStates.TryGetValue(obj.objectiveId, out var os))
|
||||
os = _objectiveStates[obj.objectiveId] = new QuestObjectiveState();
|
||||
os.progressCount = saved.ProgressCounts[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 私有辅助 ─────────────────────────────────────────────────────────
|
||||
|
||||
private bool CanAccept(string questId)
|
||||
{
|
||||
if (GetState(questId) != QuestStateEnum.Available) return false;
|
||||
var quest = GetQuestSO(questId);
|
||||
if (quest?.prerequisiteQuestIds == null) return true;
|
||||
foreach (var pre in quest.prerequisiteQuestIds)
|
||||
if (GetState(pre) != QuestStateEnum.Completed) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool IsObjectiveComplete(QuestObjectiveSO obj)
|
||||
{
|
||||
_objectiveStates.TryGetValue(obj.objectiveId, out var s);
|
||||
s ??= new QuestObjectiveState();
|
||||
return obj.EvaluateCompletion(s);
|
||||
}
|
||||
|
||||
private List<int> BuildProgressList(string questId)
|
||||
{
|
||||
var list = new List<int>();
|
||||
var quest = GetQuestSO(questId);
|
||||
if (quest?.objectives == null) return list;
|
||||
foreach (var obj in quest.objectives)
|
||||
{
|
||||
_objectiveStates.TryGetValue(obj?.objectiveId ?? string.Empty, out var os);
|
||||
list.Add(os?.progressCount ?? 0);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
// ── 事件处理 ─────────────────────────────────────────────────────────
|
||||
|
||||
private void HandleEnemyDefeated(string enemyId)
|
||||
{
|
||||
ForEachActiveObjective<DefeatEnemyObjective>(obj =>
|
||||
{
|
||||
if (obj.targetEnemyId == enemyId)
|
||||
IncrementProgress(obj.objectiveId);
|
||||
});
|
||||
}
|
||||
|
||||
private void HandleItemCollected(string itemId)
|
||||
{
|
||||
ForEachActiveObjective<CollectItemObjective>(obj =>
|
||||
{
|
||||
if (obj.itemId == itemId)
|
||||
IncrementProgress(obj.objectiveId);
|
||||
});
|
||||
}
|
||||
|
||||
private void HandleNpcDialogue(string npcId)
|
||||
{
|
||||
ForEachActiveObjective<TalkToNPCObjective>(obj =>
|
||||
{
|
||||
if (obj.targetNpcId == npcId)
|
||||
IncrementProgress(obj.objectiveId);
|
||||
});
|
||||
}
|
||||
|
||||
private void HandleSceneLoaded(string sceneName)
|
||||
{
|
||||
ForEachActiveObjective<ReachAreaObjective>(obj =>
|
||||
{
|
||||
if (obj.sceneName == sceneName)
|
||||
IncrementProgress(obj.objectiveId);
|
||||
});
|
||||
}
|
||||
|
||||
private void ForEachActiveObjective<T>(System.Action<T> action) where T : QuestObjectiveSO
|
||||
{
|
||||
foreach (var (qid, state) in _questStates)
|
||||
{
|
||||
if (state != QuestStateEnum.Active) continue;
|
||||
var quest = GetQuestSO(qid);
|
||||
if (quest?.objectives == null) continue;
|
||||
foreach (var obj in quest.objectives)
|
||||
{
|
||||
if (obj is T typed) action(typed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void IncrementProgress(string objectiveId)
|
||||
{
|
||||
if (!_objectiveStates.TryGetValue(objectiveId, out var s))
|
||||
s = _objectiveStates[objectiveId] = new QuestObjectiveState();
|
||||
s.progressCount++;
|
||||
_onObjectiveUpdated?.Raise(new QuestObjectiveEvent
|
||||
{
|
||||
ObjectiveId = objectiveId,
|
||||
Progress = s.progressCount,
|
||||
});
|
||||
}
|
||||
|
||||
private QuestSO GetQuestSO(string id)
|
||||
=> _questIndex != null && _questIndex.TryGetValue(id, out var q) ? q : null;
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Quest/QuestManager.cs.meta
Normal file
11
Assets/Scripts/Quest/QuestManager.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bc8e06db32984c04a868cda5c27c0777
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
81
Assets/Scripts/Quest/QuestObjectiveSO.cs
Normal file
81
Assets/Scripts/Quest/QuestObjectiveSO.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Player;
|
||||
|
||||
namespace BaseGames.Quest
|
||||
{
|
||||
/// <summary>
|
||||
/// 任务目标基类(抽象,架构 22_QuestChallengeModule §3)。
|
||||
/// 所有具体目标类型均继承此类,通过多态实现零代码扩展。
|
||||
/// 每种目标在事件驱动下由 QuestManager 调用 EvaluateCompletion()。
|
||||
/// </summary>
|
||||
public abstract class QuestObjectiveSO : ScriptableObject
|
||||
{
|
||||
[Header("标识")]
|
||||
public string objectiveId;
|
||||
[TextArea(1, 4)]
|
||||
public string displayText; // 任务日志中显示的文本
|
||||
public bool IsOptional; // 可选目标(完成加奖励但不阻塞任务)
|
||||
|
||||
/// <summary>根据当前进度判断目标是否完成。</summary>
|
||||
public abstract bool EvaluateCompletion(QuestObjectiveState state);
|
||||
}
|
||||
|
||||
// ── 运行时目标进度状态(由 QuestManager 管理,不继承 SO)────────────────
|
||||
|
||||
public class QuestObjectiveState
|
||||
{
|
||||
public bool completed = false;
|
||||
public int progressCount = 0;
|
||||
}
|
||||
|
||||
// ── 具体目标类型 ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>与指定 NPC 对话后完成。</summary>
|
||||
[CreateAssetMenu(menuName = "Quest/Objective/TalkToNPC")]
|
||||
public class TalkToNPCObjective : QuestObjectiveSO
|
||||
{
|
||||
public string targetNpcId;
|
||||
|
||||
public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= 1;
|
||||
}
|
||||
|
||||
/// <summary>击败指定 ID 的敌人若干次。</summary>
|
||||
[CreateAssetMenu(menuName = "Quest/Objective/Defeat")]
|
||||
public class DefeatEnemyObjective : QuestObjectiveSO
|
||||
{
|
||||
public string targetEnemyId;
|
||||
[Min(1)] public int defeatCount = 1;
|
||||
|
||||
public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= defeatCount;
|
||||
}
|
||||
|
||||
/// <summary>收集指定 ID 的物品若干件。</summary>
|
||||
[CreateAssetMenu(menuName = "Quest/Objective/Collect")]
|
||||
public class CollectItemObjective : QuestObjectiveSO
|
||||
{
|
||||
public string itemId;
|
||||
[Min(1)] public int collectCount = 1;
|
||||
|
||||
public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= collectCount;
|
||||
}
|
||||
|
||||
/// <summary>到达指定场景/区域标记点后完成。</summary>
|
||||
[CreateAssetMenu(menuName = "Quest/Objective/Reach")]
|
||||
public class ReachAreaObjective : QuestObjectiveSO
|
||||
{
|
||||
public string sceneName; // 需到达的场景
|
||||
public string markerTag; // 场景内的目标标记 Tag(预留)
|
||||
|
||||
public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= 1;
|
||||
}
|
||||
|
||||
/// <summary>使用指定能力若干次后完成。</summary>
|
||||
[CreateAssetMenu(menuName = "Quest/Objective/UseSkill")]
|
||||
public class UseSkillObjective : QuestObjectiveSO
|
||||
{
|
||||
public AbilityType requiredAbility;
|
||||
[Min(1)] public int useCount = 1;
|
||||
|
||||
public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= useCount;
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Quest/QuestObjectiveSO.cs.meta
Normal file
11
Assets/Scripts/Quest/QuestObjectiveSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cacd4b7c5f4fc7947acf8bb8b2b01dd2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
46
Assets/Scripts/Quest/QuestSO.cs
Normal file
46
Assets/Scripts/Quest/QuestSO.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using BaseGames.Dialogue;
|
||||
|
||||
namespace BaseGames.Quest
|
||||
{
|
||||
/// <summary>
|
||||
/// 任务定义 SO(架构 22_QuestChallengeModule §2)。
|
||||
/// 资产路径: Assets/ScriptableObjects/Quest/Quest_{questId}.asset
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Quest/Quest")]
|
||||
public class QuestSO : ScriptableObject
|
||||
{
|
||||
[Header("标识")]
|
||||
public string questId; // 唯一 ID,如 "Quest_FindMushroom"
|
||||
public string displayName;
|
||||
[TextArea(2, 6)]
|
||||
public string description;
|
||||
public Sprite icon;
|
||||
|
||||
[Header("目标链")]
|
||||
public QuestObjectiveSO[] objectives; // 按顺序完成,全部完成 = 可交完
|
||||
|
||||
[Header("前置条件")]
|
||||
public string[] prerequisiteQuestIds; // 所有前置任务 Completed 后才可接
|
||||
public int minAffinityToAccept; // NPC 好感度门槛(0 = 无限制)
|
||||
|
||||
[Header("奖励")]
|
||||
public RewardSO reward;
|
||||
|
||||
[Header("失败条件(可选)")]
|
||||
public bool canFail;
|
||||
public QuestObjectiveSO failCondition;
|
||||
|
||||
[Header("完成后续任务(分支)")]
|
||||
public QuestBranch[] branches;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class QuestBranch
|
||||
{
|
||||
public string conditionQuestId; // 若此任务已完成 → 走本分支(空 = 默认)
|
||||
public QuestSO nextQuest;
|
||||
public string npcDialogueKey; // 触发 NPC 对话 key
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Quest/QuestSO.cs.meta
Normal file
11
Assets/Scripts/Quest/QuestSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: da82ee299b054e84ba9f7ab718a75a89
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
46
Assets/Scripts/Quest/RewardSO.cs
Normal file
46
Assets/Scripts/Quest/RewardSO.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Player;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.Quest
|
||||
{
|
||||
/// <summary>
|
||||
/// 任务奖励 SO(架构 22_QuestChallengeModule §4)。
|
||||
/// 由 QuestManager.CompleteQuest() 调用 Apply() 发放奖励。
|
||||
/// 资产路径: Assets/ScriptableObjects/Quest/Reward_{questId}.asset
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Quest/Reward")]
|
||||
public class RewardSO : ScriptableObject
|
||||
{
|
||||
public int geo; // Geo 货币奖励
|
||||
public int soulBonus; // 灵魂槽扩展(+MaxSoulPower)
|
||||
public string[] itemIds; // 物品/护符 ID 列表(通过 InventoryManager 发放)
|
||||
public int affinityBonus; // 对发布 NPC 的好感度增量(存入 SaveData.World.NpcRelations)
|
||||
public string unlockDialogueKey; // 解锁 NPC 新台词集合 key(架构 §4)
|
||||
|
||||
[Tooltip("是否解锁能力(AbilityType 无 None 值,用 bool 标识)")]
|
||||
public bool unlocksAbility; // ⚠️ AbilityType 无 None,用 bool 标识
|
||||
public AbilityType unlockedAbility; // 仅当 unlocksAbility == true 有效
|
||||
|
||||
[Header("物品发放事件")]
|
||||
[Tooltip("EVT_CollectiblePickup:向 QuestManager/EquipmentManager 广播 itemId")]
|
||||
[SerializeField] private StringEventChannelSO _onCollectiblePickup;
|
||||
|
||||
/// <summary>将奖励应用到游戏状态(由 QuestManager.CompleteQuest 调用)。</summary>
|
||||
public void Apply(PlayerStats player)
|
||||
{
|
||||
if (player == null) return;
|
||||
|
||||
if (geo > 0) player.AddGeo(geo);
|
||||
if (soulBonus > 0) player.AddSoulPower(soulBonus);
|
||||
if (unlocksAbility) player.UnlockAbility(unlockedAbility);
|
||||
|
||||
// 通过 EVT_CollectiblePickup 事件频道广播每个物品 ID
|
||||
if (itemIds != null && _onCollectiblePickup != null)
|
||||
{
|
||||
foreach (var id in itemIds)
|
||||
_onCollectiblePickup.Raise(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Quest/RewardSO.cs.meta
Normal file
11
Assets/Scripts/Quest/RewardSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 79c456a024456b944a48184ed174a717
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user