Story排版

This commit is contained in:
SoulliesOfficial
2026-07-20 16:56:04 -04:00
parent 04ec368a59
commit c30bb258b1
107 changed files with 7794 additions and 969 deletions

View File

@@ -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 显示 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;
}
}
}