多轮审查和修复

This commit is contained in:
2026-05-12 15:34:08 +08:00
parent f55d2a57c3
commit ebbbb7332e
805 changed files with 838724 additions and 1905 deletions

View File

@@ -8,7 +8,12 @@
"versionDefines": [],
"rootNamespace": "BaseGames.Dialogue",
"references": [
"BaseGames.Core.Events"
"BaseGames.Core",
"BaseGames.Core.Events",
"BaseGames.Input",
"BaseGames.World",
"Unity.TextMeshPro",
"Unity.InputSystem"
],
"autoReferenced": true,
"overrideReferences": false,

View File

@@ -3,9 +3,7 @@ using UnityEngine;
namespace BaseGames.Dialogue
{
/// <summary>
/// 对话数据 ScriptableObject(存根)
/// Phase 3 Dialogue 模块实现时填充完整字段对话行、Speaker、选项分支等
/// 此处仅声明类型,供 DialogueEventChannelSO 引用。
/// 对话数据 ScriptableObject。
/// </summary>
[CreateAssetMenu(menuName = "Dialogue/DialogueData")]
public class DialogueDataSO : ScriptableObject
@@ -17,7 +15,7 @@ namespace BaseGames.Dialogue
public string speakerName;
[TextArea(3, 8)]
[Tooltip("Phase 3 前的占位文本正式内容在 DialogueLineSO[] ")]
[Tooltip("对话内容占位文本正式内容在 DialogueLineSO[] ")]
public string placeholderText;
}
}

View File

@@ -0,0 +1,141 @@
using System.Collections;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Input;
using BaseGames.World;
using UnityEngine;
namespace BaseGames.Dialogue
{
/// <summary>
/// 对话管理器(架构 14_NarrativeModule §4
/// 驱动 DialogueUI 打字机效果;管理 Action Map 切换;向 QuestManager 广播对话完成事件。
/// 在 Awake 中注册到 ServiceLocator。
/// </summary>
public class DialogueManager : MonoBehaviour, IDialogueService
{
[SerializeField] private DialogueUI _dialogueBox;
[SerializeField] private InputReaderSO _inputReader;
[SerializeField] private WorldStateRegistry _worldState;
[Header("事件频道")]
[SerializeField] private VoidEventChannelSO _onDialogueStarted;
[SerializeField] private VoidEventChannelSO _onDialogueEnded;
[SerializeField] private StringEventChannelSO _onNpcDialogueCompleted; // → EVT_NpcDialogueCompleted (npcId)
private bool _skipRequested;
/// <summary>当前是否有对话正在播放。</summary>
public bool IsDialogueActive { get; private set; }
// ── 生命周期 ──────────────────────────────────────────────────────
private void Awake()
{
if (ServiceLocator.GetOrDefault<IDialogueService>() != null) { Destroy(gameObject); return; }
ServiceLocator.Register<IDialogueService>(this);
}
private void OnDestroy()
{
ServiceLocator.Unregister<IDialogueService>(this);
}
private void OnEnable()
{
if (_inputReader != null) _inputReader.SubmitEvent += OnSubmit;
}
private void OnDisable()
{
if (_inputReader != null) _inputReader.SubmitEvent -= OnSubmit;
}
// ── 公开 API ──────────────────────────────────────────────────────
/// <summary>
/// 启动对话序列。若已有对话在播放则忽略新请求。
/// 由 InteractableNPC.Interact() 调用。
/// </summary>
/// <param name="sequence">要播放的对话序列 SO。</param>
/// <param name="npcId">NPC 标识符,对话结束时随 EVT_NpcDialogueCompleted 广播。</param>
public void StartDialogue(DialogueSequenceSO sequence, string npcId = "")
{
if (IsDialogueActive || sequence == null) return;
IsDialogueActive = true;
_skipRequested = false;
// 切换到 UI Action Map禁用玩家移动输入
_inputReader.EnableUIInput();
_onDialogueStarted?.Raise();
StartCoroutine(PlaySequence(sequence, npcId));
}
// ── 输入回调 ──────────────────────────────────────────────────────
private void OnSubmit() => _skipRequested = true;
// ── 内部协程 ──────────────────────────────────────────────────────
private IEnumerator PlaySequence(DialogueSequenceSO sequence, string npcId)
{
// 选择条件变体(查询 ConditionalVariant.conditionFlag — 未实现 WorldStateRegistry 查询时直接使用默认序列)
var resolved = ResolveVariant(sequence);
foreach (var line in resolved.lines)
{
_skipRequested = false;
_dialogueBox.ShowLine(line);
// 等待打字完成,期间允许跳过
yield return new WaitUntil(() => !_dialogueBox.IsTyping || _skipRequested);
if (_dialogueBox.IsTyping) _dialogueBox.SkipTyping();
// 等待玩家按 Submit 推进下一行
_skipRequested = false;
yield return new WaitUntil(() => _skipRequested);
}
EndDialogue(npcId);
}
private void EndDialogue(string npcId)
{
_dialogueBox.Hide();
IsDialogueActive = false;
// 恢复 Gameplay Action Map
_inputReader.EnableGameplayInput();
_onDialogueEnded?.Raise();
if (!string.IsNullOrEmpty(npcId))
_onNpcDialogueCompleted?.Raise(npcId);
}
/// <summary>
/// 根据 ConditionalVariant 选择正确的序列版本。
/// 按顺序检查 variants第一个满足 WorldStateRegistry 标志的变体胜出;
/// 未注入 WorldStateRegistry 或无满足条件的变体时,返回原序列(默认)。
/// </summary>
private DialogueSequenceSO ResolveVariant(DialogueSequenceSO sequence)
{
if (sequence.variants == null || sequence.variants.Length == 0)
return sequence;
if (_worldState != null)
{
foreach (var variant in sequence.variants)
{
if (!string.IsNullOrEmpty(variant.conditionFlag)
&& variant.sequence != null
&& _worldState.HasFlag(variant.conditionFlag))
return variant.sequence;
}
}
return sequence;
}
}
}

