Storyline+Dialog初步
This commit is contained in:
52
Assets/Scripts/NewStorySystem/Data/CharacterData.cs
Normal file
52
Assets/Scripts/NewStorySystem/Data/CharacterData.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using System.Collections.Generic;
|
||||
using Sirenix.OdinInspector;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ichni.Story
|
||||
{
|
||||
/// <summary>
|
||||
/// 立绘渲染方式。当前使用 Sprite,预留 Live2D / Spine 扩展入口。
|
||||
/// </summary>
|
||||
public enum PortraitStyle
|
||||
{
|
||||
Sprite,
|
||||
Live2D,
|
||||
Spine
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 单个角色的立绘数据(按角色单独创建 ScriptableObject 资产)。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(fileName = "CharacterData", menuName = "Ichni/Story/New/CharacterData")]
|
||||
public class CharacterData : SerializedScriptableObject
|
||||
{
|
||||
[LabelText("Character ID")]
|
||||
[InfoBox("此 ID 需与 .yarn 文件中对应台词的 speaker 名完全一致。")]
|
||||
public string characterId;
|
||||
|
||||
[LabelText("Display Name Key")]
|
||||
[InfoBox("Unity Localization String Table Key,用于多语言角色名显示。")]
|
||||
public string displayNameKey;
|
||||
|
||||
[LabelText("Portrait Style")]
|
||||
public PortraitStyle portraitStyle = PortraitStyle.Sprite;
|
||||
|
||||
// ── Sprite ──────────────────────────────────────────────────────────────
|
||||
|
||||
[LabelText("Emotion Sprites")]
|
||||
[ShowIf("portraitStyle", PortraitStyle.Sprite)]
|
||||
[DictionaryDrawerSettings(KeyLabel = "Emotion Name", ValueLabel = "Sprite")]
|
||||
[InfoBox("Key 为情绪名(如 normal / happy / angry),Value 为对应 Sprite 资产。")]
|
||||
public Dictionary<string, Sprite> emotionSprites = new Dictionary<string, Sprite>();
|
||||
|
||||
// ── Dynamic (Live2D / Spine) ─────────────────────────────────────────────
|
||||
|
||||
[LabelText("Dynamic Portrait Prefab")]
|
||||
[ShowIf("IsDynamicPortrait")]
|
||||
[InfoBox("Live2D / Spine 立绘预制体,需挂载实现 IPortraitRenderer 接口的组件(阶段 3 制作)。")]
|
||||
public GameObject dynamicPortraitPrefab;
|
||||
|
||||
private bool IsDynamicPortrait =>
|
||||
portraitStyle == PortraitStyle.Live2D || portraitStyle == PortraitStyle.Spine;
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/NewStorySystem/Data/CharacterData.cs.meta
Normal file
2
Assets/Scripts/NewStorySystem/Data/CharacterData.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 87a7c342a93341841bf78d2ba004879a
|
||||
61
Assets/Scripts/NewStorySystem/Data/CharacterRegistry.cs
Normal file
61
Assets/Scripts/NewStorySystem/Data/CharacterRegistry.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using System.Collections.Generic;
|
||||
using Sirenix.OdinInspector;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ichni.Story
|
||||
{
|
||||
/// <summary>
|
||||
/// 全局角色注册表(ScriptableObject)。
|
||||
/// 按 characterId 索引所有 <see cref="CharacterData"/>,
|
||||
/// 运行时提供带缓存的快速查找。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(fileName = "CharacterRegistry", menuName = "Ichni/Story/New/CharacterRegistry")]
|
||||
public class CharacterRegistry : SerializedScriptableObject
|
||||
{
|
||||
[LabelText("Characters")]
|
||||
[ListDrawerSettings(ShowFoldout = true, DraggableItems = true)]
|
||||
[InfoBox("将所有角色的 CharacterData 资产拖入此列表,系统运行时会自动按 characterId 建立索引。")]
|
||||
public List<CharacterData> characters = new List<CharacterData>();
|
||||
|
||||
// 运行时缓存,首次 TryGet 时延迟构建
|
||||
private Dictionary<string, CharacterData> _cache;
|
||||
|
||||
/// <summary>
|
||||
/// 根据 characterId 查找角色数据,找到时返回 true 并通过 out 输出结果。
|
||||
/// </summary>
|
||||
public bool TryGet(string characterId, out CharacterData data)
|
||||
{
|
||||
if (_cache == null)
|
||||
BuildCache();
|
||||
|
||||
return _cache.TryGetValue(characterId, out data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据 characterId 获取角色数据,未找到时返回 null。
|
||||
/// </summary>
|
||||
public CharacterData Get(string characterId)
|
||||
{
|
||||
TryGet(characterId, out CharacterData data);
|
||||
return data;
|
||||
}
|
||||
|
||||
private void BuildCache()
|
||||
{
|
||||
_cache = new Dictionary<string, CharacterData>(characters.Count);
|
||||
|
||||
foreach (CharacterData character in characters)
|
||||
{
|
||||
if (character == null) continue;
|
||||
if (!string.IsNullOrEmpty(character.characterId))
|
||||
_cache[character.characterId] = character;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnValidate()
|
||||
{
|
||||
// Inspector 中修改列表时清除缓存,确保 TryGet 结果始终一致
|
||||
_cache = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e3ea2ce926155ad4285efbc15a0a82b6
|
||||
140
Assets/Scripts/NewStorySystem/Data/StoryBlockDefinition.cs
Normal file
140
Assets/Scripts/NewStorySystem/Data/StoryBlockDefinition.cs
Normal file
@@ -0,0 +1,140 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Sirenix.OdinInspector;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ichni.Story
|
||||
{
|
||||
/// <summary>
|
||||
/// Block 在故事树中的状态:已锁定、当前可交互、已完成。
|
||||
/// </summary>
|
||||
public enum StoryBlockState
|
||||
{
|
||||
Locked,
|
||||
Current,
|
||||
Completed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Block 的类型,决定点击后触发的行为。
|
||||
/// </summary>
|
||||
public enum StoryBlockType
|
||||
{
|
||||
Text,
|
||||
Song,
|
||||
Tutorial
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 变量条件的比较方式。
|
||||
/// </summary>
|
||||
public enum VariableComparison
|
||||
{
|
||||
Equal,
|
||||
NotEqual,
|
||||
Greater,
|
||||
GreaterOrEqual,
|
||||
Less,
|
||||
LessOrEqual
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 故事树中单个 block 的完整数据定义,由 <see cref="StoryData"/> 持有。
|
||||
/// </summary>
|
||||
[InlineProperty]
|
||||
[Serializable]
|
||||
public class StoryBlockDefinition
|
||||
{
|
||||
[FoldoutGroup("$blockId", false)]
|
||||
[LabelText("Block ID")]
|
||||
public string blockId;
|
||||
|
||||
[FoldoutGroup("$blockId")]
|
||||
[LabelText("Type")]
|
||||
public StoryBlockType blockType;
|
||||
|
||||
[FoldoutGroup("$blockId")]
|
||||
[LabelText("Grid Column")]
|
||||
[Tooltip("故事树网格中的列:向右为正,(0,0) 位于容器左侧中部。可为负数与小数(用于微调)。")]
|
||||
public float gridColumn;
|
||||
|
||||
[FoldoutGroup("$blockId")]
|
||||
[LabelText("Grid Row")]
|
||||
[Tooltip("故事树网格中的行:向下为正、向上为负,0 对应垂直中线。可为小数(用于微调)。")]
|
||||
public float gridRow;
|
||||
|
||||
[FoldoutGroup("$blockId")]
|
||||
[LabelText("Next Block IDs")]
|
||||
public List<string> nextBlockIds = new List<string>();
|
||||
|
||||
[FoldoutGroup("$blockId")]
|
||||
[LabelText("Unlock Condition")]
|
||||
public UnlockCondition unlockCondition = new UnlockCondition();
|
||||
|
||||
// ── Text Block ──────────────────────────────────────────────────────────
|
||||
|
||||
[FoldoutGroup("$blockId")]
|
||||
[ShowIf("blockType", StoryBlockType.Text)]
|
||||
[LabelText("Yarn Node Name")]
|
||||
public string yarnNodeName;
|
||||
|
||||
[FoldoutGroup("$blockId")]
|
||||
[ShowIf("blockType", StoryBlockType.Text)]
|
||||
[LabelText("Title Key (Localization)")]
|
||||
public string titleKey;
|
||||
|
||||
// ── Song Block ──────────────────────────────────────────────────────────
|
||||
|
||||
[FoldoutGroup("$blockId")]
|
||||
[ShowIf("blockType", StoryBlockType.Song)]
|
||||
[LabelText("Song Name")]
|
||||
public string songName;
|
||||
|
||||
// ── Tutorial Block ──────────────────────────────────────────────────────
|
||||
|
||||
[FoldoutGroup("$blockId")]
|
||||
[ShowIf("blockType", StoryBlockType.Tutorial)]
|
||||
[LabelText("Tutorial Name")]
|
||||
public string tutorialName;
|
||||
|
||||
/// <summary>
|
||||
/// 预览 / 调试用的显示标题:按类型取对应字段,缺省时回退到 blockId。
|
||||
/// </summary>
|
||||
public string GetDisplayTitle()
|
||||
{
|
||||
string title = blockType switch
|
||||
{
|
||||
StoryBlockType.Text => titleKey,
|
||||
StoryBlockType.Song => songName,
|
||||
StoryBlockType.Tutorial => tutorialName,
|
||||
_ => null
|
||||
};
|
||||
|
||||
return string.IsNullOrEmpty(title) ? blockId : title;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Block 的解锁条件。以一棵可组合的条件树(<see cref="StoryConditionNode"/>)表达,
|
||||
/// 支持"与 / 或 / 非 + 叶子条件"的任意嵌套。根节点为空时视为无条件解锁(始终满足)。
|
||||
/// </summary>
|
||||
[InlineProperty]
|
||||
[Serializable]
|
||||
public class UnlockCondition
|
||||
{
|
||||
[HideLabel]
|
||||
[SerializeReference]
|
||||
[InfoBox("留空表示无条件解锁(章节起始即可用)。可选择 All Of(AND) / Any Of(OR) / Not 复合节点进行任意嵌套。")]
|
||||
public StoryConditionNode root;
|
||||
|
||||
/// <summary>
|
||||
/// 检查解锁条件是否满足。根节点为空视为满足。
|
||||
/// </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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e6951fbb376c1f44aac1da9ebbac7502
|
||||
139
Assets/Scripts/NewStorySystem/Data/StoryConditionNode.cs
Normal file
139
Assets/Scripts/NewStorySystem/Data/StoryConditionNode.cs
Normal file
@@ -0,0 +1,139 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Sirenix.OdinInspector;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ichni.Story
|
||||
{
|
||||
/// <summary>
|
||||
/// 解锁条件树的节点基类。通过多态组合出"与 / 或 / 非 + 叶子条件"的复合表达式。
|
||||
/// 由 <see cref="UnlockCondition"/> 借助 <c>[SerializeReference]</c> 序列化持有,
|
||||
/// Odin 会以类型下拉框展示可选的节点类型。
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public abstract class StoryConditionNode
|
||||
{
|
||||
/// <summary>
|
||||
/// 对该节点求值。
|
||||
/// </summary>
|
||||
/// <param name="getVariable">按变量名返回其整型值的委托。</param>
|
||||
/// <param name="isBlockCompleted">按 blockId 判断该 block 是否已完成的委托。</param>
|
||||
public abstract bool Evaluate(Func<string, int> getVariable, Func<string, bool> isBlockCompleted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复合节点(逻辑与 AND):所有子条件均满足时才满足。子列表为空视为满足。
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
[LabelText("All Of (AND)")]
|
||||
public class AllOfCondition : StoryConditionNode
|
||||
{
|
||||
[HideLabel]
|
||||
[SerializeReference]
|
||||
[ListDrawerSettings(ShowFoldout = true, DefaultExpandedState = true)]
|
||||
public List<StoryConditionNode> conditions = new List<StoryConditionNode>();
|
||||
|
||||
public override bool Evaluate(Func<string, int> getVariable, Func<string, bool> isBlockCompleted)
|
||||
{
|
||||
foreach (StoryConditionNode child in conditions)
|
||||
{
|
||||
if (child != null && !child.Evaluate(getVariable, isBlockCompleted))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复合节点(逻辑或 OR):任一子条件满足即满足。子列表为空视为不满足。
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
[LabelText("Any Of (OR)")]
|
||||
public class AnyOfCondition : StoryConditionNode
|
||||
{
|
||||
[HideLabel]
|
||||
[SerializeReference]
|
||||
[ListDrawerSettings(ShowFoldout = true, DefaultExpandedState = true)]
|
||||
public List<StoryConditionNode> conditions = new List<StoryConditionNode>();
|
||||
|
||||
public override bool Evaluate(Func<string, int> getVariable, Func<string, bool> isBlockCompleted)
|
||||
{
|
||||
if (conditions.Count == 0)
|
||||
return false;
|
||||
|
||||
foreach (StoryConditionNode child in conditions)
|
||||
{
|
||||
if (child != null && child.Evaluate(getVariable, isBlockCompleted))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复合节点(逻辑非 NOT):对子条件取反。子条件为空视为满足(等价于"非 假")。
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
[LabelText("Not")]
|
||||
public class NotCondition : StoryConditionNode
|
||||
{
|
||||
[HideLabel]
|
||||
[SerializeReference]
|
||||
public StoryConditionNode condition;
|
||||
|
||||
public override bool Evaluate(Func<string, int> getVariable, Func<string, bool> isBlockCompleted)
|
||||
{
|
||||
return condition == null || !condition.Evaluate(getVariable, isBlockCompleted);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 叶子节点:指定 block 已完成时满足。
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
[LabelText("Block Completed")]
|
||||
public class BlockCompletedCondition : StoryConditionNode
|
||||
{
|
||||
[LabelText("Block ID")]
|
||||
public string blockId;
|
||||
|
||||
public override bool Evaluate(Func<string, int> getVariable, Func<string, bool> isBlockCompleted)
|
||||
{
|
||||
return !string.IsNullOrEmpty(blockId) && isBlockCompleted(blockId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 叶子节点:将指定变量的当前值与目标值按比较方式求值。
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
[LabelText("Variable Compare")]
|
||||
public class VariableCondition : StoryConditionNode
|
||||
{
|
||||
[LabelText("Variable Name")]
|
||||
public string variableName;
|
||||
|
||||
[LabelText("Comparison")]
|
||||
public VariableComparison comparison;
|
||||
|
||||
[LabelText("Value")]
|
||||
public int value;
|
||||
|
||||
public override bool Evaluate(Func<string, int> getVariable, Func<string, bool> isBlockCompleted)
|
||||
{
|
||||
int actual = getVariable(variableName);
|
||||
return comparison switch
|
||||
{
|
||||
VariableComparison.Equal => actual == value,
|
||||
VariableComparison.NotEqual => actual != value,
|
||||
VariableComparison.Greater => actual > value,
|
||||
VariableComparison.GreaterOrEqual => actual >= value,
|
||||
VariableComparison.Less => actual < value,
|
||||
VariableComparison.LessOrEqual => actual <= value,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9fcafe0071b87374e88e01816f9a5252
|
||||
238
Assets/Scripts/NewStorySystem/Data/StoryData.cs
Normal file
238
Assets/Scripts/NewStorySystem/Data/StoryData.cs
Normal file
@@ -0,0 +1,238 @@
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// 单个章节的故事树数据,以 ScriptableObject 形式存储所有 block 定义与关联资产引用。
|
||||
/// 布局完全由每个 block 的 (gridColumn, gridRow) 静态决定,运行时全量展示。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(fileName = "StoryData", menuName = "Ichni/Story/New/StoryData")]
|
||||
public class StoryData : SerializedScriptableObject
|
||||
{
|
||||
[LabelText("Chapter Index")]
|
||||
public string chapterIndex;
|
||||
|
||||
[LabelText("Yarn Project")]
|
||||
[InfoBox("该章节对应的 YarnProject 编译资产,阶段 2 接线。")]
|
||||
public YarnProject yarnProject;
|
||||
|
||||
[LabelText("Blocks")]
|
||||
[ListDrawerSettings(ShowFoldout = true, DefaultExpandedState = false, DraggableItems = true, ShowItemCount = true)]
|
||||
public List<StoryBlockDefinition> blocks = new List<StoryBlockDefinition>();
|
||||
|
||||
// ── Queries ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 根据 blockId 获取 block 定义,未找到时返回 null。
|
||||
/// </summary>
|
||||
public StoryBlockDefinition GetBlock(string blockId)
|
||||
{
|
||||
foreach (StoryBlockDefinition block in blocks)
|
||||
{
|
||||
if (block.blockId == blockId)
|
||||
return block;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定 blockId 的所有直接后继 block 定义。
|
||||
/// </summary>
|
||||
public IEnumerable<StoryBlockDefinition> GetNextBlocks(string blockId)
|
||||
{
|
||||
StoryBlockDefinition source = GetBlock(blockId);
|
||||
if (source == null) yield break;
|
||||
|
||||
foreach (string nextId in source.nextBlockIds)
|
||||
{
|
||||
StoryBlockDefinition next = GetBlock(nextId);
|
||||
if (next != null)
|
||||
yield return next;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
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 ID
|
||||
private const float SimpleCellWidth = 62f;
|
||||
private const float SimpleCellHeight = 26f;
|
||||
private const float SimpleStepX = 92f;
|
||||
private const float SimpleStepY = 42f;
|
||||
|
||||
/// <summary>
|
||||
/// 在 Inspector 顶部绘制只读的故事树结构预览(流程图样式,连线左右相连)。
|
||||
/// 提供"详细 / 简略"两种可拖拽平移的视图;按 (gridColumn, gridRow) 摆放 block,
|
||||
/// 支持负数与小数坐标,(0,0) 对应左侧中部。
|
||||
/// </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 cellW = simple ? SimpleCellWidth : DetailCellWidth;
|
||||
float cellH = simple ? SimpleCellHeight : DetailCellHeight;
|
||||
float stepX = simple ? SimpleStepX : DetailStepX;
|
||||
float stepY = simple ? SimpleStepY : DetailStepY;
|
||||
|
||||
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) 网格点 = 视口左侧中线(叠加平移量)
|
||||
float originX = PreviewPaddingLeft + _previewPan.x;
|
||||
float originY = area.height * 0.5f + _previewPan.y;
|
||||
|
||||
// 计算各 block 矩形(左中对齐网格点,与运行时 pivot(0,0.5) 一致),按 blockId 索引
|
||||
Dictionary<string, Rect> cellRects = new Dictionary<string, Rect>();
|
||||
foreach (StoryBlockDefinition block in blocks)
|
||||
{
|
||||
if (block == null || string.IsNullOrEmpty(block.blockId)) 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);
|
||||
}
|
||||
|
||||
// 先画连线:左右相连的流程图(起点右中 → 终点左中,水平切线)
|
||||
Handles.BeginGUI();
|
||||
foreach (StoryBlockDefinition block in blocks)
|
||||
{
|
||||
if (block == null || !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);
|
||||
Vector3 startTan = start + Vector3.right * PreviewTangent;
|
||||
Vector3 endTan = end + Vector3.left * PreviewTangent;
|
||||
|
||||
Handles.DrawBezier(start, end, startTan, endTan,
|
||||
new Color(0.82f, 0.82f, 0.88f, 1f), null, simple ? 2f : 3f);
|
||||
}
|
||||
}
|
||||
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 (block == null || !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()}";
|
||||
GUI.Label(rect, label, labelStyle);
|
||||
}
|
||||
}
|
||||
GUI.EndClip();
|
||||
|
||||
EditorGUILayout.HelpBox(
|
||||
"在预览区内拖拽可平移视图。列 = 水平(右为正),行 = 垂直(下为正、上为负),(0,0) 位于左侧中部,支持负数与小数。",
|
||||
MessageType.None);
|
||||
}
|
||||
|
||||
/// <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 类型的填充色。
|
||||
/// </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
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/NewStorySystem/Data/StoryData.cs.meta
Normal file
2
Assets/Scripts/NewStorySystem/Data/StoryData.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1b6af1b536c0bd1418753fffe7a903ac
|
||||
Reference in New Issue
Block a user