Files
ichni_Official/Assets/Scripts/NewStorySystem/Data/StoryBlockDefinition.cs
SoulliesOfficial 810d019619 剧情+对话完善
2026-07-21 15:24:42 -04:00

286 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using Sirenix.OdinInspector;
using UnityEngine;
namespace Ichni.Story
{
/// <summary>
/// Block 在故事树中的状态:已锁定、当前可交互、已完成。
/// </summary>
public enum StoryBlockState
{
Locked,
Current,
Completed,
Forbidden
}
/// <summary>
/// Block 的类型,决定点击后触发的行为。
/// </summary>
public enum StoryBlockType
{
Text,
Song,
Tutorial
}
/// <summary>
/// 一个 Block 如何自动处理指向它的直接连接线。
/// <para>连接线表达基础流程关系,因此默认要求所有直接前置 Block 都已完成;该结果会与
/// <see cref="StoryBlockDefinition.unlockCondition"/> 按 AND 合并。</para>
/// <para>路线汇合时应使用 <see cref="RequireAnyDirectPredecessor"/>;仅用于视觉连线、
/// 不应影响可用性的连接则使用 <see cref="IgnoreConnections"/>。</para>
/// </summary>
public enum ConnectedBlockUnlockRule
{
/// <summary>默认值:全部直接前置 Block 都必须完成。</summary>
RequireAllDirectPredecessors = 0,
/// <summary>至少一个直接前置 Block 完成即可,适用于互斥分支的汇合节点。</summary>
RequireAnyDirectPredecessor = 1,
/// <summary>忽略连接线,只按手写 Unlock Condition 判断。</summary>
IgnoreConnections = 2
}
/// <summary>
/// TextBlock 的叙事层级。层级只决定剧情树中的视觉样式和 checkpoint 候选资格,
/// 不会自行改变 Block 的解锁条件或 Yarn 执行逻辑。
/// </summary>
public enum TextBlockImportance
{
Important,
Secondary
}
/// <summary>
/// 变量条件的比较方式。
/// </summary>
public enum VariableComparison
{
Equal,
NotEqual,
Greater,
GreaterOrEqual,
Less,
LessOrEqual
}
/// <summary>
/// 故事树中单个 block 的完整数据定义,由 <see cref="StoryData"/> 持有。
/// </summary>
[InlineProperty]
[HideReferenceObjectPicker]
[Serializable]
public class StoryBlockDefinition
{
// 仅用于 Odin Inspector 的动态折叠标题,不参与序列化或运行时状态推导。
// 在列表中即可识别 Block 的身份、类型与布局位置,减少频繁展开确认的需要。
private string EditorHeader => string.IsNullOrWhiteSpace(blockId)
? "New Block"
: $"{blockId} · {blockType} · C{gridColumn:0.##} / R{gridRow:0.##}";
[FoldoutGroup("$EditorHeader", false)]
[HorizontalGroup("$EditorHeader/Identity", 0.58f)]
[LabelText("ID")]
[Tooltip("Block 的稳定唯一 ID。连接、条件、Timeline Marker 与存档均依赖该值;建议使用英文、数字和下划线。")]
public string blockId;
[FoldoutGroup("$EditorHeader")]
[HorizontalGroup("$EditorHeader/Identity")]
[LabelText("Type")]
[Tooltip("决定该 Block 的运行时入口与下方显示的专属配置。")]
public StoryBlockType blockType;
[FoldoutGroup("$EditorHeader")]
[HorizontalGroup("$EditorHeader/Layout")]
[LabelText("Column")]
[Tooltip("故事树网格中的列:向右为正,(0,0) 位于容器左侧中部。可为负数与小数(用于微调)。")]
public float gridColumn;
[FoldoutGroup("$EditorHeader")]
[HorizontalGroup("$EditorHeader/Layout")]
[LabelText("Row")]
[Tooltip("故事树网格中的行向下为正、向上为负0 对应垂直中线。可为小数(用于微调)。")]
public float gridRow;
[FoldoutGroup("$EditorHeader/Flow", false)]
[LabelText("Next Blocks")]
[Tooltip("从本 Block 指向的后续 Block ID。该列表既决定连接线也会成为后续 Block 的自动前置条件来源。")]
[ListDrawerSettings(ShowFoldout = true, DefaultExpandedState = false, ShowItemCount = true)]
public List<string> nextBlockIds = new List<string>();
/// <summary>
/// 自动把所有指向本 Block 的直接连接线转化为基础解锁条件。
/// 没有前置连接线的章节起始 Block 不受此字段限制;默认“全部前置完成”再与手写条件按 AND 合并。
/// </summary>
[FoldoutGroup("$EditorHeader/Flow")]
[LabelText("Connected Rule")]
[Tooltip("默认 Require All所有直接前置 Block 完成后才可用。互斥路线汇合使用 Require Any纯视觉连接使用 Ignore。")]
public ConnectedBlockUnlockRule connectedBlockUnlockRule =
ConnectedBlockUnlockRule.RequireAllDirectPredecessors;
[FoldoutGroup("$EditorHeader/Rules", false)]
[LabelText("Unlock")]
[Tooltip("可选的额外可用条件;它与 Connected Rule 按 AND 合并。没有前置连接的起始 Block 通常留空即可。")]
public StoryCondition unlockCondition = new StoryCondition();
/// <summary>
/// 用于禁用当前 Block 的可选条件。它与解锁条件共用同一种通用条件树,
/// 因而可直接使用剧情变量、Block 完成状态及 AND / OR / NOT 组合。
/// 条件满足时,该 Block 的状态固定为 Forbidden且优先于 Completed / Current / Locked。
/// </summary>
[FoldoutGroup("$EditorHeader/Rules")]
[LabelText("Forbidden")]
[Tooltip("可选。满足时该 Block 固定显示为 Forbidden 且不可点击;留空表示不会被条件禁用。")]
public StoryCondition forbiddenCondition = new StoryCondition();
// ── Text Block ──────────────────────────────────────────────────────────
[FoldoutGroup("$EditorHeader/Text Content", false)]
[ShowIf("blockType", StoryBlockType.Text)]
[LabelText("Yarn Node")]
[Tooltip("点击 TextBlock 后由 Yarn Spinner 开始执行的节点名。")]
public string yarnNodeName;
[FoldoutGroup("$EditorHeader/Text Content")]
[ShowIf("blockType", StoryBlockType.Text)]
[LabelText("Title Key")]
[Tooltip("该 TextBlock 标题使用的 Unity Localization Entry Key留空时视觉层可使用 Block ID 作为开发阶段回退。")]
public string titleKey;
/// <summary>
/// TextBlock 的视觉和叙事层级。Important 用于主线转折与后续 checkpoint
/// Secondary 用于支线或补充剧情。默认 Important以保持现有 TextBlock 的呈现语义。
/// </summary>
[FoldoutGroup("$EditorHeader/Text Content")]
[ShowIf("blockType", StoryBlockType.Text)]
[LabelText("Importance")]
[Tooltip("Important 用于主线转折与后续 CheckpointSecondary 用于支线或补充剧情,并决定对应的 Block Prefab。")]
public TextBlockImportance textImportance = TextBlockImportance.Important;
// ── Song Block ──────────────────────────────────────────────────────────
[FoldoutGroup("$EditorHeader/Song Content", false)]
[ShowIf("blockType", StoryBlockType.Song)]
[LabelText("Song ID")]
[Tooltip("对应 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("$EditorHeader/Song Content")]
[ShowIf("blockType", StoryBlockType.Song)]
[LabelText("Illustration Y")]
[Range(-74.25f, 74.25f)]
[Tooltip("微调 16:9 插画在 SongBlock Mask 中的纵向裁切。SongBlockUI 的 Illustration 尺寸应保持为 400 × 225。")]
public float illustrationLocalY;
// ── Tutorial Block ──────────────────────────────────────────────────────
[FoldoutGroup("$EditorHeader/Tutorial Content", false)]
[ShowIf("blockType", StoryBlockType.Tutorial)]
[LabelText("Display Name")]
[Tooltip("仅用于 StoryBlock 的显示与编辑识别,不参与教程查找。")]
public string tutorialName;
/// <summary>
/// 教程曲目在 <see cref="Ichni.Menu.TutorialCollection"/> 中的稳定 Key。
/// 只用于运行时查找,不使用展示名称或章节名称作为逻辑匹配条件。
/// 命名统一使用小写英文、数字和下划线,例如 <c>chapter0_intro</c>。
/// </summary>
[FoldoutGroup("$EditorHeader/Tutorial Content")]
[ShowIf("blockType", StoryBlockType.Tutorial)]
[LabelText("Tutorial Key")]
[Tooltip("TutorialCollection 中的稳定 Key。使用小写英文、数字和下划线例如 chapter0_intro。")]
public string tutorialKey;
/// <summary>
/// 教程被玩家选择“游玩”或“跳过”后写入的当前章节剧情变量。
/// 后续 Block 应通过 <see cref="VariableCondition"/> 检查该变量是否等于 1 来解锁。
/// 命名统一使用小写英文、数字和下划线,例如
/// <c>story_tutorial_chapter0_intro_resolved</c>。
/// </summary>
[FoldoutGroup("$EditorHeader/Tutorial Content")]
[ShowIf("blockType", StoryBlockType.Tutorial)]
[LabelText("Progress Variable")]
[Tooltip("玩家选择游玩或跳过教程后写入 true 的章节剧情变量;后续 Block 可通过条件检查它。")]
public string tutorialProgressVariable;
/// <summary>
/// 要启动的教程难度的稳定存档 ID对应 <see cref="Ichni.Menu.DifficultyData.saveDifficultyId"/>。
/// 不使用难度列表下标,避免调整难度排列后教程进入错误谱面。
/// </summary>
[FoldoutGroup("$EditorHeader/Tutorial Content")]
[ShowIf("blockType", StoryBlockType.Tutorial)]
[LabelText("Difficulty Save ID")]
[Tooltip("要启动教程谱面的 DifficultyData.saveDifficultyId。不要使用难度列表下标以免排序变化后指向错误谱面。")]
public int tutorialDifficultySaveId = -1;
/// <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>
/// 通用剧情条件容器。以一棵可组合的条件树(<see cref="StoryConditionNode"/>)表达,
/// 支持“与 / 或 / 非 + 叶子条件”的任意嵌套。
/// <para>该类型不限定用途,可用于 Block 的解锁、禁用、Helper 对话筛选等所有剧情判断。</para>
/// <para>根节点为空表示“未配置条件”,其是否视为通过由调用方根据业务语义决定;
/// 例如 unlockCondition 留空即允许,而 forbiddenCondition 留空即不禁用。</para>
/// </summary>
[InlineProperty]
[HideReferenceObjectPicker]
[Serializable]
public class StoryCondition
{
[HideLabel]
[SerializeReference]
[Tooltip("留空表示未配置条件。可选择 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);
}
#if UNITY_EDITOR
/// <summary>
/// Odin 条件 Drawer 使用的只读摘要;不参与运行时求值和序列化。
/// </summary>
public string GetEditorSummary()
{
return root == null ? "None" : root.GetEditorSummary();
}
#endif
}
}