Story排版
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user