Files
ichni_Official/Assets/Scripts/NewStorySystem/Helper/StoryHelperController.cs

387 lines
15 KiB
C#
Raw Normal View History

2026-07-21 15:24:42 -04:00
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.AwakeStart 时再次同步,
// 确保首次进入 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)
return;
_nextClickTime = Time.unscaledTime + clickCooldown;
PlayClickAnimation();
TryCreateRandomTalk();
}
/// <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;
}
}
}