Files
ichni_Official/Assets/Scripts/NewStorySystem/Timeline/StoryTimelineController.cs
SoulliesOfficial c30bb258b1 Story排版
2026-07-20 16:56:04 -04:00

424 lines
17 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 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 StoryBlockDefinition anchorDefinition;
public StoryBlockView anchorView;
public StoryTimelineMarkerView view;
}
private readonly List<RuntimeMarker> _markers = new List<RuntimeMarker>();
private StoryTreeController _treeController;
private ScrollRect _subscribedScrollRect;
// 重复刻度的初始视觉状态。它们只在当前章节 Timeline 存在期间有效,
// Clear 时会恢复,避免重复进入章节后不断累积额外宽度。
private bool _hasTickRepeatBaseline;
private float _tickRepeatBaseAnchoredX;
private Vector2 _tickRepeatBaseSizeDelta;
private float _contentOriginBaseX;
private float _tickRepeatPeriod;
private void OnEnable()
{
LocalizationSettings.SelectedLocaleChanged += OnSelectedLocaleChanged;
}
private void OnDisable()
{
LocalizationSettings.SelectedLocaleChanged -= OnSelectedLocaleChanged;
SubscribeScrollRect(null);
}
/// <summary>
/// 根据刚构建完成的 StoryTree 创建当前章节的全部 Marker。
/// Marker 的 X 坐标不在 StoryData 中保存,而是始终由 anchorBlockId 对应 TextBlock 的实际屏幕中心推导。
/// </summary>
public void Build(StoryTreeController treeController, StoryData storyData, ScrollRect storyScrollRect)
{
Clear();
_treeController = treeController;
SubscribeScrollRect(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;
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);
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));
_markers.Add(new RuntimeMarker
{
definition = marker,
anchorDefinition = anchorDefinition,
anchorView = anchorView,
view = view
});
return true;
}
/// <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));
}
}
private void SubscribeScrollRect(ScrollRect scrollRect)
{
if (_subscribedScrollRect == scrollRect)
{
return;
}
if (_subscribedScrollRect != null)
{
_subscribedScrollRect.onValueChanged.RemoveListener(OnStoryTreeScrolled);
}
_subscribedScrollRect = scrollRect;
if (_subscribedScrollRect != null)
{
_subscribedScrollRect.onValueChanged.AddListener(OnStoryTreeScrolled);
}
}
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;
}
}
}