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 { /// /// 剧情页顶部固定的 StoryTimeline 控制器。 /// 它将 StoryData 中的时间 Marker 投影到固定 UI 层,并以锚定 TextBlock 的实际视觉中心同步横向位置; /// 因而不会受 StoryTree 的 Helper 留白、Content 尺寸变化或不同 Block Prefab 尺寸影响。 /// Timeline 本身不保存进度。Marker 可见性来自 StoryBlockState,主线百分比来自已完成 Marker 的最大 Progress Value。 /// 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 _markers = new List(); 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); } /// /// 根据刚构建完成的 StoryTree 创建当前章节的全部 Marker。 /// Marker 的 X 坐标不在 StoryData 中保存,而是始终由 anchorBlockId 对应 TextBlock 的实际屏幕中心推导。 /// 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 markerIds = new HashSet(); foreach (StoryTimelineMarkerDefinition marker in storyData.timelineMarkers) { if (!TryCreateMarker(marker, storyData, markerIds)) { continue; } } // 强制一次 Canvas 更新,确保由 StoryTree 在本帧刚设置的 Content / Block 位置已可用于坐标转换。 Canvas.ForceUpdateCanvases(); CacheTickRepeatBaseline(); RefreshMarkerStates(); RefreshMarkerPositions(); } /// /// 清理本章节运行时生成的全部 Marker。StoryData 与 Prefab 均不会被修改。 /// 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%"; } } /// /// 根据锚定 TextBlock 的实时状态更新 Marker 可见性和章节主线进度。 /// Current / Completed 显示 Marker;Locked / Forbidden 隐藏。 /// 进度只统计 Completed 且 contributesToMainProgress 的 Marker,并取最大值以兼容互斥结局。 /// 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.#}%"; } } /// /// 将每个 Marker 的 X 坐标更新为锚定 TextBlock 的实际视觉中心。 /// 该方法由 ScrollRect.onValueChanged 和 StoryTree 布局完成后的下一帧调用, /// 从而使 Timeline 只同步横向拖动,同时始终固定在屏幕顶部。 /// 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(); } /// /// 记录重复刻度条的初始相位。 /// 之后只把 StoryTree Content 原点的横向屏幕位移映射给刻度条,因此刻度与 Marker /// 使用同一套坐标来源,而不需要创建大量单独的刻度 Image。 /// 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); } } /// /// 刷新装饰刻度的平铺相位。使用模运算将位移限制在一个周期内, /// 视觉上与完整剧情树位移完全等价,同时不会让 Image 的 RectTransform 无限偏离原位。 /// 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; } /// /// 优先从同物体的标准 uGUI Image 推导 Tiled Sprite 的一个循环宽度。 /// Image.pixelsPerUnit 已包含 Canvas 的单位换算,能与 UI 实际显示尺寸保持一致。 /// private float ResolveTickRepeatPeriod() { if (tickRepeatPeriodOverride > Mathf.Epsilon) { return tickRepeatPeriodOverride; } Image tickImage = tickRepeatRect.GetComponent(); 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; } /// /// 将 StoryTree 内的世界坐标转换到指定 Timeline UI 容器的本地 X 坐标。 /// 与 Marker 的换算过程一致,兼容 Screen Space - Camera Canvas、缩放与不同 RectTransform 层级。 /// 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; } /// /// 清理当前章节时恢复美术在 Inspector 中设定的刻度条尺寸与位置, /// 确保下一次 Build 能以新的初始状态重新建立同步基准。 /// 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 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; } /// /// 使用 StoryTimeline 指定的 Unity Localization String Table 解析 Label Key。 /// String Table 或条目尚未配置时回退显示 Key,确保开发阶段仍能从 UI 上定位缺失翻译。 /// 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(); if (canvas == null || canvas.renderMode == RenderMode.ScreenSpaceOverlay) { return null; } return canvas.worldCamera; } } }