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

@@ -12,7 +12,8 @@ namespace Ichni.Story
{
Locked,
Current,
Completed
Completed,
Forbidden
}
/// <summary>
@@ -25,6 +26,16 @@ namespace Ichni.Story
Tutorial
}
/// <summary>
/// TextBlock 的叙事层级。层级只决定剧情树中的视觉样式和 checkpoint 候选资格,
/// 不会自行改变 Block 的解锁条件或 Yarn 执行逻辑。
/// </summary>
public enum TextBlockImportance
{
Important,
Secondary
}
/// <summary>
/// 变量条件的比较方式。
/// </summary>
@@ -69,7 +80,18 @@ namespace Ichni.Story
[FoldoutGroup("$blockId")]
[LabelText("Unlock Condition")]
public UnlockCondition unlockCondition = new UnlockCondition();
[InfoBox("留空表示无条件解锁(章节起始即可用)。")]
public StoryCondition unlockCondition = new StoryCondition();
/// <summary>
/// 用于禁用当前 Block 的可选条件。它与解锁条件共用同一种通用条件树,
/// 因而可直接使用剧情变量、Block 完成状态及 AND / OR / NOT 组合。
/// 条件满足时,该 Block 的状态固定为 Forbidden且优先于 Completed / Current / Locked。
/// </summary>
[FoldoutGroup("$blockId")]
[LabelText("Forbidden Condition")]
[InfoBox("可选。留空表示此 Block 不会被条件禁用;满足时显示为 Forbidden 且不可点击。")]
public StoryCondition forbiddenCondition = new StoryCondition();
// ── Text Block ──────────────────────────────────────────────────────────
@@ -83,13 +105,35 @@ namespace Ichni.Story
[LabelText("Title Key (Localization)")]
public string titleKey;
/// <summary>
/// TextBlock 的视觉和叙事层级。Important 用于主线转折与后续 checkpoint
/// Secondary 用于支线或补充剧情。默认 Important以保持现有 TextBlock 的呈现语义。
/// </summary>
[FoldoutGroup("$blockId")]
[ShowIf("blockType", StoryBlockType.Text)]
[LabelText("Text Importance")]
public TextBlockImportance textImportance = TextBlockImportance.Important;
// ── Song Block ──────────────────────────────────────────────────────────
[FoldoutGroup("$blockId")]
[ShowIf("blockType", StoryBlockType.Song)]
[LabelText("Song Name")]
[LabelText("Song Name (Stable ID)")]
[InfoBox("对应 ChapterSelectionUnit 中 SongItemData.songName 的稳定歌曲标识。它用于查找歌曲资料与进入选曲,不能填写 displaySongName、翻译文本或作曲者名称。")]
public string songName;
/// <summary>
/// 插画在 SongBlock 的 Mask 中使用的实际本地 Y 坐标。SongBlock 的 Illustration 固定为 400 x 225
/// Mask 可视区域固定为当前 Prefab 的尺寸;在不露出上下空白的前提下,安全范围为 -74.25 到 74.25。
/// 该值是最终坐标而非偏移量0 表示居中,负值向下移动,正值向上移动。
/// </summary>
[FoldoutGroup("$blockId")]
[ShowIf("blockType", StoryBlockType.Song)]
[LabelText("Illustration Local Y")]
[Range(-74.25f, 74.25f)]
[InfoBox("用于微调 16:9 插画在 SongBlock Mask 中的纵向裁切。请保持 SongBlockUI 的 Illustration 尺寸为 400 x 225。")]
public float illustrationLocalY;
// ── Tutorial Block ──────────────────────────────────────────────────────
[FoldoutGroup("$blockId")]
@@ -145,26 +189,35 @@ namespace Ichni.Story
}
/// <summary>
/// Block 的解锁条件。以一棵可组合的条件树(<see cref="StoryConditionNode"/>)表达,
/// 支持"与 / 或 / 非 + 叶子条件"的任意嵌套。根节点为空时视为无条件解锁(始终满足)。
/// 通用剧情条件容器。以一棵可组合的条件树(<see cref="StoryConditionNode"/>)表达,
/// 支持与 / 或 / 非 + 叶子条件的任意嵌套。
/// <para>该类型不限定用途,可用于 Block 的解锁、禁用、Helper 对话筛选等所有剧情判断。</para>
/// <para>根节点为空表示“未配置条件”,其是否视为通过由调用方根据业务语义决定;
/// 例如 unlockCondition 留空即允许,而 forbiddenCondition 留空即不禁用。</para>
/// </summary>
[InlineProperty]
[Serializable]
public class UnlockCondition
public class StoryCondition
{
[HideLabel]
[SerializeReference]
[InfoBox("留空表示无条件解锁(章节起始即可用)。可选择 All Of(AND) / Any Of(OR) / Not 复合节点进行任意嵌套。")]
[InfoBox("留空表示未配置条件。可选择 All Of(AND) / Any Of(OR) / Not 复合节点进行任意嵌套。")]
public StoryConditionNode root;
/// <summary>
/// 检查解锁条件是否满足。根节点为空视为满足
/// 当前容器是否已配置实际条件
/// </summary>
public bool IsConfigured => root != null;
/// <summary>
/// 检查已配置条件是否满足。根节点为空时返回 false调用方应结合
/// <see cref="IsConfigured"/> 决定“未配置条件”的业务含义。
/// </summary>
/// <param name="getVariable">按变量名返回其整型值的委托。</param>
/// <param name="isBlockCompleted">按 blockId 判断该 block 是否已完成的委托。</param>
public bool IsSatisfied(Func<string, int> getVariable, Func<string, bool> isBlockCompleted)
{
return root == null || root.Evaluate(getVariable, isBlockCompleted);
return root != null && root.Evaluate(getVariable, isBlockCompleted);
}
}
}

