147 lines
7.9 KiB
C#
147 lines
7.9 KiB
C#
using UnityEngine;
|
||
using BaseGames.World;
|
||
using BaseGames.Localization;
|
||
|
||
namespace BaseGames.Dialogue
|
||
{
|
||
/// <summary>
|
||
/// 可交互 NPC 基类(架构 14_NarrativeModule §5)。
|
||
/// 实现 IInteractable,触发对话序列;子类(如 QuestGiver)可覆盖对话选择逻辑。
|
||
/// 程序集: 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.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, LocalizationTable.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)
|
||
{
|
||
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();
|
||
}
|
||
|
||
// ── 子类覆盖点 ──────────────────────────────────────────────────────
|
||
/// <summary>组件启用时调用。子类可覆盖且应调用 base.OnEnable()。</summary>
|
||
protected virtual void OnEnable() { }
|
||
|
||
/// <summary>组件禁用时调用。子类可覆盖且应调用 base.OnDisable()。</summary>
|
||
protected virtual void OnDisable() { }
|
||
/// <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);
|
||
}
|
||
|
||
// ── 编辑器辅助 ────────────────────────────────────────────────────
|
||
// 注意: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
|
||
}
|
||
}
|