using UnityEngine; using BaseGames.World; using BaseGames.Localization; namespace BaseGames.Dialogue { /// /// 可交互 NPC 基类(架构 14_NarrativeModule §5)。 /// 实现 IInteractable,触发对话序列;子类(如 QuestGiver)可覆盖对话选择逻辑。 /// 程序集: BaseGames.Dialogue /// 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.0–2.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 { get { if (!string.IsNullOrEmpty(_interactPromptKey)) { var resolved = LocalizationManager.Get(_interactPromptKey, "UI"); if (!string.IsNullOrEmpty(resolved)) return resolved; } return "对话"; } } // ── 范围进出通知(供子组件订阅,如 InteractionPromptController)────── /// 玩家进入交互范围时触发。参数为玩家 Transform。 public event System.Action PlayerEnteredRange; /// 玩家离开交互范围时触发。 public event System.Action PlayerExitedRange; 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) { 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(); } // ── 子类覆盖点 ────────────────────────────────────────────────────── /// 交互前置逻辑(如任务接收/完成判断)。子类覆盖此方法。 protected virtual void Interact_Internal(Transform player) { } /// 返回当前应播放的对话序列。子类根据任务状态返回不同版本。 protected virtual DialogueSequenceSO GetCurrentDialogue() => _defaultDialogue; // ── 对话播放 ─────────────────────────────────────────────────────── protected virtual void PlayDialogue(DialogueSequenceSO sequence, Transform player) { if (sequence == null) return; var manager = BaseGames.Core.ServiceLocator.GetOrDefault(); if (manager == null) { Debug.LogWarning($"[InteractableNPC:{_npcId}] DialogueManager 未注册到 ServiceLocator,无法播放对话。"); return; } 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(); 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(); 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 } }