feat: Round 48 narrative systems improvements

- QuestSO: Add ValidateBranchCycles() DFS detection for branches[].nextQuest loop
- QuestSO: Mark three legacy prerequisite fields with v2.0 removal warning in Tooltip
- IQuestManager: Add QuestLockReason enum + QuestLockInfo struct (strongly-typed lock info)
- IQuestManager: Add GetQuestLockInfo() method to interface; GetQuestLockReason() now delegates to it
- IQuestEventSource: Add OnQuestStateChanged(questId, oldState, newState) unified event
- QuestManager: Implement GetQuestLockInfo(); fire OnQuestStateChanged on all state transitions
- DialogueManager: Add one-frame yield in HandleChoices before ShowChoices (skip-debounce fix)
- DialogueManager: Increment _playbackId in ForceEnd() to invalidate residual choice callbacks
- DialogueSequenceSO: Add UNITY_EDITOR debug log in TryGetActiveVariant on variant match
- WorldStateRegistry: Add OnBatchStateChanged event + BatchMark() batch-write API
- DialogueModule: List badge shows warning indicator for unconditional-shadowing variants
- DialogueModule: BuildVariantsCard shows logic mode (AND/OR) alongside flag conditions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-25 00:05:15 +08:00
parent 446fd5dcd0
commit 6eaa83dc71
72 changed files with 7080 additions and 373 deletions

View File