View File

@@ -6,8 +6,8 @@ using UnityEngine;
namespace Ichni.Story
{
/// <summary>
/// 解锁条件树的节点基类。通过多态组合出"与 / 或 / 非 + 叶子条件"的复合表达式。
/// 由 <see cref="UnlockCondition"/> 借助 <c>[SerializeReference]</c> 序列化持有,
/// 通用剧情条件树的节点基类。通过多态组合出与 / 或 / 非 + 叶子条件的复合表达式。
/// 由 <see cref="StoryCondition"/> 借助 <c>[SerializeReference]</c> 序列化持有,
/// Odin 会以类型下拉框展示可选的节点类型。
/// </summary>
[Serializable]
@@ -17,7 +17,7 @@ namespace Ichni.Story
/// 对该节点求值。
/// </summary>
/// <param name="getVariable">按变量名返回其整型值的委托。</param>
/// <param name="isBlockCompleted">按 blockId 判断该 block 是否已完成的委托。</param>
/// <param name="isBlockCompleted">按 blockId 判断该 Block 是否已完成的委托。</param>
public abstract bool Evaluate(Func<string, int> getVariable, Func<string, bool> isBlockCompleted);
}
@@ -73,7 +73,7 @@ namespace Ichni.Story
}
/// <summary>
/// 复合节点(逻辑非 NOT对子条件取反。子条件为空视为满足等价于"非 假")。
/// 复合节点(逻辑非 NOT对子条件取反。子条件为空视为满足等价于非 假)。
/// </summary>
[Serializable]
[LabelText("Not")]
@@ -90,7 +90,7 @@ namespace Ichni.Story
}
/// <summary>
/// 叶子节点:指定 block 已完成时满足。
/// 叶子节点:指定 Block 已完成时满足。
/// </summary>
[Serializable]
[LabelText("Block Completed")]
@@ -107,6 +107,7 @@ namespace Ichni.Story
/// <summary>
/// 叶子节点:将指定变量的当前值与目标值按比较方式求值。
/// 既可用于解锁条件,也可用于 Forbidden Condition例如当路线变量等于另一条路线的值时禁用 Block。
/// </summary>
[Serializable]
[LabelText("Variable Compare")]

View File

