389 lines
15 KiB
C#
389 lines
15 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using DG.Tweening;
|
||
using UnityEngine;
|
||
using UnityEngine.Localization.Settings;
|
||
using UnityEngine.UI;
|
||
|
||
namespace Ichni.Story
|
||
{
|
||
/// <summary>
|
||
/// 场景中常驻 Helper 的交互控制器。
|
||
/// <para>点击后会按剧情条件筛选并加权随机抽取一条台词,实例化 HelperTalk Prefab 显示;
|
||
/// 此过程不修改剧情变量、Block 完成状态或存档。</para>
|
||
/// </summary>
|
||
[RequireComponent(typeof(Button))]
|
||
public class StoryHelperController : MonoBehaviour
|
||
{
|
||
[Header("References")]
|
||
[SerializeField, Tooltip("承接 Helper 点击的 Button。建议挂在固定根节点,而非会移动的视觉节点上。")]
|
||
private Button helperButton;
|
||
|
||
[SerializeField, Tooltip("当前 Helper 的视觉 Presenter。未来可替换为 Spine 或 Live2D 对应实现。")]
|
||
private StoryHelperVisualPresenter visualPresenter;
|
||
|
||
[SerializeField, Tooltip("点击后实例化的 HelperTalk Prefab。它独立于 MessageBox。")]
|
||
private StoryHelperTalk helperTalkPrefab;
|
||
|
||
[SerializeField, Tooltip("运行时 HelperTalk 的父节点。留空时使用当前 Helper 根节点。")]
|
||
private Transform helperTalkContainer;
|
||
|
||
[Header("Motion Roots")]
|
||
[SerializeField, Tooltip("只承担循环漂浮动画的节点。")]
|
||
private RectTransform idleRoot;
|
||
|
||
[SerializeField, Tooltip("只承担点击反馈的节点。它应位于 Idle Root 内部,避免与循环漂浮争夺同一属性。")]
|
||
private RectTransform clickRoot;
|
||
|
||
[Header("Idle Animation")]
|
||
[SerializeField, Min(0f), Tooltip("循环漂浮的最大纵向偏移(Canvas 单位)。")]
|
||
private float idleFloatDistance = 8f;
|
||
|
||
[SerializeField, Min(0.05f), Tooltip("从基准位置移动到最高点或最低点的单程时间。")]
|
||
private float idleHalfDuration = 1.35f;
|
||
|
||
[Header("Click Animation")]
|
||
[SerializeField, Min(0.01f), Tooltip("点击缩放与位移反馈持续时间。")]
|
||
private float clickPunchDuration = 0.2f;
|
||
|
||
[SerializeField, Min(0f), Tooltip("点击时的缩放强度;0.035 表示约 3.5% 的轻微放大。")]
|
||
private float clickPunchScale = 0.035f;
|
||
|
||
[SerializeField, Min(0f), Tooltip("点击时的纵向位移反馈(Canvas 单位)。")]
|
||
private float clickPunchOffset = 5f;
|
||
|
||
[SerializeField, Min(0f), Tooltip("连续点击的最小间隔,防止重复实例化大量气泡。")]
|
||
private float clickCooldown = 0.3f;
|
||
|
||
private readonly List<StoryHelperDialogueDefinition> _eligibleDialogues = new();
|
||
|
||
private Sequence _idleSequence;
|
||
private Sequence _clickSequence;
|
||
private StoryHelperTalk _activeTalk;
|
||
private Vector2 _idleBasePosition;
|
||
private Vector2 _clickBasePosition;
|
||
private Vector3 _clickBaseScale;
|
||
private string _lastDialogueId;
|
||
private StoryHelperData _appliedHelperData;
|
||
private StoryManager _subscribedStoryManager;
|
||
private float _nextClickTime;
|
||
|
||
private void Awake()
|
||
{
|
||
if (helperButton == null)
|
||
helperButton = GetComponent<Button>();
|
||
if (idleRoot == null)
|
||
idleRoot = transform as RectTransform;
|
||
if (clickRoot == null && visualPresenter != null)
|
||
clickRoot = visualPresenter.transform as RectTransform;
|
||
if (helperTalkContainer == null)
|
||
helperTalkContainer = transform;
|
||
|
||
CacheBaseTransforms();
|
||
}
|
||
|
||
private void OnEnable()
|
||
{
|
||
if (helperButton != null)
|
||
helperButton.onClick.AddListener(HandleHelperClicked);
|
||
|
||
SubscribeToStoryManager();
|
||
|
||
PlayIdleAnimation();
|
||
}
|
||
|
||
private void Start()
|
||
{
|
||
// OnEnable 的调用顺序可能早于 StoryManager.Awake;Start 时再次同步,
|
||
// 确保首次进入 MenuScene 也能应用 Inspector 中配置的默认 Helper。
|
||
SubscribeToStoryManager();
|
||
}
|
||
|
||
private void OnDisable()
|
||
{
|
||
if (helperButton != null)
|
||
helperButton.onClick.RemoveListener(HandleHelperClicked);
|
||
UnsubscribeFromStoryManager();
|
||
|
||
KillAndRestoreMotion();
|
||
CloseActiveTalk();
|
||
}
|
||
|
||
private void OnDestroy()
|
||
{
|
||
_idleSequence?.Kill();
|
||
_clickSequence?.Kill();
|
||
}
|
||
|
||
/// <summary>点击 Helper 的统一入口:播放反馈后创建一条新的专属气泡。</summary>
|
||
private void HandleHelperClicked()
|
||
{
|
||
if (Time.unscaledTime >= _nextClickTime)
|
||
{
|
||
TryCreateRandomTalk();
|
||
_nextClickTime = Time.unscaledTime + clickCooldown;
|
||
}
|
||
|
||
PlayClickAnimation();
|
||
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从当前 Helper 对话池按条件和权重抽取文本,并实例化一个新的 HelperTalk。
|
||
/// </summary>
|
||
private void TryCreateRandomTalk()
|
||
{
|
||
StoryHelperData helperData = StoryManager.instance != null
|
||
? StoryManager.instance.ActiveHelperData
|
||
: null;
|
||
if (helperData == null)
|
||
{
|
||
Debug.LogWarning("[StoryHelper] 未配置当前 StoryHelperData,无法创建 HelperTalk。", this);
|
||
return;
|
||
}
|
||
|
||
if (helperTalkPrefab == null)
|
||
{
|
||
Debug.LogWarning("[StoryHelper] 未配置 HelperTalk Prefab,无法创建 Helper 对话气泡。", this);
|
||
return;
|
||
}
|
||
|
||
StoryHelperDialogueDefinition dialogue = SelectDialogue(helperData);
|
||
string contentKey = dialogue != null ? dialogue.dialogueKey : helperData.fallbackDialogueKey;
|
||
if (string.IsNullOrEmpty(contentKey))
|
||
{
|
||
Debug.LogWarning($"[StoryHelper] Helper '{helperData.name}' 没有可用候选对话,也未配置 Fallback Dialogue Key。", this);
|
||
return;
|
||
}
|
||
|
||
if (dialogue != null)
|
||
_lastDialogueId = GetDialogueIdentity(dialogue);
|
||
|
||
CloseActiveTalk();
|
||
_activeTalk = Instantiate(helperTalkPrefab, helperTalkContainer);
|
||
_activeTalk.Closed += HandleTalkClosed;
|
||
_activeTalk.Show(
|
||
GetLocalizedText(helperData, helperData.displayNameKey, helperData.name),
|
||
GetLocalizedText(helperData, contentKey, contentKey));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 构建当前可用候选池;当存在其它候选时,排除与上一条相同的台词,避免连续重复。
|
||
/// </summary>
|
||
private StoryHelperDialogueDefinition SelectDialogue(StoryHelperData helperData)
|
||
{
|
||
_eligibleDialogues.Clear();
|
||
if (helperData.dialoguePool == null)
|
||
return null;
|
||
|
||
foreach (StoryHelperDialogueDefinition dialogue in helperData.dialoguePool)
|
||
{
|
||
if (dialogue == null || string.IsNullOrEmpty(dialogue.dialogueKey) || !IsDialogueAvailable(dialogue))
|
||
continue;
|
||
|
||
_eligibleDialogues.Add(dialogue);
|
||
}
|
||
|
||
if (_eligibleDialogues.Count == 0)
|
||
return null;
|
||
|
||
bool hasAlternative = false;
|
||
foreach (StoryHelperDialogueDefinition dialogue in _eligibleDialogues)
|
||
{
|
||
if (GetDialogueIdentity(dialogue) != _lastDialogueId)
|
||
{
|
||
hasAlternative = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
int totalWeight = 0;
|
||
foreach (StoryHelperDialogueDefinition dialogue in _eligibleDialogues)
|
||
{
|
||
if (hasAlternative && GetDialogueIdentity(dialogue) == _lastDialogueId)
|
||
continue;
|
||
|
||
totalWeight += Mathf.Max(1, dialogue.weight);
|
||
}
|
||
|
||
int randomWeight = UnityEngine.Random.Range(0, totalWeight);
|
||
foreach (StoryHelperDialogueDefinition dialogue in _eligibleDialogues)
|
||
{
|
||
if (hasAlternative && GetDialogueIdentity(dialogue) == _lastDialogueId)
|
||
continue;
|
||
|
||
randomWeight -= Mathf.Max(1, dialogue.weight);
|
||
if (randomWeight < 0)
|
||
return dialogue;
|
||
}
|
||
|
||
return _eligibleDialogues[0];
|
||
}
|
||
|
||
/// <summary>
|
||
/// 复用 StoryCondition 的通用条件树。未配置条件代表始终可用;已配置条件必须在当前已构建章节中求值,
|
||
/// 以避免全局 Helper 在没有章节上下文时误读其它章节的 Block 状态。
|
||
/// </summary>
|
||
private static bool IsDialogueAvailable(StoryHelperDialogueDefinition dialogue)
|
||
{
|
||
StoryCondition condition = dialogue.availabilityCondition;
|
||
if (condition == null || !condition.IsConfigured)
|
||
return true;
|
||
|
||
StoryTreeController treeController = StoryManager.instance != null
|
||
? StoryManager.instance.treeController
|
||
: null;
|
||
if (treeController == null || treeController.ActiveStoryData == null)
|
||
return false;
|
||
|
||
return condition.IsSatisfied((str) => StoryVariables.GetInt(str), treeController.IsBlockCompleted);
|
||
}
|
||
|
||
private static string GetDialogueIdentity(StoryHelperDialogueDefinition dialogue)
|
||
{
|
||
return string.IsNullOrEmpty(dialogue.dialogueId) ? dialogue.dialogueKey : dialogue.dialogueId;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 创建不影响点击节点的轻微循环漂浮。Idle 与 Click 使用不同节点,避免 DOTween 同时写入
|
||
/// 同一个位置或缩放属性造成跳动。
|
||
/// </summary>
|
||
private void PlayIdleAnimation()
|
||
{
|
||
if (idleRoot == null || idleFloatDistance <= 0f)
|
||
return;
|
||
|
||
_idleSequence?.Kill();
|
||
idleRoot.anchoredPosition = _idleBasePosition;
|
||
_idleSequence = DOTween.Sequence()
|
||
.Pause()
|
||
.SetUpdate(true)
|
||
.Append(idleRoot.DOAnchorPosY(_idleBasePosition.y + idleFloatDistance, idleHalfDuration).SetEase(Ease.InOutSine))
|
||
.Append(idleRoot.DOAnchorPosY(_idleBasePosition.y - idleFloatDistance, idleHalfDuration * 2f).SetEase(Ease.InOutSine))
|
||
.Append(idleRoot.DOAnchorPosY(_idleBasePosition.y, idleHalfDuration).SetEase(Ease.InOutSine))
|
||
.SetLoops(-1);
|
||
_idleSequence.Play();
|
||
}
|
||
|
||
/// <summary>播放一次克制的点击反馈,不会中断 Helper 的循环漂浮动画。</summary>
|
||
private void PlayClickAnimation()
|
||
{
|
||
if (clickRoot == null)
|
||
return;
|
||
|
||
_clickSequence?.Kill();
|
||
clickRoot.anchoredPosition = _clickBasePosition;
|
||
clickRoot.localScale = _clickBaseScale;
|
||
_clickSequence = DOTween.Sequence()
|
||
.Pause()
|
||
.SetUpdate(true)
|
||
.Join(clickRoot.DOPunchScale(Vector3.one * clickPunchScale, clickPunchDuration, 5, 0.7f))
|
||
.Join(clickRoot.DOPunchAnchorPos(Vector2.up * clickPunchOffset, clickPunchDuration, 5, 0.7f));
|
||
_clickSequence.Play();
|
||
}
|
||
|
||
private void HandleActiveHelperChanged(StoryHelperData helperData)
|
||
{
|
||
_lastDialogueId = null;
|
||
ApplyHelper(helperData);
|
||
CloseActiveTalk();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Helper 与 StoryManager 的 Awake 顺序不固定;OnEnable 与 Start 都调用本方法,
|
||
/// 确保首次进入场景时既能应用默认 Helper,也能为未来的 Helper 切换保留可靠事件入口。
|
||
/// </summary>
|
||
private void SubscribeToStoryManager()
|
||
{
|
||
StoryManager manager = StoryManager.instance;
|
||
if (manager == null || _subscribedStoryManager == manager)
|
||
return;
|
||
|
||
UnsubscribeFromStoryManager();
|
||
_subscribedStoryManager = manager;
|
||
_subscribedStoryManager.ActiveHelperChanged += HandleActiveHelperChanged;
|
||
ApplyHelper(_subscribedStoryManager.ActiveHelperData);
|
||
}
|
||
|
||
private void UnsubscribeFromStoryManager()
|
||
{
|
||
if (_subscribedStoryManager == null)
|
||
return;
|
||
|
||
_subscribedStoryManager.ActiveHelperChanged -= HandleActiveHelperChanged;
|
||
_subscribedStoryManager = null;
|
||
}
|
||
|
||
private void ApplyHelper(StoryHelperData helperData)
|
||
{
|
||
if (_appliedHelperData == helperData)
|
||
return;
|
||
|
||
_appliedHelperData = helperData;
|
||
visualPresenter?.ApplyHelper(helperData);
|
||
}
|
||
|
||
/// <summary>本地化失败时显示 Key,确保测试阶段仍能定位缺失条目,而不是得到空白气泡。</summary>
|
||
private string GetLocalizedText(StoryHelperData helperData, string key, string fallback)
|
||
{
|
||
if (string.IsNullOrEmpty(key))
|
||
return fallback;
|
||
|
||
try
|
||
{
|
||
string localized = LocalizationSettings.StringDatabase.GetLocalizedString(helperData.localizationTable, key);
|
||
return string.IsNullOrEmpty(localized) ? fallback : localized;
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
Debug.LogWarning($"[StoryHelper] 无法本地化 Key '{key}',将回退显示 '{fallback}'。{exception.Message}", this);
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
private void CacheBaseTransforms()
|
||
{
|
||
if (idleRoot != null)
|
||
_idleBasePosition = idleRoot.anchoredPosition;
|
||
if (clickRoot != null)
|
||
{
|
||
_clickBasePosition = clickRoot.anchoredPosition;
|
||
_clickBaseScale = clickRoot.localScale;
|
||
}
|
||
}
|
||
|
||
private void KillAndRestoreMotion()
|
||
{
|
||
_idleSequence?.Kill();
|
||
_clickSequence?.Kill();
|
||
_idleSequence = null;
|
||
_clickSequence = null;
|
||
|
||
if (idleRoot != null)
|
||
idleRoot.anchoredPosition = _idleBasePosition;
|
||
if (clickRoot != null)
|
||
{
|
||
clickRoot.anchoredPosition = _clickBasePosition;
|
||
clickRoot.localScale = _clickBaseScale;
|
||
}
|
||
}
|
||
|
||
private void CloseActiveTalk()
|
||
{
|
||
if (_activeTalk == null)
|
||
return;
|
||
|
||
_activeTalk.Closed -= HandleTalkClosed;
|
||
_activeTalk.CloseImmediately();
|
||
_activeTalk = null;
|
||
}
|
||
|
||
private void HandleTalkClosed(StoryHelperTalk talk)
|
||
{
|
||
talk.Closed -= HandleTalkClosed;
|
||
if (_activeTalk == talk)
|
||
_activeTalk = null;
|
||
}
|
||
}
|
||
}
|