@@ -1,3 +1,4 @@
using TMPro;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UI;
@@ -5,34 +6,144 @@ using UnityEngine.UI;
namespace BaseGames.Dialogue
{
/// <summary>
/// 交互提示 UI 控制器(架构 14_NarrativeModule §2
/// 挂在每个 IInteractable GameObject 的子节点Prefab 实例),默认隐藏。
/// 根据当前活跃输入设备自动切换图标(键盘/手柄)。
/// 世界空间交互提示控制器(架构 14_NarrativeModule §2 升级版)。
/// 挂在每个 InteractableNPC 子节点Prefab 实例),默认隐藏。
///
/// 功能:
/// • 自动订阅父级 InteractableNPC 的进/出范围事件,免手动调用 Show/Hide
/// • TMP_Text 实时显示 InteractPrompt如"接受任务"/"提交任务"),随任务状态动态刷新
/// • 根据当前活跃输入设备自动切换按键图标(键盘/手柄)
/// • 支持淡入/淡出动画
/// </summary>
public class InteractionPromptController : MonoBehaviour
{
[Header("UI 引用")]
[Tooltip("整个提示根节点(包含图标和文字),控制显示/隐藏。")]
[SerializeField] private GameObject _promptRoot;
[Tooltip("按键图标 Image 组件(可选)。有输入设备时显示对应图标。")]
[SerializeField] private Image _icon;
[SerializeField] private Sprite _keyboardIcon;
[SerializeField] private Sprite _gamepadIcon;
[Tooltip("提示文字 TMP_Text 组件(可选)。自动显示 InteractableNPC.InteractPrompt 的当前值。")]
[SerializeField] private TMP_Text _label;
[Header("按键图标")]
[Tooltip("键盘/鼠标设备激活时使用的按键图标 Sprite。")]
[SerializeField] private Sprite _keyboardIcon;
[Tooltip("手柄设备激活时使用的按键图标 Sprite。")]
[SerializeField] private Sprite _gamepadIcon;
[Header("位置与动画")]
[Tooltip("相对于本组件 transform 的世界空间偏移。调整此值可控制气泡与 NPC 的相对位置。")]
[SerializeField] private Vector3 _offset = new Vector3(0f, 1.8f, 0f);
[Tooltip("是否随相机方向 Billboard 朝向(世界空间 Canvas 推荐开启)。")]
[SerializeField] private bool _billboard = true;
[Tooltip("淡入持续时间。0 = 立即显示,无动画。")]
[SerializeField] [Min(0f)] private float _fadeInDuration = 0.12f;
[Tooltip("淡出持续时间。0 = 立即隐藏,无动画。")]
[SerializeField] [Min(0f)] private float _fadeOutDuration = 0.08f;
// ── 运行时状态 ────────────────────────────────────────────────────────
private InteractableNPC _npc;
private bool _visible;
private float _alpha;
private Camera _cam;
// ── Lifecycle ─────────────────────────────────────────────────────────
private void Awake()
{
if (_promptRoot != null) _promptRoot.SetActive(false);
// 自动连接父级 InteractableNPC 事件(无需手动调用 Show/Hide
_npc = GetComponentInParent<InteractableNPC>();
if (_npc != null)
{
_npc.PlayerEnteredRange += OnPlayerEntered;
_npc.PlayerExitedRange += OnPlayerExited;
}
SetVisible(false, immediate: true);
}
/// <summary>显示交互提示,根据输入设备选择图标。</summary>
private void OnDestroy()
{
if (_npc != null)
{
_npc.PlayerEnteredRange -= OnPlayerEntered;
_npc.PlayerExitedRange -= OnPlayerExited;
}
}
private void Update()
{
// 位置偏移(世界空间气泡)
if (_offset != Vector3.zero)
transform.position = (_npc != null ? _npc.transform.position : transform.parent.position) + _offset;
// Billboard
if (_billboard && _visible)
{
if (_cam == null) _cam = Camera.main;
if (_cam != null)
transform.forward = _cam.transform.forward;
}
// 淡入/淡出
if (_promptRoot == null) return;
if (_visible && _alpha < 1f)
{
float speed = _fadeInDuration > 0f ? Time.deltaTime / _fadeInDuration : 1f;
_alpha = Mathf.MoveTowards(_alpha, 1f, speed);
ApplyAlpha(_alpha);
}
else if (!_visible && _alpha > 0f)
{
float speed = _fadeOutDuration > 0f ? Time.deltaTime / _fadeOutDuration : 1f;
_alpha = Mathf.MoveTowards(_alpha, 0f, speed);
ApplyAlpha(_alpha);
if (_alpha <= 0f) _promptRoot.SetActive(false);
}
}
// ── 公开 API兼容旧调用 / 脚本手动控制)────────────────────────────
/// <summary>手动显示提示。通常由 InteractableNPC 自动调用,无需手动触发。</summary>
public void Show()
{
if (_promptRoot == null) return;
_promptRoot.SetActive(true);
if (_npc != null) _label.text = _npc.InteractPrompt;
SetVisible(true, immediate: false);
UpdateIcon();
}
/// <summary>隐藏交互提示。</summary>
public void Hide()
/// <summary>手动隐藏提示。通常由 InteractableNPC 自动调用,无需手动触发。</summary>
public void Hide() => SetVisible(false, immediate: false);
// ── 私有辅助 ─────────────────────────────────────────────────────────
private void OnPlayerEntered(Transform player)
{
if (_promptRoot != null) _promptRoot.SetActive(false);
// 刷新文字(每次进入都读取最新 InteractPrompt确保任务状态变化后文字正确
if (_label != null && _npc != null)
_label.text = _npc.InteractPrompt;
SetVisible(true, immediate: false);
UpdateIcon();
}
private void OnPlayerExited() => SetVisible(false, immediate: false);
private void SetVisible(bool show, bool immediate)
{
_visible = show;
if (immediate)
{
_alpha = show ? 1f : 0f;
if (_promptRoot != null)
{
_promptRoot.SetActive(show);
ApplyAlpha(_alpha);
}
}
else if (show && _promptRoot != null)
{
_promptRoot.SetActive(true); // 淡出由 Update 结束时 SetActive(false)
}
}
private void UpdateIcon()
@@ -41,5 +152,12 @@ namespace BaseGames.Dialogue
bool isGamepad = Gamepad.current != null && Gamepad.current.enabled;
_icon.sprite = isGamepad ? _gamepadIcon : _keyboardIcon;
}
private void ApplyAlpha(float a)
{
if (_icon != null) { var c = _icon.color; c.a = a; _icon.color = c; }
if (_label != null) { var c = _label.color; c.a = a; _label.color = c; }
}
}
}