View File

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

View File

@@ -0,0 +1,41 @@
using UnityEngine;
namespace BaseGames.Dialogue
{
/// <summary>
/// 对话行结构(架构 14_NarrativeModule §3
/// 每行包含说话人、文本(本地化 Key和可选的语音片段。
/// </summary>
[System.Serializable]
public struct DialogueLine
{
public string speakerNameKey; // 本地化 key如 "NPC_Elder_Name"
[TextArea(2, 5)]
public string textKey; // 本地化文本 key如 "DLG_Elder_001"
public Sprite portraitSprite; // 可选说话人头像
public AudioClip voiceClip; // 可选语音
[Min(0.01f)]
public float typewriterDelay; // 每字符延迟0 = 使用默认 0.03f
}
/// <summary>
/// 对话序列 SO架构 14_NarrativeModule §3
/// 一个 NPC 对话场合对应一个序列,由若干 DialogueLine 组成。
/// 资产路径: Assets/ScriptableObjects/Dialogue/DLG_{NpcId}_{Context}.asset
/// </summary>
[CreateAssetMenu(menuName = "Dialogue/DialogueSequence")]
public class DialogueSequenceSO : ScriptableObject
{
public string sequenceId; // 全局唯一,如 "DLG_Elder_Quest_Available"
public DialogueLine[] lines;
/// <summary>条件变体:满足特定世界标志时替换整个序列。</summary>
[System.Serializable]
public struct ConditionalVariant
{
public string conditionFlag; // WorldState flag key
public DialogueSequenceSO sequence;
}
public ConditionalVariant[] variants;
}
}

View File

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

View File

