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,5 +1,6 @@
using UnityEngine;
using BaseGames.World;
using BaseGames.Localization;
namespace BaseGames.Dialogue
{
@@ -10,13 +11,46 @@ namespace BaseGames.Dialogue
/// </summary>
public class InteractableNPC : MonoBehaviour, IInteractable
{
[Header("NPC 基础")]
[Tooltip("NPC 唯一 ID如 \"NPC_Elder\")。对话结束时随 EVT_NpcDialogueCompleted 广播,用于驱动对话类任务目标进度。\n" +
"需与 QuestSO 目标中 targetNpcId 保持一致。")]
[SerializeField] protected string _npcId;
[Tooltip("默认对话序列。无其他逻辑覆盖时播放此序列。NarrativeNPC/QuestGiver 子类通过 GetCurrentDialogue() 返回更精确的版本。")]
[SerializeField] protected DialogueSequenceSO _defaultDialogue;
[Tooltip("玩家进入此半径单位Unity 单位)后显示交互提示。建议 1.02.0。\n编辑器下在场景视图中以黄色圆圈可视化。")]
[SerializeField] protected float _interactRadius = 1.5f;
[Tooltip("交互提示本地化 Key如 \"INTERACT_Talk\")。运行时通过 LocalizationManager 解析为实际文字。\n" +
"留空时回退到内置字符串 \"对话\"。")]
[SerializeField] protected string _interactPromptKey = "INTERACT_Talk";
[Header("范围检测")]
[Tooltip("玩家所在的物理层。OnTriggerEnter2D / OnTriggerExit2D 仅响应属于此层的碰撞体,\n" +
"实现 NPC 自包含的交互范围检测,无需外部 PlayerInteractionDetector 组件。\n" +
"将玩家 GameObject 的 Layer 与此 Mask 对齐即可(推荐专用 \"Player\" 层)。\n" +
"若留空(值为 0则跳过层级过滤任意碰撞体均可触发调试用不推荐上线。")]
[SerializeField] protected LayerMask _playerLayer;
// ── IInteractable ──────────────────────────────────────────────────
public virtual bool CanInteract => true;
public virtual string InteractPrompt => "对话";
public virtual bool CanInteract => true;
public virtual string InteractPrompt
{
get
{
if (!string.IsNullOrEmpty(_interactPromptKey))
{
var resolved = LocalizationManager.Get(_interactPromptKey, "UI");
if (!string.IsNullOrEmpty(resolved)) return resolved;
}
return "对话";
}
}
// ── 范围进出通知(供子组件订阅,如 InteractionPromptController──────
/// <summary>玩家进入交互范围时触发。参数为玩家 Transform。</summary>
public event System.Action<Transform> PlayerEnteredRange;
/// <summary>玩家离开交互范围时触发。</summary>
public event System.Action PlayerExitedRange;
public void Interact(Transform player)
{
@@ -27,8 +61,24 @@ namespace BaseGames.Dialogue
PlayDialogue(dialogue, player);
}
public virtual void OnPlayerEnterRange(Transform player) { }
public virtual void OnPlayerExitRange() { }
public virtual void OnPlayerEnterRange(Transform player) { PlayerEnteredRange?.Invoke(player); }
public virtual void OnPlayerExitRange() { PlayerExitedRange?.Invoke(); }
// ── 自包含物理范围检测 ─────────────────────────────────────────────
// 需在 NPC Prefab 上挂载 Collider2D设为 IsTrigger并将 Collider2D.size/radius
// 配置为期望的交互半径。OnTriggerEnter2D / Exit2D 会自动过滤非玩家碰撞体。
private void OnTriggerEnter2D(Collider2D other)
{
if (_playerLayer.value != 0 && ((1 << other.gameObject.layer) & _playerLayer.value) == 0) return;
OnPlayerEnterRange(other.transform);
}
private void OnTriggerExit2D(Collider2D other)
{
if (_playerLayer.value != 0 && ((1 << other.gameObject.layer) & _playerLayer.value) == 0) return;
OnPlayerExitRange();
}
// ── 子类覆盖点 ──────────────────────────────────────────────────────
@@ -51,5 +101,42 @@ namespace BaseGames.Dialogue
}
manager.StartDialogue(sequence, _npcId);
}
// ── 编辑器辅助 ────────────────────────────────────────────────────
// 注意OnValidate 声明在 #if 外,确保子类在非编辑器构建中调用 base.OnValidate() 不会编译失败。
protected virtual void OnValidate()
{
#if UNITY_EDITOR
if (_playerLayer.value == 0)
Debug.LogWarning(
$"[InteractableNPC:{name}] _playerLayer 未设置value=0。" +
"OnTriggerEnter2D 将响应所有层,建议在 Inspector 中指定玩家所在层。", this);
// 检测 _interactRadius 与 CircleCollider2D.radius 是否同步(仅输出一次,非逐帧)
var circle = GetComponent<UnityEngine.CircleCollider2D>();
if (circle != null && !Mathf.Approximately(circle.radius, _interactRadius))
Debug.LogWarning(
$"[InteractableNPC:{name}] _interactRadius({_interactRadius:F2}) 与 " +
$"CircleCollider2D.radius({circle.radius:F2}) 不一致," +
"交互范围视觉Gizmos与物理碰撞可能不匹配请手动对齐。", this);
#endif
}
#if UNITY_EDITOR
protected virtual void OnDrawGizmosSelected()
{
// Collider2D 不一致时改绘红色(警告已在 OnValidate 中输出,此处不重复 LogWarning
bool mismatch = false;
var circle = GetComponent<UnityEngine.CircleCollider2D>();
if (circle != null && !Mathf.Approximately(circle.radius, _interactRadius))
mismatch = true;
UnityEditor.Handles.color = mismatch
? new Color(1f, 0.2f, 0.2f, 0.8f)
: new Color(1f, 0.92f, 0.016f, 0.6f);
UnityEditor.Handles.DrawWireDisc(transform.position, Vector3.forward, _interactRadius);
}
#endif
}
}