@@ -27,6 +27,16 @@ namespace Ichni.Story
[ListDrawerSettings(ShowFoldout = true, DefaultExpandedState = false, DraggableItems = true, ShowItemCount = true)]
public List<StoryBlockDefinition> blocks = new List<StoryBlockDefinition>();
/// <summary>
/// 章节顶部 Timeline 的静态标记定义。
/// 标记的横向位置始终由 <see cref="StoryTimelineMarkerDefinition.anchorBlockId"/> 指向的
/// Block 的 <c>gridColumn</c> 推导,不在这里重复保存坐标,避免剧情树与 Timeline 脱节。
/// </summary>
[LabelText("Timeline Markers")]
[ListDrawerSettings(ShowFoldout = true, DefaultExpandedState = false, DraggableItems = true, ShowItemCount = true)]
[InfoBox("每个标记锚定到一个 TextBlock运行时使用该 Block 的 Grid Column 对齐 Timeline。TextBlock 为 Current / Completed 时显示Locked / Forbidden 时隐藏。未配置标记时,现有章节行为不变。")]
public List<StoryTimelineMarkerDefinition> timelineMarkers = new List<StoryTimelineMarkerDefinition>();
// ── Queries ─────────────────────────────────────────────────────────────
/// <summary>
@@ -70,24 +80,30 @@ namespace Ichni.Story
private const float PreviewViewportHeight = 380f;
private const float PreviewPaddingLeft = 24f;
private const float PreviewTangent = 44f;
// 详细版:较大方块,标注 id / 类型 / 标题
private const float DetailCellWidth = 150f;
private const float DetailCellHeight = 72f;
private const float DetailStepX = 210f;
private const float DetailStepY = 120f;
// 预览与运行时共用同一套“列 / 行代表 Block 中心点”的布局语义。
// 这里的尺寸直接对应当前四种 Block Prefab 的设计尺寸;若未来修改 Prefab 尺寸,必须同步更新本组常量。
private const float ImportantBlockWidth = 600f;
private const float ImportantBlockHeight = 375f;
private const float SecondaryBlockWidth = 400f;
private const float SecondaryBlockHeight = 200f;
private const float SongBlockWidth = 400f;
private const float SongBlockHeight = 100f;
private const float TutorialBlockWidth = 400f;
private const float TutorialBlockHeight = 100f;
// 简略版:很小的方块,仅标注 Block ID
private const float SimpleCellWidth = 62f;
private const float SimpleCellHeight = 26f;
private const float SimpleStepX = 92f;
private const float SimpleStepY = 42f;
// 与 StoryTreeController 默认 columnStep / rowStep 保持比例一致,
// 因而预览中的相邻列、行间距也能反映运行时的实际布局关系。
private const float RuntimeColumnStep = 720f;
private const float RuntimeRowStep = 525f;
private const float DetailedPreviewScale = 0.25f;
private const float SimplePreviewScale = 0.10f;
/// <summary>
/// 在 Inspector 顶部绘制只读的故事树结构预览(流程图样式,连线左右相连)。
/// 提供"详细 / 简略"两种可拖拽平移的视图;按 (gridColumn, gridRow) 摆放 block
/// 支持负数与小数坐标,(0,0) 对应左侧中部。
/// 提供"详细 / 简略"两种可拖拽平移的视图;按 (gridColumn, gridRow) 摆放 block
/// 每个 Block 使用其运行时 Prefab 尺寸按统一比例缩放后的矩形,且列坐标始终代表 Block 的中心点,
/// 从而让 Important / Secondary / Song / Tutorial 的体积与连线端点关系尽量接近游戏内画面。
/// </summary>
[PropertyOrder(-10)]
[OnInspectorGUI]
@@ -110,10 +126,9 @@ namespace Ichni.Story
EditorGUILayout.EndHorizontal();
bool simple = _previewMode == StoryPreviewMode.Simple;
float cellW = simple ? SimpleCellWidth : DetailCellWidth;
float cellH = simple ? SimpleCellHeight : DetailCellHeight;
float stepX = simple ? SimpleStepX : DetailStepX;
float stepY = simple ? SimpleStepY : DetailStepY;
float previewScale = simple ? SimplePreviewScale : DetailedPreviewScale;
float stepX = RuntimeColumnStep * previewScale;
float stepY = RuntimeRowStep * previewScale;
Rect area = GUILayoutUtility.GetRect(0f, PreviewViewportHeight, GUILayout.ExpandWidth(true));
EditorGUI.DrawRect(area, new Color(0.12f, 0.12f, 0.14f, 1f));
@@ -123,26 +138,34 @@ namespace Ichni.Story
// 裁剪到预览区,超出部分(平移后)不外溢到其它 Inspector 元素
GUI.BeginClip(area);
{
// (0,0) 网格点 = 视口左侧中线(叠加平移量)
float originX = PreviewPaddingLeft + _previewPan.x;
// (0,0) 是 Block 的逻辑中心点。为避免第一列 Important Block 被左侧裁掉,
// 初始视图预留半个最大 Block 宽度;这不改变 StoryData 的坐标语义。
float originX = PreviewPaddingLeft + ImportantBlockWidth * previewScale * 0.5f + _previewPan.x;
float originY = area.height * 0.5f + _previewPan.y;
// 计算各 block 矩形(左中对齐网格点,与运行时 pivot(0,0.5) 一致),按 blockId 索引
// 计算各 Block 的实际比例矩形。运行时同样以中心 Pivot 放置,因此 gridColumn / gridRow
// 在预览中也对应矩形中心,而不是左边缘。
Dictionary<string, Rect> cellRects = new Dictionary<string, Rect>();
foreach (StoryBlockDefinition block in blocks)
{
if (block == null || string.IsNullOrEmpty(block.blockId)) continue;
if (!HasPreviewableBlockId(block)) continue;
float px = originX + block.gridColumn * stepX; // 列向右为正(可负、可小数)
float py = originY + block.gridRow * stepY; // 行向下为正、向上为负
cellRects[block.blockId] = new Rect(px, py - cellH * 0.5f, cellW, cellH);
Vector2 blockSize = GetPreviewBlockSize(block, previewScale);
float px = originX + block.gridColumn * stepX; // 列向右为正(可负、可小数)
float py = originY + block.gridRow * stepY; // 行向下为正、向上为负
cellRects[block.blockId] = new Rect(
px - blockSize.x * 0.5f,
py - blockSize.y * 0.5f,
blockSize.x,
blockSize.y);
}
// 先画连线:左右相连的流程图(起点右中 → 终点左中,水平切线)
// 先画连线:与 BlockConnectorView 相同,起点右中 → 以两个 Block 中心的中点列折线 → 终点左中。
// 不使用端口中点作为转折列,避免不同宽度 Block 在同一列时出现不一致的转折位置。
Handles.BeginGUI();
foreach (StoryBlockDefinition block in blocks)
{
if (block == null || !cellRects.TryGetValue(block.blockId, out Rect fromRect)) continue;
if (!HasPreviewableBlockId(block) || !cellRects.TryGetValue(block.blockId, out Rect fromRect)) continue;
foreach (string nextId in block.nextBlockIds)
{
@@ -150,11 +173,12 @@ namespace Ichni.Story
Vector3 start = new Vector3(fromRect.xMax, fromRect.center.y, 0f);
Vector3 end = new Vector3(toRect.xMin, toRect.center.y, 0f);
Vector3 startTan = start + Vector3.right * PreviewTangent;
Vector3 endTan = end + Vector3.left * PreviewTangent;
float midX = (fromRect.center.x + toRect.center.x) * 0.5f;
Vector3 mid1 = new Vector3(midX, start.y, 0f);
Vector3 mid2 = new Vector3(midX, end.y, 0f);
Handles.DrawBezier(start, end, startTan, endTan,
new Color(0.82f, 0.82f, 0.88f, 1f), null, simple ? 2f : 3f);
Handles.color = new Color(0.82f, 0.82f, 0.88f, 1f);
Handles.DrawAAPolyLine(simple ? 2f : 3f, start, mid1, mid2, end);
}
}
Handles.EndGUI();
@@ -171,22 +195,58 @@ namespace Ichni.Story
foreach (StoryBlockDefinition block in blocks)
{
if (block == null || !cellRects.TryGetValue(block.blockId, out Rect rect)) continue;
if (!HasPreviewableBlockId(block) || !cellRects.TryGetValue(block.blockId, out Rect rect)) continue;
EditorGUI.DrawRect(rect, GetTypeColor(block.blockType));
string label = simple
? block.blockId
: $"{block.blockId}\n[{block.blockType}]\n{block.GetDisplayTitle()}";
string label = GetPreviewLabel(block, simple, rect.height);
GUI.Label(rect, label, labelStyle);
}
}
GUI.EndClip();
EditorGUILayout.HelpBox(
"在预览区内拖拽可平移视图。列 = 水平(右为正),行 = 垂直(下为正、上为负)(0,0) 位于左侧中部,支持负数与小数。",
"在预览区内拖拽可平移视图。列 = 水平(右为正),行 = 垂直(下为正、上为负);坐标代表 Block 中心点。预览按当前 Prefab 尺寸比例绘制Important 600×375、Secondary 400×200、Song / Tutorial 400×100。",
MessageType.None);
}
/// <summary>
/// 返回一个 Block 在 Inspector 预览中的尺寸。所有类型使用同一缩放比例,
/// 因而宽高比及不同 Block 之间的体积关系与运行时 Prefab 保持一致。
/// </summary>
private static Vector2 GetPreviewBlockSize(StoryBlockDefinition block, float previewScale)
{
Vector2 runtimeSize = block.blockType switch
{
StoryBlockType.Text when block.textImportance == TextBlockImportance.Important =>
new Vector2(ImportantBlockWidth, ImportantBlockHeight),
StoryBlockType.Text => new Vector2(SecondaryBlockWidth, SecondaryBlockHeight),
StoryBlockType.Song => new Vector2(SongBlockWidth, SongBlockHeight),
StoryBlockType.Tutorial => new Vector2(TutorialBlockWidth, TutorialBlockHeight),
_ => Vector2.zero
};
return runtimeSize * previewScale;
}
/// <summary>
/// 根据可用高度选择预览标签密度。Song / Tutorial 的真实比例高度较小,
/// 因此只显示 Block ID避免文字撑破矩形而掩盖实际体积关系。
/// </summary>
private static string GetPreviewLabel(StoryBlockDefinition block, bool simple, float blockHeight)
{
if (simple || blockHeight < 32f)
{
return block.blockId;
}
if (blockHeight < 60f)
{
return $"{block.blockId}\n[{block.blockType}]";
}
return $"{block.blockId}\n[{block.blockType}]\n{block.GetDisplayTitle()}";
}
/// <summary>
/// 处理预览区内的鼠标拖拽平移。
/// </summary>
@@ -220,6 +280,17 @@ namespace Ichni.Story
}
}
/// <summary>
/// 判断 Block 能否参与 Inspector 中的故事树预览。
/// 新增 Block 时Odin 会先创建一个尚未填写 blockId 的临时数据项;它还不能作为
/// Dictionary 的 Key也不应在预览中显示或连接。统一在三个预览阶段过滤避免后续
/// Draw / TryGetValue 阶段再次把空引用传入 Dictionary。
/// </summary>
private static bool HasPreviewableBlockId(StoryBlockDefinition block)
{
return block != null && !string.IsNullOrWhiteSpace(block.blockId);
}
/// <summary>
/// 预览中不同 block 类型的填充色。
/// </summary>

