Files
ichni_Official/Assets/Scripts/NewStorySystem/Helper/StoryHelperController.cs
SoulliesOfficial 810d019619 剧情+对话完善
2026-07-21 15:24:42 -04:00

387 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}
}