@@ -0,0 +1,102 @@
using System.Collections;
using System.Text;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace BaseGames.Dialogue
{
/// <summary>
/// 对话框 UI 组件(架构 14_NarrativeModule §5
/// 挂载在 Canvas_Overlay 下的 DialogueBox 子对象。
/// 负责打字机效果、头像显示、继续提示。
/// </summary>
public class DialogueUI : MonoBehaviour
{
[SerializeField] private GameObject _rootPanel;
[SerializeField] private TMP_Text _speakerNameText;
[SerializeField] private TMP_Text _dialogueText;
[SerializeField] private GameObject _speakerNamePanel; // 无名称时隐藏整个名称框
[SerializeField] private GameObject _continuePrompt; // "▼" 图标,打字完成后显示
[SerializeField] private Image _speakerPortrait; // 角色头像框
private Coroutine _typingCoroutine;
private DialogueLine _currentLine;
private const float DefaultTypewriterDelay = 0.03f;
/// <summary>当前是否仍在执行打字机效果。</summary>
public bool IsTyping { get; private set; }
// ── 公开 API ──────────────────────────────────────────────────────
/// <summary>显示一行对话并开始打字机效果。</summary>
public void ShowLine(DialogueLine line)
{
_currentLine = line;
_rootPanel.SetActive(true);
_continuePrompt.SetActive(false);
// 说话人名称
bool hasSpeaker = !string.IsNullOrEmpty(line.speakerNameKey);
if (_speakerNamePanel != null) _speakerNamePanel.SetActive(hasSpeaker);
if (hasSpeaker && _speakerNameText != null)
_speakerNameText.text = line.speakerNameKey;
// 头像
if (_speakerPortrait != null)
{
_speakerPortrait.gameObject.SetActive(line.portraitSprite != null);
if (line.portraitSprite != null) _speakerPortrait.sprite = line.portraitSprite;
}
// 开始打字机协程
if (_typingCoroutine != null) StopCoroutine(_typingCoroutine);
_typingCoroutine = StartCoroutine(TypeLine(line));
}
/// <summary>立即显示全部文字(跳过打字机效果)。</summary>
public void SkipTyping()
{
if (!IsTyping) return;
if (_typingCoroutine != null)
{
StopCoroutine(_typingCoroutine);
_typingCoroutine = null;
}
if (_dialogueText != null)
_dialogueText.text = _currentLine.textKey ?? "";
IsTyping = false;
if (_continuePrompt != null) _continuePrompt.SetActive(true);
}
/// <summary>隐藏对话框面板。</summary>
public void Hide()
{
if (_rootPanel != null) _rootPanel.SetActive(false);
}
// ── 内部协程 ──────────────────────────────────────────────────────
private IEnumerator TypeLine(DialogueLine line)
{
IsTyping = true;
float delay = line.typewriterDelay > 0f ? line.typewriterDelay : DefaultTypewriterDelay;
string text = line.textKey ?? "";
// 性能StringBuilder 避免每帧字符串分配O(n²) → O(n)
var sb = new StringBuilder(text.Length);
if (_dialogueText != null) _dialogueText.text = "";
foreach (char c in text)
{
sb.Append(c);
if (_dialogueText != null) _dialogueText.SetText(sb); // TMP SetText(StringBuilder) 零分配
yield return new WaitForSecondsRealtime(delay);
}
IsTyping = false;
if (_continuePrompt != null) _continuePrompt.SetActive(true);
_typingCoroutine = null;
}
}
}

View File

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

View File

@@ -0,0 +1,18 @@
namespace BaseGames.Dialogue
{
/// <summary>
/// 对话服务接口。通过 ServiceLocator 注册,供 NPC 和测试使用。
/// </summary>
public interface IDialogueService
{
/// <summary>当前是否有对话正在播放。</summary>
bool IsDialogueActive { get; }
/// <summary>
/// 启动对话序列。若已有对话在播放则忽略新请求。
/// </summary>
/// <param name="sequence">要播放的对话序列 SO。</param>
/// <param name="npcId">NPC 标识符,对话结束时随 EVT_NpcDialogueCompleted 广播。</param>
void StartDialogue(DialogueSequenceSO sequence, string npcId = "");
}
}

View File

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

View File

@@ -0,0 +1,55 @@
using UnityEngine;
using BaseGames.World;
namespace BaseGames.Dialogue
{
/// <summary>
/// 可交互 NPC 基类(架构 14_NarrativeModule §5
/// 实现 IInteractable触发对话序列子类如 QuestGiver可覆盖对话选择逻辑。
/// 程序集: BaseGames.Dialogue
/// </summary>
public class InteractableNPC : MonoBehaviour, IInteractable
{
[SerializeField] protected string _npcId;
[SerializeField] protected DialogueSequenceSO _defaultDialogue;
[SerializeField] protected float _interactRadius = 1.5f;
// ── IInteractable ──────────────────────────────────────────────────
public virtual bool CanInteract => true;
public virtual string InteractPrompt => "对话";
public void Interact(Transform player)
{
if (!CanInteract) return;
Interact_Internal(player);
var dialogue = GetCurrentDialogue();
if (dialogue != null)
PlayDialogue(dialogue, player);
}
public virtual void OnPlayerEnterRange(Transform player) { }
public virtual void OnPlayerExitRange() { }
// ── 子类覆盖点 ──────────────────────────────────────────────────────
/// <summary>交互前置逻辑(如任务接收/完成判断)。子类覆盖此方法。</summary>
protected virtual void Interact_Internal(Transform player) { }
/// <summary>返回当前应播放的对话序列。子类根据任务状态返回不同版本。</summary>
protected virtual DialogueSequenceSO GetCurrentDialogue() => _defaultDialogue;
// ── 对话播放 ───────────────────────────────────────────────────────
protected virtual void PlayDialogue(DialogueSequenceSO sequence, Transform player)
{
if (sequence == null) return;
var manager = BaseGames.Core.ServiceLocator.GetOrDefault<IDialogueService>();
if (manager == null)
{
Debug.LogWarning($"[InteractableNPC:{_npcId}] DialogueManager 未注册到 ServiceLocator无法播放对话。");
return;
}
manager.StartDialogue(sequence, _npcId);
}
}
}