View File

@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using Sirenix.OdinInspector;
using UnityEngine;
namespace Ichni.Story
{
/// <summary>
/// 全局 Helper 的独立数据资产。
/// <para>该资产不隶属于任何 StoryData由 <see cref="StoryManager.helperData"/> 统一引用,
/// 因而所有章节都共用同一个 Helper 角色、名称和候选对话池。</para>
/// <para>运行时会在满足条件的候选中按权重抽取,并在候选数量允许时避免与上次对话重复;
/// 具体抽取和 UI 展示逻辑将在 Helper 功能实现阶段接入。</para>
/// </summary>
[CreateAssetMenu(fileName = "StoryHelperData", menuName = "Ichni/Story/New/Story Helper Data")]
public class StoryHelperData : SerializedScriptableObject
{
/// <summary>
/// Helper 显示名称的 Unity Localization Entry Key。
/// </summary>
[LabelText("Display Name Key (Localization)")]
public string displayNameKey;
/// <summary>
/// 当所有候选对话均不满足条件时显示的兜底对话 Entry Key。
/// </summary>
[LabelText("Fallback Dialogue Key (Localization)")]
public string fallbackDialogueKey;
/// <summary>
/// 全章节共用的候选对话。条件留空表示始终可参与抽取。
/// </summary>
[LabelText("Dialogue Pool")]
[ListDrawerSettings(ShowFoldout = true, DefaultExpandedState = false, DraggableItems = true, ShowItemCount = true)]
public List<StoryHelperDialogueDefinition> dialoguePool = new List<StoryHelperDialogueDefinition>();
}
/// <summary>
/// Helper 对话池中的一条候选记录。
/// <para>条件复用故事树的 <see cref="StoryCondition"/>,因此未来可直接根据章节剧情变量、
/// 路线变量或指定 Block 的完成状态筛选,而不需要为 Helper 另写一套条件语法。</para>
/// </summary>
[Serializable]
[InlineProperty]
public class StoryHelperDialogueDefinition
{
/// <summary>
/// 候选对话的稳定 ID仅用于调试、去重和未来数据追踪。
/// 命名使用小写英文、数字和下划线,例如 <c>general_greeting_01</c>。
/// </summary>
[LabelText("Dialogue ID")]
public string dialogueId;
/// <summary>
/// 实际显示内容的 Unity Localization Entry Key。
/// </summary>
[LabelText("Dialogue Key (Localization)")]
public string dialogueKey;
/// <summary>
/// 此对话可被抽取的条件;留空表示始终可用。
/// </summary>
[LabelText("Availability Condition")]
public StoryCondition availabilityCondition = new StoryCondition();
/// <summary>
/// 在所有满足条件的候选中的相对抽取权重。值越大,出现概率越高;
/// 小于 1 的值在运行时应按 1 处理,避免无效的零或负权重。
/// </summary>
[LabelText("Weight")]
[MinValue(1)]
public int weight = 1;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 24bdc25737924012ab3c078aa043fcad

View File

@@ -0,0 +1,52 @@
using System;
using Sirenix.OdinInspector;
using UnityEngine;
namespace Ichni.Story
{
/// <summary>
/// 一个章节 Timeline 标记的静态定义。
/// <para>它不保存独立的横坐标:运行时始终跟随锚定 Block 的 <c>gridColumn</c>
/// 使时间轴与剧情树在横向拖动时保持对齐。</para>
/// <para>显示文本和 ID 使用 Unity Localization 的 Entry Key本阶段不绑定具体 String Table
/// 以便后续统一迁移本地化系统时集中配置。</para>
/// </summary>
[Serializable]
[InlineProperty]
public class StoryTimelineMarkerDefinition
{
/// <summary>
/// 标记的稳定唯一 ID仅用于配置、调试和未来持久化引用。
/// 命名使用小写英文、数字和下划线,例如 <c>chapter0_day_01</c>。
/// </summary>
[LabelText("Marker ID")]
public string markerId;
/// <summary>
/// 被此标记锚定的 TextBlock ID。该 Block 的 gridColumn 是标记的唯一位置来源;
/// 当它处于 Current 或 Completed 时标记显示Locked 或 Forbidden 时,标记隐藏。
/// </summary>
[LabelText("Anchor Block ID")]
public string anchorBlockId;
/// <summary>
/// Timeline 上显示的本地化文本 Key。通常用于时间描述也可用于特殊叙事文本。
/// </summary>
[LabelText("Label Key (Localization)")]
public string labelKey;
/// <summary>
/// 是否参与章节主线进度百分比计算。支线或纯装饰性时间标记应关闭此项。
/// </summary>
[LabelText("Contributes To Main Progress")]
public bool contributesToMainProgress = true;
/// <summary>
/// 锚定 Block 完成后可贡献的主线进度,范围为 0 到 1。
/// 同一章节的不同结局都应配置为 1运行时取所有有效完成标记中的最大值。
/// </summary>
[LabelText("Progress Value")]
[Range(0f, 1f)]
public float progressValue;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 02587e83d8754ac5828e51066dce570f

View File

@@ -24,6 +24,20 @@ namespace Ichni.Story
return true;
}
/// <summary>
/// 写入整型剧情变量、保存到 StorySaveModule并立即重新推导当前故事树。
/// 路线选项可通过本方法写入 Forbidden Condition 所读取的变量,确保玩家确认选择后,
/// 同一帧内即可把其它路线的 Block 更新为 Forbidden。
/// </summary>
public static bool SetIntAndRefresh(string variableName, int value)
{
if (!TrySetIntAndSave(variableName, value))
return false;
StoryManager.instance?.treeController?.RefreshAllStates();
return true;
}
/// <summary>
/// 处理一次 TutorialBlock 的选择结果。
/// 无论选择“游玩”还是“跳过”,都会将其进度变量设为 true并将教程节点标记为完成。
@@ -112,5 +126,28 @@ namespace Ichni.Story
GameSaveManager.instance.StorySaveModule.SaveVariables();
return true;
}
/// <summary>
/// 整型变量的保存实现。Yarn 底层会以 float 字典持久化数字,具体存储细节由
/// StoryVariables.SetInt 统一处理,调用方不应直接操作 StorySaveModule.variables。
/// </summary>
private static bool TrySetIntAndSave(string variableName, int value)
{
if (string.IsNullOrWhiteSpace(variableName))
{
Debug.LogWarning("[StoryProgress] 剧情变量名为空,拒绝写入。");
return false;
}
if (GameSaveManager.instance == null || GameSaveManager.instance.StorySaveModule == null)
{
Debug.LogWarning("[StoryProgress] StorySaveModule 未就绪,拒绝写入剧情变量。");
return false;
}
StoryVariables.SetInt(variableName, value);
GameSaveManager.instance.StorySaveModule.SaveVariables();
return true;
}
}
}

