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

499 lines
20 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 Ichni.Menu.UI;
using TMPro;
using UnityEngine;
using UnityEngine.Localization;
using UnityEngine.Localization.Settings;
using UnityEngine.Localization.Tables;
using UnityEngine.UI;
namespace Ichni.Story.UI
{
/// <summary>
/// 剧情页顶部固定的 StoryTimeline 控制器。
/// <para>它将 StoryData 中的时间 Marker 投影到固定 UI 层,并以锚定 TextBlock 的实际视觉中心同步横向位置;
/// 因而不会受 StoryTree 的 Helper 留白、Content 尺寸变化或不同 Block Prefab 尺寸影响。</para>
/// <para>Timeline 本身不保存进度。Marker 可见性来自 StoryBlockState主线百分比来自已完成 Marker 的最大 Progress Value。</para>
/// </summary>
public class StoryTimelineController : MonoBehaviour
{
[Header("UI References")]
[Tooltip("位于固定顶部 Timeline 中、用于实例化 Marker 的容器。建议由 RectMask2D 的 Viewport 裁切。")]
public RectTransform markerContainer;
[Tooltip("运行时生成的单个 Timeline Marker Prefab。")]
public StoryTimelineMarkerView markerPrefab;
[Tooltip("右上角章节主线进度文本;留空时仍会正常生成 Marker。")]
public TMP_Text progressText;
[Header("Decorative Tick Strip")]
[Tooltip("Timeline 中使用 Image Type = Tiled 的重复刻度 RectTransform。它只承担装饰不参与剧情进度或 Marker 判断。")]
public RectTransform tickRepeatRect;
[Tooltip("一个完整刻度贴图周期在当前 Canvas 坐标中的宽度。设为 0 时,自动使用同物体 Image 的 Sprite 宽度;仅在未使用标准 Image 时手动填写。")]
[Min(0f)] public float tickRepeatPeriodOverride;
[Tooltip("自动在左右各扩展一个刻度周期,保证刻度条循环平移时不会从 Timeline 边缘露出空白。")]
public bool expandTickRepeatRect = true;
[Header("Localization")]
[Tooltip("StoryTimeline 的 Unity Localization String Table。Marker.labelKey 在该 Table 中查找。")]
public TableReference localizationTable;
private sealed class RuntimeMarker
{
public StoryTimelineMarkerDefinition definition;
public StoryBlockView anchorView;
public StoryTimelineMarkerView view;
}
private readonly List<RuntimeMarker> _markers = new List<RuntimeMarker>();
private StoryTreeController _treeController;
// StoryPage 在进入对话时会被 FadeOut 并暂时 SetActive(false)。
// 因此需要将“应监听的 ScrollRect”与“当前是否已挂接监听”分开保存
// OnDisable 只解除事件OnEnable 再恢复同一个目标的事件,不能丢失该引用。
private ScrollRect _storyScrollRect;
private bool _isScrollRectSubscribed;
// 重复刻度的初始视觉状态。它们只在当前章节 Timeline 存在期间有效,
// Clear 时会恢复,避免重复进入章节后不断累积额外宽度。
private bool _hasTickRepeatBaseline;
private float _tickRepeatBaseAnchoredX;
private Vector2 _tickRepeatBaseSizeDelta;
private float _contentOriginBaseX;
private float _tickRepeatPeriod;
private void OnEnable()
{
LocalizationSettings.SelectedLocaleChanged += OnSelectedLocaleChanged;
SubscribeToStoryScrollRect();
// StoryPage 从对话界面返回后Canvas 与 StoryTree 会在同一帧重新启用。
// 立即按锚定 Block 的当前实际位置刷新一次,避免首帧沿用淡出前的坐标。
RefreshMarkerPositions();
}
private void OnDisable()
{
LocalizationSettings.SelectedLocaleChanged -= OnSelectedLocaleChanged;
UnsubscribeFromStoryScrollRect();
}
/// <summary>
/// 根据刚构建完成的 StoryTree 创建当前章节的全部 Marker。
/// Marker 的 X 坐标不在 StoryData 中保存,而是始终由 anchorBlockId 对应 TextBlock 的实际屏幕中心推导。
/// </summary>
public void Build(StoryTreeController treeController, StoryData storyData, ScrollRect storyScrollRect)
{
Clear();
_treeController = treeController;
SetStoryScrollRect(storyScrollRect);
if (markerContainer == null || markerPrefab == null || storyData == null)
{
Debug.LogWarning("[StoryTimeline] 缺少 Marker Container、Marker Prefab 或 StoryData无法构建 Timeline。");
return;
}
HashSet<string> markerIds = new HashSet<string>();
foreach (StoryTimelineMarkerDefinition marker in storyData.timelineMarkers)
{
if (!TryCreateMarker(marker, storyData, markerIds))
{
continue;
}
}
// 强制一次 Canvas 更新,确保由 StoryTree 在本帧刚设置的 Content / Block 位置已可用于坐标转换。
Canvas.ForceUpdateCanvases();
CacheTickRepeatBaseline();
RefreshMarkerStates();
RefreshMarkerPositions();
}
/// <summary>
/// 清理本章节运行时生成的全部 Marker。StoryData 与 Prefab 均不会被修改。
/// </summary>
public void Clear()
{
RestoreTickRepeatBaseline();
foreach (RuntimeMarker marker in _markers)
{
if (marker.view != null)
{
Destroy(marker.view.gameObject);
}
}
_markers.Clear();
_treeController = null;
SetStoryScrollRect(null);
if (progressText != null)
{
progressText.text = "0%";
}
}
/// <summary>
/// 根据锚定 TextBlock 的实时状态更新 Marker 可见性和章节主线进度。
/// Current / Completed 显示 MarkerLocked / Forbidden 隐藏。
/// 进度只统计 Completed 且 contributesToMainProgress 的 Marker并取最大值以兼容互斥结局。
/// </summary>
public void RefreshMarkerStates()
{
float mainProgress = 0f;
foreach (RuntimeMarker marker in _markers)
{
StoryBlockState state = marker.anchorView.state;
bool visible = state == StoryBlockState.Current || state == StoryBlockState.Completed;
marker.view.SetVisible(visible);
marker.view.SetInteractable(visible && _treeController != null &&
_treeController.HasRollbackSnapshot(marker.definition.markerId));
if (state == StoryBlockState.Completed && marker.definition.contributesToMainProgress)
{
mainProgress = Mathf.Max(mainProgress, marker.definition.progressValue);
}
}
if (progressText != null)
{
progressText.text = $"{mainProgress * 100f:0.#}%";
}
}
/// <summary>
/// 将每个 Marker 的 X 坐标更新为锚定 TextBlock 的实际视觉中心。
/// 该方法由 ScrollRect.onValueChanged 和 StoryTree 布局完成后的下一帧调用,
/// 从而使 Timeline 只同步横向拖动,同时始终固定在屏幕顶部。
/// </summary>
public void RefreshMarkerPositions()
{
if (markerContainer == null || _treeController == null)
{
return;
}
Camera uiCamera = ResolveUICamera();
foreach (RuntimeMarker marker in _markers)
{
RectTransform anchorRect = marker.anchorView.blockRect;
Vector3 worldCenter = anchorRect.TransformPoint(anchorRect.rect.center);
Vector2 screenPoint = RectTransformUtility.WorldToScreenPoint(uiCamera, worldCenter);
if (RectTransformUtility.ScreenPointToLocalPointInRectangle(
markerContainer, screenPoint, uiCamera, out Vector2 localPoint))
{
marker.view.SetPositionX(localPoint.x);
}
}
RefreshTickRepeatPosition();
}
/// <summary>
/// 记录重复刻度条的初始相位。
/// 之后只把 StoryTree Content 原点的横向屏幕位移映射给刻度条,因此刻度与 Marker
/// 使用同一套坐标来源,而不需要创建大量单独的刻度 Image。
/// </summary>
private void CacheTickRepeatBaseline()
{
if (tickRepeatRect == null || _treeController == null || _treeController.content == null)
{
return;
}
RectTransform tickParent = tickRepeatRect.parent as RectTransform;
if (tickParent == null || !TryConvertWorldPointToLocalX(
tickParent, _treeController.content.TransformPoint(Vector3.zero), out float contentOriginX))
{
return;
}
_tickRepeatPeriod = ResolveTickRepeatPeriod();
if (_tickRepeatPeriod <= Mathf.Epsilon)
{
Debug.LogWarning("[StoryTimeline] Repeat Tick Image 未能解析有效周期,请为 Tick Repeat Period Override 填写大于 0 的值。");
return;
}
_tickRepeatBaseAnchoredX = tickRepeatRect.anchoredPosition.x;
_tickRepeatBaseSizeDelta = tickRepeatRect.sizeDelta;
_contentOriginBaseX = contentOriginX;
_hasTickRepeatBaseline = true;
if (expandTickRepeatRect)
{
// 保留左右各一个完整周期的冗余区域:循环移动到 -P/2 ~ P/2 时,
// Timeline 的可见范围两端仍会持续被 Tiled Image 覆盖。
tickRepeatRect.sizeDelta = _tickRepeatBaseSizeDelta + Vector2.right * (_tickRepeatPeriod * 2f);
}
}
/// <summary>
/// 刷新装饰刻度的平铺相位。使用模运算将位移限制在一个周期内,
/// 视觉上与完整剧情树位移完全等价,同时不会让 Image 的 RectTransform 无限偏离原位。
/// </summary>
private void RefreshTickRepeatPosition()
{
if (!_hasTickRepeatBaseline || tickRepeatRect == null || _treeController == null || _treeController.content == null)
{
return;
}
RectTransform tickParent = tickRepeatRect.parent as RectTransform;
if (tickParent == null || !TryConvertWorldPointToLocalX(
tickParent, _treeController.content.TransformPoint(Vector3.zero), out float contentOriginX))
{
return;
}
float contentDeltaX = contentOriginX - _contentOriginBaseX;
float loopedDeltaX = Mathf.Repeat(contentDeltaX + _tickRepeatPeriod * 0.5f, _tickRepeatPeriod) - _tickRepeatPeriod * 0.5f;
Vector2 position = tickRepeatRect.anchoredPosition;
position.x = _tickRepeatBaseAnchoredX + loopedDeltaX;
tickRepeatRect.anchoredPosition = position;
}
/// <summary>
/// 优先从同物体的标准 uGUI Image 推导 Tiled Sprite 的一个循环宽度。
/// <c>Image.pixelsPerUnit</c> 已包含 Canvas 的单位换算,能与 UI 实际显示尺寸保持一致。
/// </summary>
private float ResolveTickRepeatPeriod()
{
if (tickRepeatPeriodOverride > Mathf.Epsilon)
{
return tickRepeatPeriodOverride;
}
Image tickImage = tickRepeatRect.GetComponent<Image>();
if (tickImage == null || tickImage.sprite == null)
{
return 0f;
}
if (tickImage.type != Image.Type.Tiled)
{
Debug.LogWarning("[StoryTimeline] Tick Repeat Image 建议使用 Image Type = Tiled当前仍会按贴图宽度平移但不会产生重复刻度。", tickImage);
}
return tickImage.sprite.rect.width / tickImage.pixelsPerUnit;
}
/// <summary>
/// 将 StoryTree 内的世界坐标转换到指定 Timeline UI 容器的本地 X 坐标。
/// 与 Marker 的换算过程一致,兼容 Screen Space - Camera Canvas、缩放与不同 RectTransform 层级。
/// </summary>
private bool TryConvertWorldPointToLocalX(RectTransform target, Vector3 worldPoint, out float localX)
{
Camera uiCamera = ResolveUICamera();
Vector2 screenPoint = RectTransformUtility.WorldToScreenPoint(uiCamera, worldPoint);
if (RectTransformUtility.ScreenPointToLocalPointInRectangle(target, screenPoint, uiCamera, out Vector2 localPoint))
{
localX = localPoint.x;
return true;
}
localX = 0f;
return false;
}
/// <summary>
/// 清理当前章节时恢复美术在 Inspector 中设定的刻度条尺寸与位置,
/// 确保下一次 Build 能以新的初始状态重新建立同步基准。
/// </summary>
private void RestoreTickRepeatBaseline()
{
if (!_hasTickRepeatBaseline || tickRepeatRect == null)
{
_hasTickRepeatBaseline = false;
return;
}
Vector2 position = tickRepeatRect.anchoredPosition;
position.x = _tickRepeatBaseAnchoredX;
tickRepeatRect.anchoredPosition = position;
tickRepeatRect.sizeDelta = _tickRepeatBaseSizeDelta;
_hasTickRepeatBaseline = false;
}
private bool TryCreateMarker(StoryTimelineMarkerDefinition marker, StoryData storyData,
HashSet<string> markerIds)
{
if (marker == null || string.IsNullOrWhiteSpace(marker.markerId))
{
Debug.LogWarning("[StoryTimeline] 跳过缺少 Marker ID 的 Timeline 配置。");
return false;
}
if (!markerIds.Add(marker.markerId))
{
Debug.LogWarning($"[StoryTimeline] 跳过重复 Marker ID '{marker.markerId}'。");
return false;
}
StoryBlockDefinition anchorDefinition = storyData.GetBlock(marker.anchorBlockId);
StoryBlockView anchorView = _treeController.GetBlockView(marker.anchorBlockId);
if (anchorDefinition == null || anchorView == null)
{
Debug.LogWarning($"[StoryTimeline] Marker '{marker.markerId}' 的 Anchor Block '{marker.anchorBlockId}' 不存在。");
return false;
}
if (anchorDefinition.blockType != StoryBlockType.Text)
{
Debug.LogWarning($"[StoryTimeline] Marker '{marker.markerId}' 只能锚定 TextBlock'{marker.anchorBlockId}' 当前类型为 {anchorDefinition.blockType}。");
return false;
}
StoryTimelineMarkerView view = Instantiate(markerPrefab, markerContainer);
view.name = $"StoryTimelineMarker_{marker.markerId}";
view.SetLabel(GetLocalizedLabel(marker.labelKey));
RuntimeMarker runtimeMarker = new RuntimeMarker
{
definition = marker,
anchorView = anchorView,
view = view
};
view.BindClick(() => RequestRollback(runtimeMarker));
_markers.Add(runtimeMarker);
return true;
}
/// <summary>
/// 点击可回滚 Marker 后显示通用 SelectionBox。实际恢复由 StoryTreeController 处理,
/// 因而 Timeline 只承担 UI 入口,不保存任何独立剧情状态。
/// </summary>
private void RequestRollback(RuntimeMarker marker)
{
if (marker == null || _treeController == null ||
!_treeController.HasRollbackSnapshot(marker.definition.markerId))
{
return;
}
MessageUIPage messageUIPage = MessageUIPage.instance;
if (messageUIPage == null)
{
Debug.LogWarning("[StoryTimeline] MessageUIPage 未就绪,无法显示剧情回滚确认框。");
return;
}
string markerLabel = GetLocalizedLabel(marker.definition.labelKey);
messageUIPage.ShowSelectionBox(
"重开本章节",
$"将重置“{markerLabel}”所在列及其之后的剧情选择与进度。歌曲成绩、解锁内容、设置和其它章节不会受到影响。",
new List<SelectionBoxOption>
{
new SelectionBoxOption("重开此时间点", () => _treeController.TryRollbackToMarker(marker.definition.markerId)),
new SelectionBoxOption("取消", () => { })
});
}
/// <summary>
/// 使用 StoryTimeline 指定的 Unity Localization String Table 解析 Label Key。
/// String Table 或条目尚未配置时回退显示 Key确保开发阶段仍能从 UI 上定位缺失翻译。
/// </summary>
private string GetLocalizedLabel(string labelKey)
{
if (string.IsNullOrEmpty(labelKey))
{
return string.Empty;
}
try
{
string localizedText = LocalizationSettings.StringDatabase.GetLocalizedString(localizationTable, labelKey);
return string.IsNullOrEmpty(localizedText) ? labelKey : localizedText;
}
catch (Exception exception)
{
Debug.LogWarning($"[StoryTimeline] 无法本地化 Label Key '{labelKey}',将回退显示 Key。{exception.Message}");
return labelKey;
}
}
private void OnSelectedLocaleChanged(Locale _)
{
foreach (RuntimeMarker marker in _markers)
{
marker.view.SetLabel(GetLocalizedLabel(marker.definition.labelKey));
}
}
/// <summary>
/// 设置当前章节 StoryTree 使用的 ScrollRect。
/// 该引用在 Timeline 暂时禁用期间仍会保留,以便 StoryPage 恢复显示时重新建立位置同步。
/// </summary>
private void SetStoryScrollRect(ScrollRect scrollRect)
{
if (_storyScrollRect == scrollRect)
{
return;
}
UnsubscribeFromStoryScrollRect();
_storyScrollRect = scrollRect;
SubscribeToStoryScrollRect();
}
/// <summary>
/// 当 Timeline 处于启用状态时挂接 StoryTree 的拖动回调。
/// 使用独立标志防止 OnEnable、Build 等生命周期入口重复注册同一个监听。
/// </summary>
private void SubscribeToStoryScrollRect()
{
if (_isScrollRectSubscribed || !isActiveAndEnabled || _storyScrollRect == null)
{
return;
}
_storyScrollRect.onValueChanged.AddListener(OnStoryTreeScrolled);
_isScrollRectSubscribed = true;
}
/// <summary>
/// 仅移除运行时事件监听,不清空 <see cref="_storyScrollRect"/>。
/// 对话期间 StoryPage 被临时隐藏后OnEnable 可据此自动恢复 Marker 与 Block 的同步。
/// </summary>
private void UnsubscribeFromStoryScrollRect()
{
if (!_isScrollRectSubscribed)
{
return;
}
if (_storyScrollRect != null)
{
_storyScrollRect.onValueChanged.RemoveListener(OnStoryTreeScrolled);
}
_isScrollRectSubscribed = false;
}
private void OnStoryTreeScrolled(Vector2 _)
{
RefreshMarkerPositions();
}
private Camera ResolveUICamera()
{
Canvas canvas = markerContainer.GetComponentInParent<Canvas>();
if (canvas == null || canvas.renderMode == RenderMode.ScreenSpaceOverlay)
{
return null;
}
return canvas.worldCamera;
}
}
}