@@ -0,0 +1,423 @@
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 显示 Marker; Locked / 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 ;
}
}
}