Story排版
This commit is contained in:
@@ -38,6 +38,8 @@ namespace Ichni.Story.UI
|
||||
|
||||
Vector2 startPosition = GetLocalPoint(startBlock.outPort, selfRect, uiCamera);
|
||||
Vector2 endPosition = GetLocalPoint(endBlock.inPort, selfRect, uiCamera);
|
||||
Vector2 startBlockCenter = GetLocalRectCenter(startBlock.blockRect, selfRect, uiCamera);
|
||||
Vector2 endBlockCenter = GetLocalRectCenter(endBlock.blockRect, selfRect, uiCamera);
|
||||
|
||||
if (Vector2.Distance(startPosition, endPosition) < DegenerateDistanceThreshold)
|
||||
{
|
||||
@@ -47,7 +49,10 @@ namespace Ichni.Story.UI
|
||||
}
|
||||
|
||||
// 两个控制点构成 S 形:先水平离开起点,再水平进入终点。
|
||||
float midX = (startPosition.x + endPosition.x) / 2f;
|
||||
// 转折列取两个 Block 的逻辑中心中点,而不是两个端口的中点。
|
||||
// 这样当目标 Block 的宽度不同(例如 Important 与 Secondary)但位于同一列时,
|
||||
// 连接线仍会在同一条竖线转折;尺寸差异只影响端口到转折列的水平线长度。
|
||||
float midX = (startBlockCenter.x + endBlockCenter.x) * 0.5f;
|
||||
Vector2 mid1 = new Vector2(midX, startPosition.y);
|
||||
Vector2 mid2 = new Vector2(midX, endPosition.y);
|
||||
|
||||
@@ -66,6 +71,19 @@ namespace Ichni.Story.UI
|
||||
return localPoint;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将任意 RectTransform 的视觉中心转换为连接线容器的局部坐标。
|
||||
/// 使用 rect.center 而非 transform.position,因此不依赖 Block Prefab 自身的 Pivot 配置;
|
||||
/// StoryBlockView 运行时统一为中心 Pivot 后,仍可安全用于未来的任意尺寸 Block。
|
||||
/// </summary>
|
||||
private static Vector2 GetLocalRectCenter(RectTransform rect, RectTransform space, Camera uiCamera)
|
||||
{
|
||||
Vector3 worldCenter = rect.TransformPoint(rect.rect.center);
|
||||
Vector2 screenPoint = RectTransformUtility.WorldToScreenPoint(uiCamera, worldCenter);
|
||||
RectTransformUtility.ScreenPointToLocalPointInRectangle(space, screenPoint, uiCamera, out Vector2 localPoint);
|
||||
return localPoint;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析用于坐标转换的相机:Overlay 画布返回 null,其余(ScreenSpaceCamera / WorldSpace)返回画布相机。
|
||||
/// </summary>
|
||||
|
||||
@@ -1,26 +1,134 @@
|
||||
using Ichni;
|
||||
using Ichni.Menu;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Ichni.Story.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 音乐块视图(占位)。点击后将切换到曲目选择并自动选中对应歌曲,后续阶段接入。
|
||||
/// 剧情树中的歌曲 Block 视图。
|
||||
/// 它只保存 <see cref="StoryBlockDefinition.songName"/> 这一个稳定歌曲标识;歌曲名称、作曲者与插画
|
||||
/// 均从当前章节的 <see cref="SongItemData"/> 读取,避免 StoryData 与选曲资料出现两份可漂移的数据。
|
||||
/// 点击后切换到选曲并自动选中对应歌曲的流程将在后续阶段接入。
|
||||
/// </summary>
|
||||
public class SongBlockView : StoryBlockView
|
||||
{
|
||||
[Header("Song Block")]
|
||||
[Tooltip("Mask 下的 16:9 插画 Image。Sprite 由当前章节的 SongItemData.illustration 提供。")]
|
||||
public Image illustrationImage;
|
||||
|
||||
[Tooltip("illustrationImage 对应的 RectTransform。仅修改其 anchoredPosition.y 以完成纵向裁切。")]
|
||||
public RectTransform illustrationRect;
|
||||
|
||||
public TMP_Text songNameText;
|
||||
public TMP_Text composerText;
|
||||
|
||||
[Tooltip("歌曲或剧情 Block 不可进入时显示的遮罩节点。")]
|
||||
public GameObject lockOverlay;
|
||||
|
||||
private ChapterSelectionUnit _chapter;
|
||||
private SongItemData _song;
|
||||
private bool _canEnterSong;
|
||||
|
||||
public override void Initialize(StoryBlockDefinition def, Vector2 position, StoryBlockState blockState)
|
||||
{
|
||||
base.Initialize(def, position, blockState);
|
||||
|
||||
if (songNameText != null)
|
||||
if (TryResolveSong(out _chapter, out _song))
|
||||
{
|
||||
illustrationImage.sprite = _song.illustration;
|
||||
songNameText.text = _song.displaySongName;
|
||||
composerText.text = _song.composer;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 配置错误时保留稳定 ID 以便定位问题,同时锁定 Block,避免从剧情页绕过内容授权。
|
||||
illustrationImage.sprite = null;
|
||||
songNameText.text = def.songName;
|
||||
composerText.text = string.Empty;
|
||||
}
|
||||
|
||||
// 数据存储的是最终 local Y,而非叠加在 Prefab 默认位置上的偏移量。
|
||||
// Clamp 仍保留作为运行时防线:即使旧资产或外部工具写入越界值,也不会在 Mask 边缘露出空白。
|
||||
Vector2 illustrationPosition = illustrationRect.anchoredPosition;
|
||||
illustrationPosition.y = Mathf.Clamp(def.illustrationLocalY, -74.25f, 74.25f);
|
||||
illustrationRect.anchoredPosition = illustrationPosition;
|
||||
|
||||
ApplyState(blockState);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// StoryBlock 的剧情状态和歌曲的内容解锁状态必须同时满足,才能真正点击进入歌曲。
|
||||
/// <see cref="UnlockSaveModule.CanEnterSong"/> 复用选曲页面的统一授权判断,防止 Story SongBlock 成为绕过入口。
|
||||
/// </summary>
|
||||
public override void ApplyState(StoryBlockState newState)
|
||||
{
|
||||
base.ApplyState(newState);
|
||||
|
||||
bool storyAllowsInteraction = state != StoryBlockState.Locked && state != StoryBlockState.Forbidden;
|
||||
_canEnterSong = _chapter != null && _song != null &&
|
||||
GameSaveManager.instance != null &&
|
||||
GameSaveManager.instance.UnlockSaveModule != null &&
|
||||
GameSaveManager.instance.UnlockSaveModule.CanEnterSong(_chapter, _song);
|
||||
|
||||
bool canInteract = storyAllowsInteraction && _canEnterSong;
|
||||
button.interactable = canInteract;
|
||||
lockOverlay.SetActive(!canInteract);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据正在构建的剧情章节索引定位歌曲,而不是盲目信任 currentChapter。
|
||||
/// 这样即使未来从其它页面直接打开 StoryData,也不会把同名歌曲解析到上一次残留的章节中。
|
||||
/// </summary>
|
||||
private bool TryResolveSong(out ChapterSelectionUnit chapter, out SongItemData song)
|
||||
{
|
||||
chapter = null;
|
||||
song = null;
|
||||
|
||||
string chapterIndex = StoryManager.instance?.treeController?.ActiveChapterIndex;
|
||||
ChapterSelectionManager chapterManager = ChapterSelectionManager.instance;
|
||||
if (string.IsNullOrEmpty(chapterIndex) || chapterManager == null)
|
||||
{
|
||||
Debug.LogWarning($"[SongBlockView] 无法解析歌曲 Block '{blockId}':当前章节或 ChapterSelectionManager 不可用。");
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (ChapterSelectionUnit candidate in chapterManager.chapters)
|
||||
{
|
||||
if (candidate != null && candidate.chapterIndex == chapterIndex)
|
||||
{
|
||||
chapter = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (chapter == null)
|
||||
{
|
||||
Debug.LogWarning($"[SongBlockView] 无法解析歌曲 Block '{blockId}':找不到章节 '{chapterIndex}' 的 ChapterSelectionUnit。");
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (SongItemData candidate in chapter.songs)
|
||||
{
|
||||
if (candidate != null && candidate.songName == definition.songName)
|
||||
{
|
||||
song = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Debug.LogWarning($"[SongBlockView] 无法解析歌曲 Block '{blockId}':章节 '{chapterIndex}' 中不存在歌曲 ID '{definition.songName}'。");
|
||||
return false;
|
||||
}
|
||||
|
||||
protected override void OnClicked()
|
||||
{
|
||||
if (!_canEnterSong)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 占位:后续接入曲目选择流程
|
||||
Debug.Log($"[SongBlockView] 点击音乐块 '{blockId}' (song='{definition.songName}')。占位实现,曲目选择将在后续阶段接入。");
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ namespace Ichni.Story.UI
|
||||
/// 初始化 block 视图。逻辑坐标已由控制器按网格换算为 <paramref name="position"/>
|
||||
/// (以 BlockContainer 左侧中线为原点、x 向右为正、y 向下为正的 anchoredPosition)。
|
||||
/// </summary>
|
||||
/// <param name="def">block 定义(提供尺寸与类型专属数据)。</param>
|
||||
/// <param name="def">Block 定义(提供类型专属数据与剧情配置)。</param>
|
||||
/// <param name="position">已换算好的 anchoredPosition。</param>
|
||||
/// <param name="blockState">推导得到的 block 状态。</param>
|
||||
public virtual void Initialize(StoryBlockDefinition def, Vector2 position, StoryBlockState blockState)
|
||||
@@ -36,11 +36,16 @@ namespace Ichni.Story.UI
|
||||
blockId = def.blockId;
|
||||
state = blockState;
|
||||
|
||||
// 统一坐标约定:以 BlockContainer 的左侧中线为原点,向右为 +x、向下为 +y。
|
||||
// 统一坐标约定:以 BlockContainer 的左侧中线为原点,Block 的中心向右为 +x、向下为 +y。
|
||||
// 所有类型使用中心 Pivot,使相同 gridColumn 始终代表同一条视觉中心列,
|
||||
// 不会再因 Important / Secondary / Song / Tutorial 的宽度不同而产生左右间距不对称。
|
||||
blockRect.anchorMin = new Vector2(0f, 0.5f);
|
||||
blockRect.anchorMax = new Vector2(0f, 0.5f);
|
||||
blockRect.pivot = new Vector2(0f, 0.5f);
|
||||
blockRect.sizeDelta = new Vector2(400, 200);
|
||||
blockRect.pivot = new Vector2(0.5f, 0.5f);
|
||||
|
||||
// 不在基类覆盖 sizeDelta:Block 的尺寸是 Prefab 的视觉契约。
|
||||
// Important / Secondary TextBlock,以及未来不同尺寸的 Song / Tutorial Block,
|
||||
// 都可保留各自的宽高;StoryTreeController 会据此计算 Content 边界和连接线端口。
|
||||
blockRect.anchoredPosition = position;
|
||||
|
||||
if (button != null)
|
||||
@@ -58,12 +63,12 @@ namespace Ichni.Story.UI
|
||||
state = newState;
|
||||
|
||||
if (button != null)
|
||||
button.interactable = state != StoryBlockState.Locked;
|
||||
button.interactable = state != StoryBlockState.Locked && state != StoryBlockState.Forbidden;
|
||||
}
|
||||
|
||||
private void HandleClick()
|
||||
{
|
||||
if (state == StoryBlockState.Locked)
|
||||
if (state == StoryBlockState.Locked || state == StoryBlockState.Forbidden)
|
||||
return;
|
||||
|
||||
if (StoryManager.instance != null && StoryManager.instance.treeController != null)
|
||||
|
||||
@@ -18,6 +18,9 @@ namespace Ichni.Story
|
||||
public StoryUIPage storyUIPage;
|
||||
public CharacterRegistry characterRegistry;
|
||||
|
||||
[Tooltip("全章节共用的 Helper 对话数据。它独立于各章节 StoryData,避免重复配置相同的 Helper。")]
|
||||
public StoryHelperData helperData;
|
||||
|
||||
[Header("Chapter Data")]
|
||||
[Tooltip("章节索引 (chapterIndex) -> 该章节的 StoryData 资产")]
|
||||
public Dictionary<string, StoryData> storyDatas = new Dictionary<string, StoryData>();
|
||||
|
||||
@@ -4,6 +4,8 @@ using Ichni.Story.UI;
|
||||
using Sirenix.OdinInspector;
|
||||
using UniRx;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Serialization;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Ichni.Story
|
||||
{
|
||||
@@ -18,19 +20,60 @@ namespace Ichni.Story
|
||||
[Header("UI References")]
|
||||
[Tooltip("blocks 与 connectors 共用的单一容器(即 ScrollRect 的 content,localScale 必须为 1)")]
|
||||
public RectTransform content;
|
||||
public GameObject textBlockPrefab;
|
||||
|
||||
[Tooltip("承载剧情树拖动的 ScrollRect。StoryTimeline 监听其横向位置,以同步顶部 Marker。")]
|
||||
public ScrollRect storyScrollRect;
|
||||
|
||||
[Tooltip("固定在剧情页顶部的 StoryTimeline。未配置时剧情树仍可独立运行。")]
|
||||
public StoryTimelineController storyTimeline;
|
||||
|
||||
/// <summary>
|
||||
/// 重要 TextBlock 的 Prefab。保留 FormerlySerializedAs,使旧场景中的 textBlockPrefab 引用
|
||||
/// 自动迁移到此字段;在新 Prefab 配置完成前,已有 Important Block 不会失去引用。
|
||||
/// </summary>
|
||||
[FormerlySerializedAs("textBlockPrefab")]
|
||||
[Tooltip("对应 TextBlockImportance.Important。Prefab 自行定义实际尺寸,例如 600 × 375。")]
|
||||
public GameObject importantTextBlockPrefab;
|
||||
|
||||
/// <summary>
|
||||
/// 次要 TextBlock 的 Prefab。由 StoryBlockDefinition.textImportance 选择;
|
||||
/// Prefab 自行定义实际尺寸,例如 400 × 200。
|
||||
/// </summary>
|
||||
[Tooltip("对应 TextBlockImportance.Secondary。Prefab 自行定义实际尺寸,例如 400 × 200。")]
|
||||
public GameObject secondaryTextBlockPrefab;
|
||||
|
||||
public GameObject songBlockPrefab;
|
||||
public GameObject tutorialBlockPrefab;
|
||||
public GameObject connectorPrefab;
|
||||
|
||||
[Header("Layout Settings")]
|
||||
[Tooltip("网格单元格尺寸(决定相邻列/行的步距,全局统一)")]
|
||||
public Vector2 cellSize = new Vector2(400f, 200f);
|
||||
/// <summary>
|
||||
/// 相邻叙事列之间的固定距离。它是 StoryData.gridColumn 的坐标步距,
|
||||
/// 不等于也不会覆盖任何 Block Prefab 的宽度。
|
||||
/// 默认值为 Important TextBlock 宽度 600 加最小横向留白 120。
|
||||
/// </summary>
|
||||
[Tooltip("StoryData.gridColumn 的水平步距,不会改变任何 Prefab 的实际宽度。")]
|
||||
public float columnStep = 720f;
|
||||
|
||||
[Tooltip("相邻单元格之间的间隔(水平 x、垂直 y)")]
|
||||
public Vector2 cellSpacing = new Vector2(120f, 150f);
|
||||
/// <summary>
|
||||
/// 相邻路线行之间的固定距离。它是 StoryData.gridRow 的坐标步距,
|
||||
/// 不等于也不会覆盖任何 Block Prefab 的高度。
|
||||
/// 默认值为 Important TextBlock 高度 375 加最小纵向留白 150。
|
||||
/// </summary>
|
||||
[Tooltip("StoryData.gridRow 的垂直步距,不会改变任何 Prefab 的实际高度。")]
|
||||
public float rowStep = 525f;
|
||||
|
||||
[Tooltip("按所有 Block 的真实边界自动归一化后的左侧留白。")]
|
||||
public float marginLeft = 50f;
|
||||
|
||||
/// <summary>
|
||||
/// 为剧情页左侧常驻 UI(当前为 Helper)预留的额外水平空间。
|
||||
/// 最左 Block 的真实左边缘会被放置在 <see cref="marginLeft"/> 加本值的位置;
|
||||
/// 默认 400,等价于在原有布局基础上将整棵剧情树向右平移 400 单位。
|
||||
/// </summary>
|
||||
[Tooltip("为左侧 Helper 等常驻 UI 预留的额外空间。默认 1500,会让所有 Block 整体向右移动 1500。")]
|
||||
public float helperReservedLeftSpace = 1500f;
|
||||
|
||||
public float marginRight = 50f;
|
||||
public float marginTop = 50f;
|
||||
public float marginBottom = 50f;
|
||||
@@ -45,12 +88,32 @@ namespace Ichni.Story
|
||||
private string _chapterIndex;
|
||||
private StoryData _storyData;
|
||||
|
||||
// 由 SetUpBackground 根据最左侧 Block 的真实边界计算。
|
||||
// 未来 Timeline 在独立 UI 容器中生成 Marker 时,也必须叠加此偏移量才能与 Block 中心对齐。
|
||||
private float _layoutHorizontalOffset;
|
||||
|
||||
/// <summary>当前已构建章节的 StoryData(供对话控制器切换 YarnProject 等使用)。</summary>
|
||||
public StoryData ActiveStoryData => _storyData;
|
||||
|
||||
/// <summary>当前已构建章节的索引。</summary>
|
||||
public string ActiveChapterIndex => _chapterIndex;
|
||||
|
||||
/// <summary>
|
||||
/// 当前章节的自动横向留白偏移。它不是 StoryData 的内容,而是根据本次实例化后的
|
||||
/// 真实 Prefab 尺寸计算出的运行时布局结果。
|
||||
/// </summary>
|
||||
public float LayoutHorizontalOffset => _layoutHorizontalOffset;
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定叙事列在 BlockContainer 中的最终中心 X 坐标。
|
||||
/// Timeline Marker 必须使用此方法,而不是自行只计算 gridColumn * columnStep,
|
||||
/// 才能在不同尺寸 Block 自动留白后继续与其中心对齐。
|
||||
/// </summary>
|
||||
public float GetColumnCenterX(float gridColumn)
|
||||
{
|
||||
return _layoutHorizontalOffset + gridColumn * columnStep;
|
||||
}
|
||||
|
||||
// 当前章节已完成的 block id 集合(状态推导的唯一进度来源)
|
||||
private readonly HashSet<string> _completed = new HashSet<string>();
|
||||
|
||||
@@ -111,6 +174,10 @@ namespace Ichni.Story
|
||||
// 连线在下一帧刷新(见 RefreshConnectors):等待 Unity 完成 Canvas 布局,
|
||||
// 使 block 端口的世界坐标稳定,避免端口坐标尚未生效时塌缩到同一点。
|
||||
RefreshConnectors();
|
||||
|
||||
// Timeline 是故事树的固定顶部投影:必须在 Block 真实边界与 Content 留白均确定后再构建,
|
||||
// 才能使用锚定 TextBlock 的最终视觉中心进行横向同步。
|
||||
storyTimeline?.Build(this, _storyData, storyScrollRect);
|
||||
}
|
||||
|
||||
// ── Generation ──────────────────────────────────────────────────────────
|
||||
@@ -122,7 +189,9 @@ namespace Ichni.Story
|
||||
{
|
||||
GameObject prefab = def.blockType switch
|
||||
{
|
||||
StoryBlockType.Text => textBlockPrefab,
|
||||
StoryBlockType.Text => def.textImportance == TextBlockImportance.Important
|
||||
? importantTextBlockPrefab
|
||||
: secondaryTextBlockPrefab,
|
||||
StoryBlockType.Song => songBlockPrefab,
|
||||
StoryBlockType.Tutorial => tutorialBlockPrefab,
|
||||
_ => null
|
||||
@@ -130,7 +199,10 @@ namespace Ichni.Story
|
||||
|
||||
if (prefab == null)
|
||||
{
|
||||
Debug.LogError($"[StoryTreeController] block 类型 {def.blockType} 未配置预制体。");
|
||||
string prefabDescription = def.blockType == StoryBlockType.Text
|
||||
? $"TextBlockImportance.{def.textImportance}"
|
||||
: def.blockType.ToString();
|
||||
Debug.LogError($"[StoryTreeController] block '{def.blockId}' 的 {prefabDescription} 未配置预制体。");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -181,17 +253,27 @@ namespace Ichni.Story
|
||||
// ── State / Unlocking ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 依据"已完成集合 + 解锁条件"推导单个 block 的状态。
|
||||
/// 已完成 → Completed;否则满足解锁条件 → Current;否则 → Locked。
|
||||
/// 解锁条件为空视为无条件满足(章节起始即为 Current)。
|
||||
/// 依据“禁用条件 + 已完成集合 + 解锁条件”推导单个 Block 的状态。
|
||||
/// Forbidden Condition 满足时优先显示 Forbidden;否则依次为 Completed、Current、Locked。
|
||||
/// Unlock Condition 未配置时视为无条件满足(章节起始即为 Current)。
|
||||
/// </summary>
|
||||
private StoryBlockState DeriveState(StoryBlockDefinition def)
|
||||
{
|
||||
if (def.forbiddenCondition != null &&
|
||||
def.forbiddenCondition.IsSatisfied(GetVariable, IsBlockCompleted))
|
||||
{
|
||||
return StoryBlockState.Forbidden;
|
||||
}
|
||||
|
||||
if (_completed.Contains(def.blockId))
|
||||
return StoryBlockState.Completed;
|
||||
|
||||
if (def.unlockCondition.IsSatisfied(GetVariable, IsBlockCompleted))
|
||||
if (def.unlockCondition == null ||
|
||||
!def.unlockCondition.IsConfigured ||
|
||||
def.unlockCondition.IsSatisfied(GetVariable, IsBlockCompleted))
|
||||
{
|
||||
return StoryBlockState.Current;
|
||||
}
|
||||
|
||||
return StoryBlockState.Locked;
|
||||
}
|
||||
@@ -208,6 +290,8 @@ namespace Ichni.Story
|
||||
|
||||
view.ApplyState(DeriveState(def));
|
||||
}
|
||||
|
||||
storyTimeline?.RefreshMarkerStates();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -229,18 +313,11 @@ namespace Ichni.Story
|
||||
|
||||
private bool IsBlockCompleted(string blockId) => _completed.Contains(blockId);
|
||||
|
||||
// 阶段 2 将改由 StoryVariableStorage 提供;此处从全局变量存档读取作占位
|
||||
// 阶段 2 将改由 StoryVariableStorage 提供;此处统一经 StoryVariables 读取,
|
||||
// 以便后续切换到按章节保存的变量容器时不再让故事树直接依赖存档结构。
|
||||
private int GetVariable(string variableName)
|
||||
{
|
||||
StoryVariablesSave vars = GameSaveManager.instance.StorySaveModule.variables;
|
||||
|
||||
if (vars.floatVariables.TryGetValue(variableName, out float f))
|
||||
return Mathf.RoundToInt(f);
|
||||
|
||||
if (vars.boolVariables.TryGetValue(variableName, out bool b))
|
||||
return b ? 1 : 0;
|
||||
|
||||
return 0;
|
||||
return StoryVariables.GetInt(variableName);
|
||||
}
|
||||
|
||||
// ── Persistence ─────────────────────────────────────────────────────────
|
||||
@@ -263,30 +340,38 @@ namespace Ichni.Story
|
||||
// ── Layout ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 将 block 的网格坐标 (gridColumn, gridRow) 换算为 BlockContainer 中的 anchoredPosition。
|
||||
/// 约定左侧中线为原点:列向右为正,行向下为正、向上为负(anchoredPosition.y 取负)。
|
||||
/// gridColumn / gridRow 支持负数与小数,可用于微调 block 位置。
|
||||
/// 将 Block 的静态叙事坐标 (gridColumn, gridRow) 换算为 BlockContainer 中的预归一化中心位置。
|
||||
/// 约定列中心向右为正,行中心向下为正、向上为负(anchoredPosition.y 取负)。
|
||||
/// <para>columnStep / rowStep 只定义坐标锚点的间距,Block 的真实尺寸始终由各自 Prefab 保持;
|
||||
/// 因而 Important、Secondary、Song、Tutorial 及未来新增类型可使用不同尺寸。</para>
|
||||
/// <para>gridColumn / gridRow 支持负数与小数,供静态剧情排版微调;Timeline 应通过
|
||||
/// <see cref="GetColumnCenterX"/> 查询横向位置,以保持标记和 Block 对齐。</para>
|
||||
/// </summary>
|
||||
private Vector2 GetGridPosition(StoryBlockDefinition def)
|
||||
{
|
||||
float x = marginLeft + def.gridColumn * (cellSize.x + cellSpacing.x);
|
||||
float y = -def.gridRow * (cellSize.y + cellSpacing.y);
|
||||
float x = GetColumnCenterX(def.gridColumn);
|
||||
float y = -def.gridRow * rowStep;
|
||||
return new Vector2(x, y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据 block 分布计算并设置 content(BlockContainer) 尺寸。
|
||||
/// block 采用左中 pivot:anchoredPosition.x 为左边缘,y 相对垂直中线(上正下负)。
|
||||
/// 根据所有 Block 的真实矩形边界归一化横向位置并设置 Content(BlockContainer) 尺寸。
|
||||
/// <para>Block 采用中心 Pivot:anchoredPosition 表示 Block 中心。因此会先用
|
||||
/// centerX ± width / 2 求取左右边界,再把全体 Block 平移到左侧留白与 Helper 预留区之后。</para>
|
||||
/// <para>该步骤允许不同尺寸的 Important / Secondary / Song / Tutorial Block 共用相同的
|
||||
/// 逻辑列中心,且不会因最左 Block 的宽度不同而被 Content 裁切。</para>
|
||||
/// </summary>
|
||||
public void SetUpBackground()
|
||||
{
|
||||
if (blocks.Count == 0)
|
||||
{
|
||||
_layoutHorizontalOffset = 0f;
|
||||
content.sizeDelta = new Vector2(minContentWidth, minContentHeight);
|
||||
return;
|
||||
}
|
||||
|
||||
float maxRight = 0f;
|
||||
float minLeft = float.PositiveInfinity;
|
||||
float maxRight = float.NegativeInfinity;
|
||||
float maxVertical = 0f; // 距垂直中线的最大延伸(上下取较大者,用于对称扩展 content 高度)
|
||||
|
||||
foreach (StoryBlockView block in blocks)
|
||||
@@ -295,15 +380,34 @@ namespace Ichni.Story
|
||||
Vector2 pos = rect.anchoredPosition;
|
||||
Vector2 size = rect.sizeDelta;
|
||||
|
||||
float rightEdge = pos.x + size.x;
|
||||
float leftEdge = pos.x - size.x * 0.5f;
|
||||
float rightEdge = pos.x + size.x * 0.5f;
|
||||
float topExtent = pos.y + size.y * 0.5f;
|
||||
float bottomExtent = -(pos.y - size.y * 0.5f);
|
||||
|
||||
if (leftEdge < minLeft) minLeft = leftEdge;
|
||||
if (rightEdge > maxRight) maxRight = rightEdge;
|
||||
if (topExtent > maxVertical) maxVertical = topExtent;
|
||||
if (bottomExtent > maxVertical) maxVertical = bottomExtent;
|
||||
}
|
||||
|
||||
// 用真实最左边缘归一化所有 Block。这样 gridColumn = 0 不再要求作者预先知道
|
||||
// 最宽 Prefab 的一半宽度;未来加入更宽的 Song / Tutorial Block 也会自动留出左侧空间。
|
||||
// helperReservedLeftSpace 额外为左侧常驻 Helper 保留可视区域,避免第一列被遮挡。
|
||||
float targetLeftEdge = marginLeft + helperReservedLeftSpace;
|
||||
float horizontalCorrection = targetLeftEdge - minLeft;
|
||||
if (!Mathf.Approximately(horizontalCorrection, 0f))
|
||||
{
|
||||
foreach (StoryBlockView block in blocks)
|
||||
block.blockRect.anchoredPosition += Vector2.right * horizontalCorrection;
|
||||
|
||||
maxRight += horizontalCorrection;
|
||||
}
|
||||
|
||||
// SetUpBackground 可能因尺寸变化被重复调用;偏移量必须累积,不能在第二次调用时
|
||||
// 被归零,否则未来 Timeline 会失去与已平移 Block 的横向对齐。
|
||||
_layoutHorizontalOffset += horizontalCorrection;
|
||||
|
||||
float width = Mathf.Max(maxRight + marginRight, minContentWidth);
|
||||
float height = Mathf.Max(maxVertical * 2f + marginTop + marginBottom, minContentHeight);
|
||||
|
||||
@@ -321,6 +425,9 @@ namespace Ichni.Story
|
||||
{
|
||||
connector.SetCurve();
|
||||
}
|
||||
|
||||
// 同一帧中 Content 的尺寸与 Block 的世界坐标已稳定,刷新 Timeline 的首次对齐位置。
|
||||
storyTimeline?.RefreshMarkerPositions();
|
||||
}).AddTo(this);
|
||||
}
|
||||
|
||||
@@ -331,6 +438,8 @@ namespace Ichni.Story
|
||||
|
||||
private void ClearTree()
|
||||
{
|
||||
storyTimeline?.Clear();
|
||||
|
||||
foreach (StoryBlockView block in blocks)
|
||||
if (block != null) Destroy(block.gameObject);
|
||||
blocks.Clear();
|
||||
@@ -339,6 +448,8 @@ namespace Ichni.Story
|
||||
if (connector != null) Destroy(connector.gameObject);
|
||||
connectors.Clear();
|
||||
|
||||
_layoutHorizontalOffset = 0f;
|
||||
|
||||
if (content != null)
|
||||
content.sizeDelta = Vector2.zero;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace Ichni.Story.UI
|
||||
public TMP_Text storyIdText;
|
||||
public TMP_Text titleText;
|
||||
|
||||
[Tooltip("三种状态对应的背景预制体,生成 block 时按当前状态实例化为第一个子物体")]
|
||||
[Tooltip("各状态对应的背景预制体。未配置 Forbidden 时会自动回退到 Locked 背景。")]
|
||||
public Dictionary<StoryBlockState, GameObject> blockVisuals = new Dictionary<StoryBlockState, GameObject>();
|
||||
|
||||
/// <summary>该文本块对应的 Yarn 节点名。</summary>
|
||||
@@ -59,14 +59,26 @@ namespace Ichni.Story.UI
|
||||
_currentVisual = null;
|
||||
}
|
||||
|
||||
if (blockVisuals == null ||
|
||||
!blockVisuals.TryGetValue(newState, out GameObject prefab) ||
|
||||
prefab == null)
|
||||
if (blockVisuals == null)
|
||||
{
|
||||
Debug.LogWarning($"[TextBlockView] block '{blockId}' 缺少状态 {newState} 的背景预制体(blockVisuals 未配置)。");
|
||||
return;
|
||||
}
|
||||
|
||||
// Forbidden 专用美术尚未配置前沿用 Locked 背景,保证互斥路线 Block 始终可见且不可点击。
|
||||
// 后续只需在 Inspector 中补充 Forbidden 键,即可自动覆盖此回退表现。
|
||||
if ((!blockVisuals.TryGetValue(newState, out GameObject prefab) || prefab == null) &&
|
||||
newState == StoryBlockState.Forbidden)
|
||||
{
|
||||
blockVisuals.TryGetValue(StoryBlockState.Locked, out prefab);
|
||||
}
|
||||
|
||||
if (prefab == null)
|
||||
{
|
||||
Debug.LogWarning($"[TextBlockView] block '{blockId}' 缺少状态 {newState} 的背景预制体。");
|
||||
return;
|
||||
}
|
||||
|
||||
_currentVisual = Instantiate(prefab, blockRect);
|
||||
_currentVisual.transform.SetAsFirstSibling();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user