View File

@@ -7,7 +7,7 @@ namespace Ichni.Story
/// 单章节的存档容器。
/// <para>故事树的布局与连接完全由 <see cref="StoryData"/> 静态定义,故存档不再保存
/// block 位置与连接线;进入章节时按"已完成集合 + 解锁条件"实时推导每个 block 的
/// <see cref="StoryBlockState"/>Completed / Current / Locked。</para>
/// <see cref="StoryBlockState"/>Completed / Current / Locked / Forbidden)。</para>
/// <para>以单个 ES3 key 存储,按章节分文件,一次读写即可加载/保存整章进度。</para>
/// </summary>
[Serializable]

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 424a277e60b372a4082a7b4cd9a32202
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

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;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 84d93bde6e074965b7ca259465f2a713

View File

@@ -0,0 +1,46 @@
using TMPro;
using UnityEngine;
namespace Ichni.Story.UI
{
/// <summary>
/// StoryTimeline 中单个时间标记的纯视图组件。
/// 它不读取存档、不判断剧情状态,也不接收点击;可见性、位置与本地化文本均由
/// <see cref="StoryTimelineController"/> 统一驱动,从而避免 Timeline 再维护一份剧情进度。
/// </summary>
public class StoryTimelineMarkerView : MonoBehaviour
{
[Tooltip("Marker Prefab 的根 RectTransform。建议锚点为 Middle Center以便 anchoredPosition.x 直接表示最终屏幕位置。")]
public RectTransform markerRect;
[Tooltip("显示时间或特殊叙事文本的 TMP 文本。")]
public TMP_Text labelText;
/// <summary>
/// 设置 Marker 的本地化完成文本。文本解析由控制器完成View 只负责呈现。
/// </summary>
public void SetLabel(string label)
{
labelText.text = label;
}
/// <summary>
/// 只更新横向坐标,保留 Prefab 中由美术设定的纵向位置、缩放和旋转。
/// </summary>
public void SetPositionX(float x)
{
Vector2 position = markerRect.anchoredPosition;
position.x = x;
markerRect.anchoredPosition = position;
}
/// <summary>
/// 显示或隐藏 Marker。Timeline 不可交互,因此隐藏后不会留下射线或导航焦点。
/// 后续若增加 DOTween 入场动画,可在此处保留当前的最终状态并由控制器显式调用播放函数。
/// </summary>
public void SetVisible(bool visible)
{
gameObject.SetActive(visible);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5fa49b969e984456985eec86f0275d7d

View File

@@ -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>

View File

@@ -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}')。占位实现,曲目选择将在后续阶段接入。");
}

View File

@@ -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);
// 不在基类覆盖 sizeDeltaBlock 的尺寸是 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)

View File

@@ -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>();

View File

@@ -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 的 contentlocalScale 必须为 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、CurrentLocked。
/// 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 采用左中 pivotanchoredPosition.x 为左边缘y 相对垂直中线(上正下负)。
/// 根据所有 Block 的真实矩形边界归一化横向位置并设置 Content(BlockContainer) 尺寸。
/// <para>Block 采用中心 PivotanchoredPosition 表示 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;
}

View File

@@ -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();