View File

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

View File

@@ -0,0 +1,45 @@
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UI;
namespace BaseGames.Dialogue
{
/// <summary>
/// 交互提示 UI 控制器(架构 14_NarrativeModule §2
/// 挂载在每个 IInteractable GameObject 的子节点Prefab 实例),默认隐藏。
/// 根据当前活跃输入设备自动切换图标(键盘/手柄)。
/// </summary>
public class InteractionPromptController : MonoBehaviour
{
[SerializeField] private GameObject _promptRoot;
[SerializeField] private Image _icon;
[SerializeField] private Sprite _keyboardIcon;
[SerializeField] private Sprite _gamepadIcon;
private void Awake()
{
if (_promptRoot != null) _promptRoot.SetActive(false);
}
/// <summary>显示交互提示,根据输入设备选择图标。</summary>
public void Show()
{
if (_promptRoot == null) return;
_promptRoot.SetActive(true);
UpdateIcon();
}
/// <summary>隐藏交互提示。</summary>
public void Hide()
{
if (_promptRoot != null) _promptRoot.SetActive(false);
}
private void UpdateIcon()
{
if (_icon == null) return;
bool isGamepad = Gamepad.current != null && Gamepad.current.enabled;
_icon.sprite = isGamepad ? _gamepadIcon : _keyboardIcon;
}
}
}

View File

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

View File

@@ -0,0 +1,67 @@
using System;
using BaseGames.World;
using UnityEngine;
namespace BaseGames.Dialogue
{
/// <summary>
/// 条件对话 NPC架构 14_NarrativeModule §7
/// 扩展 InteractableNPC根据 WorldStateRegistry 标志动态选择对话版本。
/// 版本列表从高到低优先级排列;第一个满足条件的版本生效。
/// </summary>
public class NarrativeNPC : InteractableNPC
{
[Header("台词版本集(从高到低优先级排列)")]
[SerializeField] private DialogueVersion[] _dialogueVersions;
[SerializeField] private DialogueSequenceSO _fallbackDialogue; // 无条件满足时的兜底台词
[SerializeField] private WorldStateRegistry _worldState; // SO 注入
protected override DialogueSequenceSO GetCurrentDialogue()
{
if (_dialogueVersions == null) return _fallbackDialogue;
foreach (var version in _dialogueVersions)
{
if (version != null && version.CheckConditions(_worldState))
return version.dialogue;
}
return _fallbackDialogue;
}
}
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// 一个对话版本及其激活条件(架构 14_NarrativeModule §7
/// </summary>
[Serializable]
public class DialogueVersion
{
[Tooltip("编辑器显示名,如'森林 Boss 击败后'")]
public string versionLabel;
public DialogueSequenceSO dialogue;
[Tooltip("全部满足才激活此版本AND 关系)")]
public string[] requiredFlags;
[Tooltip("有任意一个 = 此版本不激活NOT 关系)")]
public string[] blockedByFlags;
/// <summary>检查此版本的激活条件AND requiredFlags / NOT blockedByFlags。</summary>
public bool CheckConditions(WorldStateRegistry registry)
{
if (registry == null) return false;
if (requiredFlags != null)
foreach (var f in requiredFlags)
if (!registry.HasFlag(f)) return false;
if (blockedByFlags != null)
foreach (var f in blockedByFlags)
if (registry.HasFlag(f)) return false;
return true;
}
}
}

View File

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