剧情+对话完善
This commit is contained in:
@@ -2,10 +2,6 @@ using System.Collections.Generic;
|
||||
using Sirenix.OdinInspector;
|
||||
using UnityEngine;
|
||||
using Yarn.Unity;
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
using Sirenix.Utilities.Editor;
|
||||
#endif
|
||||
|
||||
namespace Ichni.Story
|
||||
{
|
||||
@@ -14,17 +10,30 @@ namespace Ichni.Story
|
||||
/// 布局完全由每个 block 的 (gridColumn, gridRow) 静态决定,运行时全量展示。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(fileName = "StoryData", menuName = "Ichni/Story/New/StoryData")]
|
||||
public class StoryData : SerializedScriptableObject
|
||||
public partial class StoryData : SerializedScriptableObject
|
||||
{
|
||||
[TitleGroup("Chapter Settings", Alignment = TitleAlignments.Centered)]
|
||||
[LabelText("Chapter Index")]
|
||||
[Tooltip("章节的稳定索引。StorySave、StoryManager 与 ChapterSelection 都以该值定位当前章节。")]
|
||||
public string chapterIndex;
|
||||
|
||||
[TitleGroup("Chapter Settings")]
|
||||
[LabelText("Yarn Project")]
|
||||
[InfoBox("该章节对应的 YarnProject 编译资产,阶段 2 接线。")]
|
||||
[Tooltip("该章节对应的 YarnProject 编译资产。TextBlock 会在运行时从此项目中执行配置的 Yarn Node。")]
|
||||
public YarnProject yarnProject;
|
||||
|
||||
/// <summary>
|
||||
/// 本章节永久剧情变量的默认值。它们只属于本章节,且不会直接写入存档;
|
||||
/// 玩家第一次通过 Yarn 或其它剧情入口写入同名变量后,存档值才会覆盖这里的默认值。
|
||||
/// </summary>
|
||||
[LabelText("Initial Variables")]
|
||||
[ListDrawerSettings(ShowFoldout = true, DefaultExpandedState = false, DraggableItems = true, ShowItemCount = true)]
|
||||
[Tooltip("变量 Key 使用小写英文、数字和下划线。Yarn 请通过 <<set_*>> 与 get_*() 访问,不使用 <<declare>>、<<set>> 或 $variable。")]
|
||||
public List<StoryVariableDefinition> initialVariables = new List<StoryVariableDefinition>();
|
||||
|
||||
[LabelText("Blocks")]
|
||||
[ListDrawerSettings(ShowFoldout = true, DefaultExpandedState = false, DraggableItems = true, ShowItemCount = true)]
|
||||
[Tooltip("章节的完整剧情树定义。列表排序不决定运行时流程;连接关系、条件与网格坐标才是唯一依据。")]
|
||||
public List<StoryBlockDefinition> blocks = new List<StoryBlockDefinition>();
|
||||
|
||||
/// <summary>
|
||||
@@ -34,9 +43,18 @@ namespace Ichni.Story
|
||||
/// </summary>
|
||||
[LabelText("Timeline Markers")]
|
||||
[ListDrawerSettings(ShowFoldout = true, DefaultExpandedState = false, DraggableItems = true, ShowItemCount = true)]
|
||||
[InfoBox("每个标记锚定到一个 TextBlock;运行时使用该 Block 的 Grid Column 对齐 Timeline。TextBlock 为 Current / Completed 时显示,Locked / Forbidden 时隐藏。未配置标记时,现有章节行为不变。")]
|
||||
[Tooltip("每个标记锚定到一个 TextBlock;运行时使用该 Block 的 Grid Column 对齐 Timeline。TextBlock 为 Current / Completed 时显示,Locked / Forbidden 时隐藏。")]
|
||||
public List<StoryTimelineMarkerDefinition> timelineMarkers = new List<StoryTimelineMarkerDefinition>();
|
||||
|
||||
/// <summary>
|
||||
/// 仅供编辑器自检使用的互斥结局分组,不参与运行时的解锁、禁用或存档逻辑。
|
||||
/// 实际路线禁用仍完全通过各 Block 的 Forbidden Condition 表达,以保留剧情变量组合的灵活性。
|
||||
/// </summary>
|
||||
[LabelText("Exclusive Route Groups")]
|
||||
[ListDrawerSettings(ShowFoldout = true, DefaultExpandedState = false, DraggableItems = true, ShowItemCount = true)]
|
||||
[Tooltip("把同一章节中只能同时达成一个的结局 Block 放入同一组。Validate Story Data 会检查引用、终点形态与 Forbidden Condition 配置;不会改变运行时逻辑。")]
|
||||
public List<StoryRouteValidationGroup> exclusiveRouteGroups = new List<StoryRouteValidationGroup>();
|
||||
|
||||
// ── Queries ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
@@ -46,13 +64,35 @@ namespace Ichni.Story
|
||||
{
|
||||
foreach (StoryBlockDefinition block in blocks)
|
||||
{
|
||||
if (block.blockId == blockId)
|
||||
if (block != null && block.blockId == blockId)
|
||||
return block;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 尝试获取一个章节变量的静态默认定义。重复 Key 取列表中第一项,
|
||||
/// 因此配置时应保持 Key 唯一,以免在 Inspector 中产生歧义。
|
||||
/// </summary>
|
||||
public bool TryGetInitialVariable(string key, out StoryVariableDefinition definition)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(key) && initialVariables != null)
|
||||
{
|
||||
foreach (StoryVariableDefinition item in initialVariables)
|
||||
{
|
||||
if (item != null && item.key == key)
|
||||
{
|
||||
definition = item;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
definition = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定 blockId 的所有直接后继 block 定义。
|
||||
/// </summary>
|
||||
@@ -61,6 +101,9 @@ namespace Ichni.Story
|
||||
StoryBlockDefinition source = GetBlock(blockId);
|
||||
if (source == null) yield break;
|
||||
|
||||
if (source.nextBlockIds == null)
|
||||
yield break;
|
||||
|
||||
foreach (string nextId in source.nextBlockIds)
|
||||
{
|
||||
StoryBlockDefinition next = GetBlock(nextId);
|
||||
@@ -69,241 +112,5 @@ namespace Ichni.Story
|
||||
}
|
||||
}
|
||||
|
||||
// ── Editor Preview ────────────────────────────────────────────────────────
|
||||
#if UNITY_EDITOR
|
||||
private enum StoryPreviewMode { Detailed, Simple }
|
||||
|
||||
// 编辑器 UI 临时状态(不参与序列化)
|
||||
[System.NonSerialized] private StoryPreviewMode _previewMode = StoryPreviewMode.Detailed;
|
||||
[System.NonSerialized] private Vector2 _previewPan;
|
||||
[System.NonSerialized] private bool _isPanningPreview;
|
||||
|
||||
private const float PreviewViewportHeight = 380f;
|
||||
private const float PreviewPaddingLeft = 24f;
|
||||
|
||||
// 预览与运行时共用同一套“列 / 行代表 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;
|
||||
|
||||
// 与 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。
|
||||
/// 每个 Block 使用其运行时 Prefab 尺寸按统一比例缩放后的矩形,且列坐标始终代表 Block 的中心点,
|
||||
/// 从而让 Important / Secondary / Song / Tutorial 的体积与连线端点关系尽量接近游戏内画面。
|
||||
/// </summary>
|
||||
[PropertyOrder(-10)]
|
||||
[OnInspectorGUI]
|
||||
private void DrawStoryPreview()
|
||||
{
|
||||
SirenixEditorGUI.Title("Story Tree Preview", null, TextAlignment.Left, true);
|
||||
|
||||
if (blocks == null || blocks.Count == 0)
|
||||
{
|
||||
EditorGUILayout.HelpBox("尚未定义任何 block。", MessageType.Info);
|
||||
return;
|
||||
}
|
||||
|
||||
// 顶部工具条:模式切换 + 复位视图
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
_previewMode = (StoryPreviewMode)GUILayout.Toolbar(
|
||||
(int)_previewMode, new[] { "Detailed", "Simple" }, GUILayout.Height(20f));
|
||||
if (GUILayout.Button("Reset View", GUILayout.Width(90f), GUILayout.Height(20f)))
|
||||
_previewPan = Vector2.zero;
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
bool simple = _previewMode == StoryPreviewMode.Simple;
|
||||
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));
|
||||
|
||||
HandlePreviewPanning(area);
|
||||
|
||||
// 裁剪到预览区,超出部分(平移后)不外溢到其它 Inspector 元素
|
||||
GUI.BeginClip(area);
|
||||
{
|
||||
// (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 放置,因此 gridColumn / gridRow
|
||||
// 在预览中也对应矩形中心,而不是左边缘。
|
||||
Dictionary<string, Rect> cellRects = new Dictionary<string, Rect>();
|
||||
foreach (StoryBlockDefinition block in blocks)
|
||||
{
|
||||
if (!HasPreviewableBlockId(block)) continue;
|
||||
|
||||
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 (!HasPreviewableBlockId(block) || !cellRects.TryGetValue(block.blockId, out Rect fromRect)) continue;
|
||||
|
||||
foreach (string nextId in block.nextBlockIds)
|
||||
{
|
||||
if (string.IsNullOrEmpty(nextId) || !cellRects.TryGetValue(nextId, out Rect toRect)) continue;
|
||||
|
||||
Vector3 start = new Vector3(fromRect.xMax, fromRect.center.y, 0f);
|
||||
Vector3 end = new Vector3(toRect.xMin, toRect.center.y, 0f);
|
||||
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.color = new Color(0.82f, 0.82f, 0.88f, 1f);
|
||||
Handles.DrawAAPolyLine(simple ? 2f : 3f, start, mid1, mid2, end);
|
||||
}
|
||||
}
|
||||
Handles.EndGUI();
|
||||
|
||||
// 再画 block 方块
|
||||
GUIStyle labelStyle = new GUIStyle(EditorStyles.boldLabel)
|
||||
{
|
||||
alignment = TextAnchor.MiddleCenter,
|
||||
wordWrap = true,
|
||||
richText = false,
|
||||
fontSize = simple ? 9 : 11,
|
||||
normal = { textColor = Color.white }
|
||||
};
|
||||
|
||||
foreach (StoryBlockDefinition block in blocks)
|
||||
{
|
||||
if (!HasPreviewableBlockId(block) || !cellRects.TryGetValue(block.blockId, out Rect rect)) continue;
|
||||
|
||||
EditorGUI.DrawRect(rect, GetTypeColor(block.blockType));
|
||||
string label = GetPreviewLabel(block, simple, rect.height);
|
||||
GUI.Label(rect, label, labelStyle);
|
||||
}
|
||||
}
|
||||
GUI.EndClip();
|
||||
|
||||
EditorGUILayout.HelpBox(
|
||||
"在预览区内拖拽可平移视图。列 = 水平(右为正),行 = 垂直(下为正、上为负);坐标代表 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>
|
||||
private void HandlePreviewPanning(Rect area)
|
||||
{
|
||||
Event e = Event.current;
|
||||
switch (e.type)
|
||||
{
|
||||
case EventType.MouseDown:
|
||||
if (area.Contains(e.mousePosition))
|
||||
{
|
||||
_isPanningPreview = true;
|
||||
e.Use();
|
||||
}
|
||||
break;
|
||||
case EventType.MouseDrag:
|
||||
if (_isPanningPreview)
|
||||
{
|
||||
_previewPan += e.delta;
|
||||
GUI.changed = true;
|
||||
e.Use();
|
||||
}
|
||||
break;
|
||||
case EventType.MouseUp:
|
||||
if (_isPanningPreview)
|
||||
{
|
||||
_isPanningPreview = false;
|
||||
e.Use();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>
|
||||
private static Color GetTypeColor(StoryBlockType type)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
StoryBlockType.Text => new Color(0.20f, 0.42f, 0.72f, 1f), // 蓝
|
||||
StoryBlockType.Song => new Color(0.62f, 0.30f, 0.66f, 1f), // 紫
|
||||
StoryBlockType.Tutorial => new Color(0.28f, 0.56f, 0.36f, 1f), // 绿
|
||||
_ => new Color(0.4f, 0.4f, 0.4f, 1f)
|
||||
};
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user