using System; using System.Collections.Generic; using Sirenix.OdinInspector; using UnityEngine; namespace Ichni.Story { /// /// Block 在故事树中的状态:已锁定、当前可交互、已完成。 /// public enum StoryBlockState { Locked, Current, Completed, Forbidden } /// /// Block 的类型,决定点击后触发的行为。 /// public enum StoryBlockType { Text, Song, Tutorial } /// /// TextBlock 的叙事层级。层级只决定剧情树中的视觉样式和 checkpoint 候选资格, /// 不会自行改变 Block 的解锁条件或 Yarn 执行逻辑。 /// public enum TextBlockImportance { Important, Secondary } /// /// 变量条件的比较方式。 /// public enum VariableComparison { Equal, NotEqual, Greater, GreaterOrEqual, Less, LessOrEqual } /// /// 故事树中单个 block 的完整数据定义,由 持有。 /// [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 nextBlockIds = new List(); [FoldoutGroup("$blockId")] [LabelText("Unlock Condition")] [InfoBox("留空表示无条件解锁(章节起始即可用)。")] public StoryCondition unlockCondition = new StoryCondition(); /// /// 用于禁用当前 Block 的可选条件。它与解锁条件共用同一种通用条件树, /// 因而可直接使用剧情变量、Block 完成状态及 AND / OR / NOT 组合。 /// 条件满足时,该 Block 的状态固定为 Forbidden,且优先于 Completed / Current / Locked。 /// [FoldoutGroup("$blockId")] [LabelText("Forbidden Condition")] [InfoBox("可选。留空表示此 Block 不会被条件禁用;满足时显示为 Forbidden 且不可点击。")] public StoryCondition forbiddenCondition = new StoryCondition(); // ── 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; /// /// TextBlock 的视觉和叙事层级。Important 用于主线转折与后续 checkpoint; /// Secondary 用于支线或补充剧情。默认 Important,以保持现有 TextBlock 的呈现语义。 /// [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 (Stable ID)")] [InfoBox("对应 ChapterSelectionUnit 中 SongItemData.songName 的稳定歌曲标识。它用于查找歌曲资料与进入选曲,不能填写 displaySongName、翻译文本或作曲者名称。")] public string songName; /// /// 插画在 SongBlock 的 Mask 中使用的实际本地 Y 坐标。SongBlock 的 Illustration 固定为 400 x 225, /// Mask 可视区域固定为当前 Prefab 的尺寸;在不露出上下空白的前提下,安全范围为 -74.25 到 74.25。 /// 该值是最终坐标而非偏移量:0 表示居中,负值向下移动,正值向上移动。 /// [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")] [ShowIf("blockType", StoryBlockType.Tutorial)] [LabelText("Tutorial Name")] public string tutorialName; /// /// 教程曲目在 中的稳定 Key。 /// 只用于运行时查找,不使用展示名称或章节名称作为逻辑匹配条件。 /// 命名统一使用小写英文、数字和下划线,例如 chapter0_intro。 /// [FoldoutGroup("$blockId")] [ShowIf("blockType", StoryBlockType.Tutorial)] [LabelText("Tutorial Key")] public string tutorialKey; /// /// 教程被玩家选择“游玩”或“跳过”后写入的全局剧情变量。 /// 后续 Block 应通过 检查该变量是否等于 1 来解锁。 /// 命名统一使用小写英文、数字和下划线,例如 /// story_tutorial_chapter0_intro_resolved。 /// [FoldoutGroup("$blockId")] [ShowIf("blockType", StoryBlockType.Tutorial)] [LabelText("Progress Variable")] public string tutorialProgressVariable; /// /// 要启动的教程难度的稳定存档 ID,对应 。 /// 不使用难度列表下标,避免调整难度排列后教程进入错误谱面。 /// [FoldoutGroup("$blockId")] [ShowIf("blockType", StoryBlockType.Tutorial)] [LabelText("Tutorial Difficulty Save ID")] public int tutorialDifficultySaveId = -1; /// /// 预览 / 调试用的显示标题:按类型取对应字段,缺省时回退到 blockId。 /// public string GetDisplayTitle() { string title = blockType switch { StoryBlockType.Text => titleKey, StoryBlockType.Song => songName, StoryBlockType.Tutorial => tutorialName, _ => null }; return string.IsNullOrEmpty(title) ? blockId : title; } } /// /// 通用剧情条件容器。以一棵可组合的条件树()表达, /// 支持“与 / 或 / 非 + 叶子条件”的任意嵌套。 /// 该类型不限定用途,可用于 Block 的解锁、禁用、Helper 对话筛选等所有剧情判断。 /// 根节点为空表示“未配置条件”,其是否视为通过由调用方根据业务语义决定; /// 例如 unlockCondition 留空即允许,而 forbiddenCondition 留空即不禁用。 /// [InlineProperty] [Serializable] public class StoryCondition { [HideLabel] [SerializeReference] [InfoBox("留空表示未配置条件。可选择 All Of(AND) / Any Of(OR) / Not 复合节点进行任意嵌套。")] public StoryConditionNode root; /// /// 当前容器是否已配置实际条件。 /// public bool IsConfigured => root != null; /// /// 检查已配置条件是否满足。根节点为空时返回 false;调用方应结合 /// 决定“未配置条件”的业务含义。 /// /// 按变量名返回其整型值的委托。 /// 按 blockId 判断该 block 是否已完成的委托。 public bool IsSatisfied(Func getVariable, Func isBlockCompleted) { return root != null && root.Evaluate(getVariable, isBlockCompleted); } } }