Storyline+Dialog初步
This commit is contained in:
@@ -82,7 +82,7 @@ namespace Ichni.RhythmGame
|
||||
}
|
||||
}
|
||||
|
||||
protected void SetEnableEmission() => meshRenderer.material.SetInt("_Emission", enableEmission ? 1 : 0);
|
||||
protected void SetEnableEmission() => meshRenderer.material.SetInt("_EnableEmission", enableEmission ? 1 : 0);
|
||||
protected void SetEnableZWrite() => meshRenderer.material.SetInt("_ZWrite", zWrite ? 1 : 0);
|
||||
protected void SetEmissionIntensity() => meshRenderer.material.SetColor("_EmissionColor", Color.white * Mathf.Pow(2, emissionIntensity));
|
||||
protected void SetUV()
|
||||
|
||||
@@ -21,7 +21,7 @@ namespace Ichni
|
||||
public LoginPage loginPage;
|
||||
public ChapterSelectionUIPage chapterSelectionUIPage;
|
||||
public StoryUIPage storyUIPage;
|
||||
public DialogUIPage dialogUIPage;
|
||||
// dialogUIPage 已随旧对话系统移除;阶段 3 将接入新的 VN 对话页面引用
|
||||
public SongSelectionUIPage songSelectionUIPage;
|
||||
public TransitionUIPage transitionUIPage;
|
||||
public SettingsUIPage settingsUIPage;
|
||||
|
||||
8
Assets/Scripts/NewStorySystem.meta
Normal file
8
Assets/Scripts/NewStorySystem.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e814c481539958443ae18379d442462d
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/Scripts/NewStorySystem/Data.meta
Normal file
8
Assets/Scripts/NewStorySystem/Data.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f44ff0ea98bbdd44794db6a7250ef7f7
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
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
|
||||
8
Assets/Scripts/NewStorySystem/Dialogue.meta
Normal file
8
Assets/Scripts/NewStorySystem/Dialogue.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 79af33f24eff7054aa7cfe2386f6e221
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,73 @@
|
||||
using UnityEngine;
|
||||
using Yarn.Unity;
|
||||
|
||||
namespace Ichni.Story.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// 【临时 / 调试用】将 Yarn 对话行以"本地化后的文本"输出到 Console 的呈现器。
|
||||
/// <para>用途:在没有 VN 对话 UI 的情况下,验证整条 Yarn 管线是否跑通,以及
|
||||
/// Unity Localization 是否正确返回当前语言的文本(点击文本块即可在 Console 看到结果)。</para>
|
||||
/// <para>阶段 3 接入 <c>VNDialoguePresenter</c> 后,可从 DialogueRunner 的呈现器列表移除本组件并删除此脚本。</para>
|
||||
/// </summary>
|
||||
public class ConsoleLinePresenter : DialoguePresenterBase
|
||||
{
|
||||
public override YarnTask OnDialogueStartedAsync()
|
||||
{
|
||||
Debug.Log("[ConsoleLinePresenter] === 对话开始 ===");
|
||||
return YarnTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 输出本地化后的台词文本。
|
||||
/// <para>注意:必须 async + YarnTask.Yield()。Yarn Spinner 3.x 的 RunLocalisedLine 在所有
|
||||
/// presenter 均同步完成时会错误地调用 Dialogue.SignalContentComplete()(该方法仅对
|
||||
/// command dispatch 合法),导致 InvalidOperationException。Yield 确保任务进入异步路径。</para>
|
||||
/// </summary>
|
||||
public override async YarnTask RunLineAsync(LocalizedLine line, LineCancellationToken token)
|
||||
{
|
||||
string speaker = string.IsNullOrEmpty(line.CharacterName) ? "(旁白)" : line.CharacterName;
|
||||
string body = line.TextWithoutCharacterName.Text;
|
||||
|
||||
Debug.Log($"[ConsoleLinePresenter] {speaker}: {body}\n" +
|
||||
$" (lineID={line.TextID}, rawText=\"{line.RawText}\")");
|
||||
|
||||
await YarnTask.Yield();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 调试用:打印所有选项并自动选择第一个可用选项,以便管线可以跑完整个对话树。
|
||||
/// </summary>
|
||||
public override async YarnTask<DialogueOption?> RunOptionsAsync(
|
||||
DialogueOption[] options,
|
||||
LineCancellationToken token)
|
||||
{
|
||||
for (int i = 0; i < options.Length; i++)
|
||||
{
|
||||
string optText = options[i].Line.TextWithoutCharacterName.Text;
|
||||
string tag = options[i].IsAvailable ? "" : " [不可用]";
|
||||
Debug.Log($"[ConsoleLinePresenter] 选项 {i}{tag}: {optText}");
|
||||
}
|
||||
|
||||
// 自动选择第一个可用选项
|
||||
foreach (DialogueOption opt in options)
|
||||
{
|
||||
if (opt.IsAvailable)
|
||||
{
|
||||
Debug.Log($"[ConsoleLinePresenter] → 自动选择: {opt.Line.TextWithoutCharacterName.Text}");
|
||||
await YarnTask.Yield();
|
||||
return opt;
|
||||
}
|
||||
}
|
||||
|
||||
// 无可用选项时返回 null(触发 DialogueRunner 的 fallthrough 逻辑)
|
||||
await YarnTask.Yield();
|
||||
return null;
|
||||
}
|
||||
|
||||
public override YarnTask OnDialogueCompleteAsync()
|
||||
{
|
||||
Debug.Log("[ConsoleLinePresenter] === 对话结束 ===");
|
||||
return YarnTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7f38c46a2f0519249b2b9efb27734c13
|
||||
@@ -0,0 +1,163 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using Ichni.Story.UI;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
using Yarn.Unity;
|
||||
using Yarn.Unity.UnityLocalization;
|
||||
|
||||
namespace Ichni.Story.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// 故事树 block 与 Yarn <see cref="DialogueRunner"/> 之间的桥接控制器。
|
||||
/// <para>点击文本块 → 启动对应 Yarn 节点;对话结束 → 持久化剧情变量、标记 block 完成、
|
||||
/// 重算解锁并存档、依次执行结束回调(如歌曲解锁提示,阶段 4 使用)。</para>
|
||||
/// </summary>
|
||||
public class StoryDialogueController : MonoBehaviour
|
||||
{
|
||||
public static StoryDialogueController instance;
|
||||
|
||||
[Header("References")]
|
||||
[Tooltip("驱动 Yarn 对话的 DialogueRunner。")]
|
||||
public DialogueRunner dialogueRunner;
|
||||
|
||||
[Tooltip("故事树页面:对话进行时淡出、结束后淡入(可选)。")]
|
||||
public StoryUIPage storyUIPage;
|
||||
|
||||
/// <summary>
|
||||
/// 对话结束后依次执行并清空的回调队列。供 <c>unlock_song</c> 等 Yarn 命令(阶段 4)
|
||||
/// 把"解锁提示"排队到对话结束后弹出。
|
||||
/// </summary>
|
||||
[System.NonSerialized] public List<UnityAction> dialogueEndActions = new List<UnityAction>();
|
||||
|
||||
// 当前正在播放对话的 block id;对话结束时据此标记完成
|
||||
private string _activeBlockId;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
instance = this;
|
||||
EnsureUnityLocalisedLineProvider();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (dialogueRunner != null && dialogueRunner.onDialogueComplete != null)
|
||||
dialogueRunner.onDialogueComplete.AddListener(HandleDialogueComplete);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (dialogueRunner != null && dialogueRunner.onDialogueComplete != null)
|
||||
dialogueRunner.onDialogueComplete.RemoveListener(HandleDialogueComplete);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 播放某文本块对应的 Yarn 节点。若对话已在进行、引用缺失或节点名为空则忽略。
|
||||
/// </summary>
|
||||
public void PlayBlock(TextBlockView block)
|
||||
{
|
||||
if (block == null) return;
|
||||
|
||||
if (dialogueRunner == null)
|
||||
{
|
||||
Debug.LogError("[StoryDialogueController] 未配置 DialogueRunner,无法开始对话。");
|
||||
return;
|
||||
}
|
||||
|
||||
if (dialogueRunner.IsDialogueRunning)
|
||||
{
|
||||
Debug.LogWarning("[StoryDialogueController] 已有对话在进行中,忽略本次点击。");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(block.YarnNodeName))
|
||||
{
|
||||
Debug.LogWarning($"[StoryDialogueController] block '{block.blockId}' 未配置 yarnNodeName,无法开始对话。");
|
||||
return;
|
||||
}
|
||||
|
||||
// 将 DialogueRunner 的 YarnProject 对齐到当前章节(支持多章节共用一个 Runner)
|
||||
EnsureProjectForCurrentChapter();
|
||||
|
||||
_activeBlockId = block.blockId;
|
||||
|
||||
if (storyUIPage != null)
|
||||
storyUIPage.FadeOut();
|
||||
|
||||
// StartDialogue 返回 YarnTask;此处即发即忘,完成由 onDialogueComplete 处理
|
||||
dialogueRunner.StartDialogue(block.YarnNodeName).Forget();
|
||||
}
|
||||
|
||||
// 将 DialogueRunner 的 YarnProject 切换为当前已构建章节的 StoryData.yarnProject(若不同且未在运行)
|
||||
private void EnsureProjectForCurrentChapter()
|
||||
{
|
||||
StoryTreeController tree = StoryManager.instance != null ? StoryManager.instance.treeController : null;
|
||||
StoryData data = tree != null ? tree.ActiveStoryData : null;
|
||||
|
||||
if (data == null || data.yarnProject == null) return;
|
||||
|
||||
if (dialogueRunner.YarnProject != data.yarnProject && !dialogueRunner.IsDialogueRunning)
|
||||
dialogueRunner.SetProject(data.yarnProject);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DialogueRunner.lineProvider 为 [SerializeReference] internal 字段。
|
||||
/// Inspector 的 "Use Unity Localised Line Provider" 按钮有时无法将 MonoBehaviour
|
||||
/// 引用正确持久化到该字段;此方法在运行时用反射补救,确保使用正确的 Provider。
|
||||
/// </summary>
|
||||
private void EnsureUnityLocalisedLineProvider()
|
||||
{
|
||||
if (dialogueRunner == null) return;
|
||||
|
||||
FieldInfo field = typeof(DialogueRunner).GetField(
|
||||
"lineProvider",
|
||||
BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
|
||||
if (field == null) return;
|
||||
|
||||
// 已经是 UnityLocalisedLineProvider 则无需处理
|
||||
if (field.GetValue(dialogueRunner) is UnityLocalisedLineProvider) return;
|
||||
|
||||
UnityLocalisedLineProvider provider =
|
||||
dialogueRunner.GetComponent<UnityLocalisedLineProvider>();
|
||||
|
||||
if (provider != null)
|
||||
{
|
||||
field.SetValue(dialogueRunner, provider);
|
||||
Debug.Log("[StoryDialogueController] Assigned UnityLocalisedLineProvider to DialogueRunner.lineProvider.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning("[StoryDialogueController] UnityLocalisedLineProvider not found on DialogueRunner's GameObject. Localized line text will be unavailable.");
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleDialogueComplete()
|
||||
{
|
||||
// 持久化对话过程中写入的全局剧情变量
|
||||
if (GameSaveManager.instance != null && GameSaveManager.instance.StorySaveModule != null)
|
||||
GameSaveManager.instance.StorySaveModule.SaveVariables();
|
||||
|
||||
// 标记 block 完成 → 重算解锁 → 存档(OnBlockCompleted 内部已去重并保存章节进度)
|
||||
if (!string.IsNullOrEmpty(_activeBlockId) &&
|
||||
StoryManager.instance != null && StoryManager.instance.treeController != null)
|
||||
{
|
||||
StoryManager.instance.treeController.OnBlockCompleted(_activeBlockId);
|
||||
}
|
||||
|
||||
_activeBlockId = null;
|
||||
|
||||
// 依次执行并清空结束回调(如歌曲解锁提示)
|
||||
if (dialogueEndActions.Count > 0)
|
||||
{
|
||||
List<UnityAction> pending = new List<UnityAction>(dialogueEndActions);
|
||||
dialogueEndActions.Clear();
|
||||
foreach (UnityAction action in pending)
|
||||
action?.Invoke();
|
||||
}
|
||||
|
||||
if (storyUIPage != null)
|
||||
storyUIPage.FadeIn();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3c5da6d59dd1b3b4594e01d045b271c8
|
||||
207
Assets/Scripts/NewStorySystem/Dialogue/StoryVariableStorage.cs
Normal file
207
Assets/Scripts/NewStorySystem/Dialogue/StoryVariableStorage.cs
Normal file
@@ -0,0 +1,207 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using UnityEngine;
|
||||
using Yarn.Unity;
|
||||
|
||||
namespace Ichni.Story.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// 自定义 Yarn 变量存储。直接读写 <see cref="StorySaveModule.variables"/>(全局剧情变量),
|
||||
/// 让 Yarn 对话中的 <c><<set $var to ...>></c> 与故事树解锁条件共享同一份数据来源,
|
||||
/// 并借助存档模块的 ES3 序列化持久化。
|
||||
/// <para>变量名沿用 Yarn 约定(带 <c>$</c> 前缀);解锁条件中的变量名也应保持一致。</para>
|
||||
/// </summary>
|
||||
public class StoryVariableStorage : VariableStorageBehaviour
|
||||
{
|
||||
// GameSaveManager 尚未就绪(编辑器早期 / 场景未初始化)时的兜底容器
|
||||
private readonly StoryVariablesSave _fallback = new StoryVariablesSave();
|
||||
|
||||
/// <summary>当前生效的变量容器:优先取存档模块,缺失时用本地兜底容器。</summary>
|
||||
private StoryVariablesSave Variables
|
||||
{
|
||||
get
|
||||
{
|
||||
if (GameSaveManager.instance != null && GameSaveManager.instance.StorySaveModule != null)
|
||||
return GameSaveManager.instance.StorySaveModule.variables;
|
||||
return _fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// ── IVariableStorage 实现 ────────────────────────────────────────────────
|
||||
|
||||
public override bool TryGetValue<T>(string variableName, out T result)
|
||||
{
|
||||
StoryVariablesSave vars = Variables;
|
||||
|
||||
if (vars.floatVariables.TryGetValue(variableName, out float f))
|
||||
return TryConvert(f, out result);
|
||||
|
||||
if (vars.boolVariables.TryGetValue(variableName, out bool b))
|
||||
return TryConvert(b, out result);
|
||||
|
||||
if (vars.stringVariables.TryGetValue(variableName, out string s))
|
||||
return TryConvert(s, out result);
|
||||
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public override void SetValue(string variableName, string stringValue)
|
||||
{
|
||||
RemoveStaleEntries(variableName, keepFloat: false, keepString: true, keepBool: false);
|
||||
Variables.stringVariables[variableName] = stringValue;
|
||||
NotifyVariableChanged(variableName, stringValue);
|
||||
}
|
||||
|
||||
public override void SetValue(string variableName, float floatValue)
|
||||
{
|
||||
RemoveStaleEntries(variableName, keepFloat: true, keepString: false, keepBool: false);
|
||||
Variables.floatVariables[variableName] = floatValue;
|
||||
NotifyVariableChanged(variableName, floatValue);
|
||||
}
|
||||
|
||||
public override void SetValue(string variableName, bool boolValue)
|
||||
{
|
||||
RemoveStaleEntries(variableName, keepFloat: false, keepString: false, keepBool: true);
|
||||
Variables.boolVariables[variableName] = boolValue;
|
||||
NotifyVariableChanged(variableName, boolValue);
|
||||
}
|
||||
|
||||
public override bool Contains(string variableName)
|
||||
{
|
||||
StoryVariablesSave vars = Variables;
|
||||
return vars.floatVariables.ContainsKey(variableName)
|
||||
|| vars.boolVariables.ContainsKey(variableName)
|
||||
|| vars.stringVariables.ContainsKey(variableName);
|
||||
}
|
||||
|
||||
public override void Clear()
|
||||
{
|
||||
StoryVariablesSave vars = Variables;
|
||||
vars.floatVariables.Clear();
|
||||
vars.stringVariables.Clear();
|
||||
vars.boolVariables.Clear();
|
||||
}
|
||||
|
||||
public override void SetAllVariables(
|
||||
Dictionary<string, float> floats,
|
||||
Dictionary<string, string> strings,
|
||||
Dictionary<string, bool> bools,
|
||||
bool clear = true)
|
||||
{
|
||||
StoryVariablesSave vars = Variables;
|
||||
|
||||
if (clear)
|
||||
{
|
||||
vars.floatVariables.Clear();
|
||||
vars.stringVariables.Clear();
|
||||
vars.boolVariables.Clear();
|
||||
}
|
||||
|
||||
if (floats != null)
|
||||
foreach (KeyValuePair<string, float> kv in floats) vars.floatVariables[kv.Key] = kv.Value;
|
||||
if (strings != null)
|
||||
foreach (KeyValuePair<string, string> kv in strings) vars.stringVariables[kv.Key] = kv.Value;
|
||||
if (bools != null)
|
||||
foreach (KeyValuePair<string, bool> kv in bools) vars.boolVariables[kv.Key] = kv.Value;
|
||||
}
|
||||
|
||||
public override (Dictionary<string, float> FloatVariables,
|
||||
Dictionary<string, string> StringVariables,
|
||||
Dictionary<string, bool> BoolVariables) GetAllVariables()
|
||||
{
|
||||
StoryVariablesSave vars = Variables;
|
||||
return (
|
||||
new Dictionary<string, float>(vars.floatVariables),
|
||||
new Dictionary<string, string>(vars.stringVariables),
|
||||
new Dictionary<string, bool>(vars.boolVariables)
|
||||
);
|
||||
}
|
||||
|
||||
// ── 供故事树解锁条件复用的便捷读取 ────────────────────────────────────────
|
||||
|
||||
/// <summary>读取变量的整数近似值(float 四舍五入、bool 取 0/1);不存在返回 0。</summary>
|
||||
public int GetIntVariable(string variableName)
|
||||
{
|
||||
StoryVariablesSave vars = 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;
|
||||
}
|
||||
|
||||
/// <summary>该变量是否存在于任一类型的存储中。</summary>
|
||||
public bool HasVariable(string variableName) => Contains(variableName);
|
||||
|
||||
// ── 内部 ────────────────────────────────────────────────────────────────
|
||||
|
||||
// 当变量被重新赋值为其它类型时,从旧类型字典移除,避免同名变量残留多份
|
||||
private void RemoveStaleEntries(string name, bool keepFloat, bool keepString, bool keepBool)
|
||||
{
|
||||
StoryVariablesSave vars = Variables;
|
||||
if (!keepFloat) vars.floatVariables.Remove(name);
|
||||
if (!keepString) vars.stringVariables.Remove(name);
|
||||
if (!keepBool) vars.boolVariables.Remove(name);
|
||||
}
|
||||
|
||||
// 将存储中的原始值转换为 Yarn 请求的类型(float / bool / string / int 之间互转)
|
||||
private static bool TryConvert<T>(object value, out T result)
|
||||
{
|
||||
if (value is T typed)
|
||||
{
|
||||
result = typed;
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (typeof(T) == typeof(float) || typeof(T) == typeof(double) || typeof(T) == typeof(int))
|
||||
{
|
||||
float f = value switch
|
||||
{
|
||||
float fv => fv,
|
||||
bool bv => bv ? 1f : 0f,
|
||||
string sv => float.TryParse(sv, NumberStyles.Any, CultureInfo.InvariantCulture, out float p) ? p : 0f,
|
||||
_ => 0f
|
||||
};
|
||||
result = (T)System.Convert.ChangeType(f, typeof(T), CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof(T) == typeof(bool))
|
||||
{
|
||||
bool b = value switch
|
||||
{
|
||||
bool bv => bv,
|
||||
float fv => fv != 0f,
|
||||
string sv => bool.TryParse(sv, out bool p) && p,
|
||||
_ => false
|
||||
};
|
||||
result = (T)(object)b;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof(T) == typeof(string))
|
||||
{
|
||||
string s = value switch
|
||||
{
|
||||
string sv => sv,
|
||||
float fv => fv.ToString(CultureInfo.InvariantCulture),
|
||||
bool bv => bv.ToString(),
|
||||
_ => value != null ? value.ToString() : string.Empty
|
||||
};
|
||||
result = (T)(object)s;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 转换失败时落到下方失败分支
|
||||
}
|
||||
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7dc7dc14d9f97c043bf6fb67c3a70877
|
||||
320
Assets/Scripts/NewStorySystem/Dialogue/VNDialoguePresenter.cs
Normal file
320
Assets/Scripts/NewStorySystem/Dialogue/VNDialoguePresenter.cs
Normal file
@@ -0,0 +1,320 @@
|
||||
using System.Threading;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using Yarn.Unity;
|
||||
|
||||
namespace Ichni.Story.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// 视觉小说(VN)风格的 Yarn 对话呈现器。
|
||||
/// <para>
|
||||
/// 职责:
|
||||
/// <list type="bullet">
|
||||
/// <item>对话开始/结束时淡入/淡出 <see cref="panelGroup"/>。</item>
|
||||
/// <item><see cref="RunLineAsync"/>:逐字打字效果 + 点击推进。</item>
|
||||
/// <item><see cref="RunOptionsAsync"/>:显示选项按钮 + 等待玩家选择。</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 挂载位置:与 <see cref="DialogueRunner"/> 同一 GameObject(StoryDialogueRoot)。
|
||||
/// UI 引用字段需在 Inspector 中连接到 DialogPage 下的对应节点。
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class VNDialoguePresenter : DialoguePresenterBase
|
||||
{
|
||||
// ── UI 引用 ─────────────────────────────────────────────────────────────
|
||||
|
||||
[Header("面板控制")]
|
||||
[Tooltip("对话面板整体的 CanvasGroup(DialogPage/DialogCanvas)。用于淡入/淡出及交互开关。")]
|
||||
[SerializeField] private CanvasGroup panelGroup;
|
||||
|
||||
[Header("台词区")]
|
||||
[Tooltip("台词主体文本 TMP(DialogContentFrame/Background/Text)。")]
|
||||
[SerializeField] private TextMeshProUGUI dialogueText;
|
||||
|
||||
[Tooltip("说话者名称的父容器 GameObject(DialogContentFrame/Speaker)。无说话者时整体隐藏。")]
|
||||
[SerializeField] private GameObject speakerContainer;
|
||||
|
||||
[Tooltip("说话者名称 TMP(DialogContentFrame/Speaker/Text)。")]
|
||||
[SerializeField] private TextMeshProUGUI speakerText;
|
||||
|
||||
[Header("推进按钮")]
|
||||
[Tooltip("点击此按钮推进对话。\n" +
|
||||
" • 打字效果进行中 → 加速(RequestHurryUpLine)显示全文。\n" +
|
||||
" • 打字已完成 → 进入下一行(RequestNextLine)。\n" +
|
||||
"推荐:将场景中 DialogContentFrame/Background 或 BackButton 的 Button 拖入此处。")]
|
||||
[SerializeField] private Button advanceButton;
|
||||
|
||||
[Header("选项区")]
|
||||
[Tooltip("选项按钮容器根节点(ChoiceFrame)。有选项时显示,无选项时隐藏。")]
|
||||
[SerializeField] private GameObject choiceFrame;
|
||||
|
||||
[Tooltip("选项按钮数组(最多 4 个,对应 ChoiceFrame 下的四个 GameObject)。")]
|
||||
[SerializeField] private Button[] choiceButtons = new Button[4];
|
||||
|
||||
[Tooltip("每个选项按钮上的文本 TMP,与 choiceButtons 按索引一一对应。")]
|
||||
[SerializeField] private TextMeshProUGUI[] choiceTexts = new TextMeshProUGUI[4];
|
||||
|
||||
// ── 打字 / 淡入淡出设置 ──────────────────────────────────────────────────
|
||||
|
||||
[Header("打字效果")]
|
||||
[Tooltip("每秒显示的字符数。设为 0 则瞬间显示全文。")]
|
||||
[SerializeField] private float lettersPerSecond = 40f;
|
||||
|
||||
[Header("淡入淡出")]
|
||||
[SerializeField] private float fadeInDuration = 0.2f;
|
||||
[SerializeField] private float fadeOutDuration = 0.15f;
|
||||
|
||||
// ── 私有状态 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 当前行的打字效果是否已完成。
|
||||
/// 用于区分"加速打字(第一次点击)"与"推进到下一行(第二次点击)"两个阶段。
|
||||
/// </summary>
|
||||
private bool _typewriterComplete;
|
||||
|
||||
/// <summary>
|
||||
/// 选项选择的异步结果来源。RunOptionsAsync 等待其 Task 完成,
|
||||
/// 点击按钮时 TrySetResult 触发完成。
|
||||
/// </summary>
|
||||
private YarnTaskCompletionSource<DialogueOption?> _optionTcs;
|
||||
|
||||
/// <summary>
|
||||
/// 对应 StoryDialogueRoot 上的 DialogueRunner,用于调用 RequestNextLine / RequestHurryUpLine。
|
||||
/// 在 Awake 中通过 GetComponent 取得。
|
||||
/// </summary>
|
||||
private DialogueRunner _runner;
|
||||
|
||||
// ── 生命周期 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
// VNDialoguePresenter 需要与 DialogueRunner 同 GameObject,通过 GetComponent 获取引用。
|
||||
_runner = GetComponent<DialogueRunner>();
|
||||
if (_runner == null)
|
||||
Debug.LogWarning("[VNDialoguePresenter] 未在同 GameObject 上找到 DialogueRunner。" +
|
||||
"请确认 VNDialoguePresenter 挂载在 StoryDialogueRoot 上。");
|
||||
|
||||
// 启动时隐藏面板(alpha=0, 不响应交互)
|
||||
SetPanelState(visible: false, instant: true);
|
||||
|
||||
// 启动时隐藏选项容器
|
||||
if (choiceFrame != null) choiceFrame.SetActive(false);
|
||||
|
||||
// 注册推进按钮回调
|
||||
if (advanceButton != null)
|
||||
advanceButton.onClick.AddListener(OnAdvanceButtonClicked);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (advanceButton != null)
|
||||
advanceButton.onClick.RemoveListener(OnAdvanceButtonClicked);
|
||||
}
|
||||
|
||||
// ── DialoguePresenterBase 实现 ───────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 对话开始:将面板淡入到可见状态。
|
||||
/// 在 DialogueRunner 开始第一行前由 Yarn 调用。
|
||||
/// </summary>
|
||||
public override async YarnTask OnDialogueStartedAsync()
|
||||
{
|
||||
// 先令 CanvasGroup 可交互但 alpha=0(为淡入动画做准备)
|
||||
SetPanelState(visible: true, instant: false);
|
||||
|
||||
if (panelGroup != null)
|
||||
await Effects.FadeAlphaAsync(panelGroup, 0f, 1f, fadeInDuration, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 呈现一行台词。
|
||||
/// <list type="number">
|
||||
/// <item>隐藏选项区,显示说话者名称(若有)。</item>
|
||||
/// <item>逐字打字效果,期间可被 <c>HurryUpToken</c> 中断(显示全文)。</item>
|
||||
/// <item>等待玩家点击推进(<c>NextContentToken</c> 由 <see cref="DialogueRunner.RequestNextLine"/> 取消)。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public override async YarnTask RunLineAsync(LocalizedLine line, LineCancellationToken token)
|
||||
{
|
||||
// 确保选项区不可见;台词区始终可见
|
||||
if (choiceFrame != null) choiceFrame.SetActive(false);
|
||||
|
||||
// ── 说话者名称 ──
|
||||
bool hasSpeaker = !string.IsNullOrEmpty(line.CharacterName);
|
||||
if (speakerContainer != null) speakerContainer.SetActive(hasSpeaker);
|
||||
if (speakerText != null) speakerText.text = hasSpeaker ? line.CharacterName : string.Empty;
|
||||
|
||||
// ── 打字效果 ──
|
||||
string fullText = line.TextWithoutCharacterName.Text;
|
||||
_typewriterComplete = false;
|
||||
|
||||
if (dialogueText != null)
|
||||
{
|
||||
// 先将全文赋给 TMP,再通过 maxVisibleCharacters 逐步显示
|
||||
// (避免每帧 Substring 产生大量 GC 分配)
|
||||
dialogueText.text = fullText;
|
||||
dialogueText.maxVisibleCharacters = 0;
|
||||
}
|
||||
|
||||
// 逐字播放;若 HurryUpToken 被取消(玩家第一次点击)则立即跳出循环
|
||||
await TypewriterAsync(fullText, token.HurryUpToken);
|
||||
|
||||
// 确保全文可见(无论正常完成还是被加速中断)
|
||||
if (dialogueText != null)
|
||||
dialogueText.maxVisibleCharacters = int.MaxValue;
|
||||
|
||||
_typewriterComplete = true;
|
||||
|
||||
// ── 等待玩家推进 ──
|
||||
// token.NextContentToken 由外部 DialogueRunner.RequestNextLine() 取消,
|
||||
// SuppressCancellationThrow 确保不抛出 OperationCanceledException。
|
||||
await YarnTask.WaitUntilCanceled(token.NextContentToken).SuppressCancellationThrow();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 呈现选项列表,等待玩家选择。
|
||||
/// <list type="number">
|
||||
/// <item>显示选项容器,配置每个按钮的文本与点击监听。</item>
|
||||
/// <item>通过 <see cref="YarnTaskCompletionSource{T}"/> 异步等待玩家选择。</item>
|
||||
/// <item>返回玩家选择的 <see cref="DialogueOption"/>(对话被外部取消则返回 null)。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public override async YarnTask<DialogueOption?> RunOptionsAsync(
|
||||
DialogueOption[] options,
|
||||
LineCancellationToken token)
|
||||
{
|
||||
// 清空台词区,准备显示选项
|
||||
if (speakerContainer != null) speakerContainer.SetActive(false);
|
||||
if (dialogueText != null) dialogueText.text = string.Empty;
|
||||
|
||||
// 显示选项容器
|
||||
if (choiceFrame != null) choiceFrame.SetActive(true);
|
||||
|
||||
// 创建选项选择的异步结果来源
|
||||
_optionTcs = new YarnTaskCompletionSource<DialogueOption?>();
|
||||
|
||||
// 当 NextContentToken 被外部取消(对话被强制结束),提前将 TCS 置为 null 结果,
|
||||
// 使 await _optionTcs.Task 能正常退出而不死锁。
|
||||
WaitForExternalCancelAsync(token.NextContentToken).Forget();
|
||||
|
||||
// ── 配置每个选项按钮 ──
|
||||
for (int i = 0; i < choiceButtons.Length; i++)
|
||||
{
|
||||
// 只显示当前实际有效且可用的选项
|
||||
bool active = i < options.Length && options[i].IsAvailable;
|
||||
if (choiceButtons[i] != null)
|
||||
choiceButtons[i].gameObject.SetActive(active);
|
||||
|
||||
if (!active) continue;
|
||||
|
||||
// 设置按钮文本
|
||||
if (i < choiceTexts.Length && choiceTexts[i] != null)
|
||||
choiceTexts[i].text = options[i].Line.TextWithoutCharacterName.Text;
|
||||
|
||||
// 捕获循环变量,避免闭包捕获错误索引
|
||||
DialogueOption captured = options[i];
|
||||
choiceButtons[i].onClick.RemoveAllListeners();
|
||||
choiceButtons[i].onClick.AddListener(() => _optionTcs.TrySetResult(captured));
|
||||
}
|
||||
|
||||
// ── 等待玩家选择 ──
|
||||
DialogueOption? selected = await _optionTcs.Task;
|
||||
|
||||
// 清理按钮监听,隐藏选项容器
|
||||
foreach (Button btn in choiceButtons)
|
||||
if (btn != null) btn.onClick.RemoveAllListeners();
|
||||
|
||||
if (choiceFrame != null) choiceFrame.SetActive(false);
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 对话结束:淡出并隐藏对话面板。
|
||||
/// 在 DialogueRunner 处理完最后一行/选项后由 Yarn 调用。
|
||||
/// </summary>
|
||||
public override async YarnTask OnDialogueCompleteAsync()
|
||||
{
|
||||
if (panelGroup != null)
|
||||
await Effects.FadeAlphaAsync(panelGroup, 1f, 0f, fadeOutDuration, CancellationToken.None);
|
||||
|
||||
SetPanelState(visible: false, instant: true);
|
||||
}
|
||||
|
||||
// ── 内部方法 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 推进按钮点击回调。
|
||||
/// <list type="bullet">
|
||||
/// <item>打字中 → RequestHurryUpLine(取消 HurryUpToken,立即显示全文)。</item>
|
||||
/// <item>打字完毕 → RequestNextLine(取消 NextContentToken,进入下一行)。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private void OnAdvanceButtonClicked()
|
||||
{
|
||||
if (_runner == null) return;
|
||||
|
||||
if (!_typewriterComplete)
|
||||
_runner.RequestHurryUpLine(); // 第一次点击:跳过打字效果
|
||||
else
|
||||
_runner.RequestNextLine(); // 第二次点击:推进到下一行
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 控制对话面板 <see cref="panelGroup"/> 的显示状态。
|
||||
/// </summary>
|
||||
/// <param name="visible">是否可见且可交互。</param>
|
||||
/// <param name="instant">为 true 时立即设置 alpha;为 false 时仅设置 interactable/blocksRaycasts,
|
||||
/// alpha 留给外部的 <c>FadeAlphaAsync</c> 动画处理。</param>
|
||||
private void SetPanelState(bool visible, bool instant)
|
||||
{
|
||||
if (panelGroup == null) return;
|
||||
panelGroup.interactable = visible;
|
||||
panelGroup.blocksRaycasts = visible;
|
||||
if (instant) panelGroup.alpha = visible ? 1f : 0f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 逐字打字效果。
|
||||
/// 通过 <c>maxVisibleCharacters</c> 逐步显示文本(无 GC 分配)。
|
||||
/// 被 <paramref name="hurryUpToken"/> 取消时立即返回,调用方负责将 maxVisibleCharacters 设为 MaxValue。
|
||||
/// </summary>
|
||||
/// <param name="text">要逐字展示的完整文本(已预先赋值给 dialogueText.text)。</param>
|
||||
/// <param name="hurryUpToken">取消此 token 可跳过打字效果。</param>
|
||||
private async YarnTask TypewriterAsync(string text, CancellationToken hurryUpToken)
|
||||
{
|
||||
if (dialogueText == null) return;
|
||||
|
||||
// lettersPerSecond <= 0 时瞬间显示全文
|
||||
if (lettersPerSecond <= 0f)
|
||||
{
|
||||
dialogueText.maxVisibleCharacters = int.MaxValue;
|
||||
return;
|
||||
}
|
||||
|
||||
int delayMs = Mathf.Max(1, Mathf.RoundToInt(1000f / lettersPerSecond));
|
||||
|
||||
// TMP 的 maxVisibleCharacters 控制可见字符数(不含 rich-text 标签)
|
||||
// 逐帧递增直到全文显示或被 hurryUpToken 中断
|
||||
for (int i = 1; i <= text.Length; i++)
|
||||
{
|
||||
if (hurryUpToken.IsCancellationRequested) return;
|
||||
dialogueText.maxVisibleCharacters = i;
|
||||
await YarnTask.Delay(delayMs, hurryUpToken).SuppressCancellationThrow();
|
||||
if (hurryUpToken.IsCancellationRequested) return;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 监听外部取消信号(对话被强制终止),将 <see cref="_optionTcs"/> 设为 null 结果,
|
||||
/// 确保 <see cref="RunOptionsAsync"/> 的 await 能正常退出而不会死锁。
|
||||
/// </summary>
|
||||
private async YarnTask WaitForExternalCancelAsync(CancellationToken externalToken)
|
||||
{
|
||||
await YarnTask.WaitUntilCanceled(externalToken).SuppressCancellationThrow();
|
||||
_optionTcs?.TrySetResult(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b7b08c1a67d3d65478d9d9b4e275393f
|
||||
8
Assets/Scripts/NewStorySystem/Save.meta
Normal file
8
Assets/Scripts/NewStorySystem/Save.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5b59115c30ec8634887e285597b704c2
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
37
Assets/Scripts/NewStorySystem/Save/StorySave.cs
Normal file
37
Assets/Scripts/NewStorySystem/Save/StorySave.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Ichni.Story
|
||||
{
|
||||
/// <summary>
|
||||
/// 单章节的存档容器。
|
||||
/// <para>故事树的布局与连接完全由 <see cref="StoryData"/> 静态定义,故存档不再保存
|
||||
/// block 位置与连接线;进入章节时按"已完成集合 + 解锁条件"实时推导每个 block 的
|
||||
/// <see cref="StoryBlockState"/>(Completed / Current / Locked)。</para>
|
||||
/// <para>以单个 ES3 key 存储,按章节分文件,一次读写即可加载/保存整章进度。</para>
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class ChapterStorySave
|
||||
{
|
||||
public string chapterIndex;
|
||||
|
||||
// 已完成 block 的 id 列表(顺序无关,去重由 StoryTreeController 保证)
|
||||
public List<string> completedBlockIds = new();
|
||||
|
||||
// 章节内的对话选项记录(choiceKey -> 选中项索引)。选项不跨章节引用。
|
||||
public Dictionary<string, int> selectedChoices = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 全局剧情变量存档。float / string / bool 三类型与 Yarn Spinner 的
|
||||
/// <c>VariableStorageBehaviour.GetAllVariables/SetAllVariables</c> 契约一致,
|
||||
/// 供阶段 2 的 StoryVariableStorage 直接读写。
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class StoryVariablesSave
|
||||
{
|
||||
public Dictionary<string, float> floatVariables = new();
|
||||
public Dictionary<string, string> stringVariables = new();
|
||||
public Dictionary<string, bool> boolVariables = new();
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/NewStorySystem/Save/StorySave.cs.meta
Normal file
2
Assets/Scripts/NewStorySystem/Save/StorySave.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ca9f4aa0dff5c774baf0eaab0d404115
|
||||
149
Assets/Scripts/NewStorySystem/Save/StorySaveModule.cs
Normal file
149
Assets/Scripts/NewStorySystem/Save/StorySaveModule.cs
Normal file
@@ -0,0 +1,149 @@
|
||||
using System.Collections.Generic;
|
||||
using Ichni.Menu;
|
||||
using Ichni.Story;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ichni
|
||||
{
|
||||
/// <summary>
|
||||
/// 剧情存档模块。
|
||||
/// <para>故事树状态与对话选项 <b>按章节</b> 保存(单文件、单 ES3 key)。</para>
|
||||
/// <para>剧情变量 <b>全局</b> 保存,可跨章节引用,契合 Yarn 变量模型。</para>
|
||||
/// </summary>
|
||||
public class StorySaveModule
|
||||
{
|
||||
private const string ChapterStoryKey = "ChapterStory";
|
||||
private const string VariablesKey = "StoryVariables";
|
||||
|
||||
// 已加载章节的内存缓存(chapterIndex -> 章节存档)
|
||||
private readonly Dictionary<string, ChapterStorySave> _chapterSaves =
|
||||
new Dictionary<string, ChapterStorySave>();
|
||||
|
||||
// 全局剧情变量(float / string / bool 三类型,匹配 Yarn 契约)
|
||||
public StoryVariablesSave variables = new StoryVariablesSave();
|
||||
|
||||
private static string GetChapterSavePath(string chapterIndex) =>
|
||||
Application.persistentDataPath + "/StorySaves/" + chapterIndex + ".json";
|
||||
|
||||
private static string VariablesPath =>
|
||||
Application.persistentDataPath + "/StorySaves/StoryVariables.json";
|
||||
|
||||
// ── 故事树 + 选项(按章节)────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 判断某章节是否尚无存档(首次进入)。
|
||||
/// </summary>
|
||||
public bool IsNewChapter(string chapterIndex) =>
|
||||
!ES3.FileExists(GetChapterSavePath(chapterIndex));
|
||||
|
||||
/// <summary>
|
||||
/// 加载指定章节的存档容器;无存档时返回一个新的空容器。加载结果会写入内存缓存。
|
||||
/// </summary>
|
||||
public ChapterStorySave LoadChapter(string chapterIndex)
|
||||
{
|
||||
string path = GetChapterSavePath(chapterIndex);
|
||||
|
||||
ChapterStorySave save;
|
||||
if (ES3.FileExists(path) && ES3.KeyExists(ChapterStoryKey, path))
|
||||
{
|
||||
save = ES3.Load<ChapterStorySave>(ChapterStoryKey, path);
|
||||
}
|
||||
else
|
||||
{
|
||||
save = new ChapterStorySave { chapterIndex = chapterIndex };
|
||||
}
|
||||
|
||||
_chapterSaves[chapterIndex] = save;
|
||||
return save;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存整章存档(blocks + connectors + choices),单次 ES3 写入。
|
||||
/// </summary>
|
||||
public void SaveChapter(ChapterStorySave save)
|
||||
{
|
||||
_chapterSaves[save.chapterIndex] = save;
|
||||
ES3.Save(ChapterStoryKey, save, GetChapterSavePath(save.chapterIndex));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取内存中的章节存档;若未加载则从磁盘加载。
|
||||
/// </summary>
|
||||
public ChapterStorySave GetChapter(string chapterIndex)
|
||||
{
|
||||
return _chapterSaves.TryGetValue(chapterIndex, out ChapterStorySave save)
|
||||
? save
|
||||
: LoadChapter(chapterIndex);
|
||||
}
|
||||
|
||||
// ── 对话选项(按章节)──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 记录某章节内一次对话选项的选择结果。
|
||||
/// </summary>
|
||||
public void SetChoice(string chapterIndex, string choiceKey, int optionIndex)
|
||||
{
|
||||
GetChapter(chapterIndex).selectedChoices[choiceKey] = optionIndex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取某章节内已记录的对话选项选择结果。
|
||||
/// </summary>
|
||||
public bool TryGetChoice(string chapterIndex, string choiceKey, out int optionIndex)
|
||||
{
|
||||
return GetChapter(chapterIndex).selectedChoices.TryGetValue(choiceKey, out optionIndex);
|
||||
}
|
||||
|
||||
// ── 剧情变量(全局)──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 从磁盘加载全局剧情变量(启动时调用)。
|
||||
/// </summary>
|
||||
public void LoadVariables()
|
||||
{
|
||||
if (ES3.FileExists(VariablesPath) && ES3.KeyExists(VariablesKey, VariablesPath))
|
||||
{
|
||||
variables = ES3.Load<StoryVariablesSave>(VariablesKey, VariablesPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
variables = new StoryVariablesSave();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将全局剧情变量写入磁盘。
|
||||
/// </summary>
|
||||
public void SaveVariables()
|
||||
{
|
||||
ES3.Save(VariablesKey, variables, VariablesPath);
|
||||
}
|
||||
|
||||
// ── 清档 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 清除所有章节的故事树存档与全局剧情变量。
|
||||
/// </summary>
|
||||
public void ClearAllStoryline()
|
||||
{
|
||||
if (ChapterSelectionManager.instance != null)
|
||||
{
|
||||
foreach (ChapterSelectionUnit chapter in ChapterSelectionManager.instance.chapters)
|
||||
{
|
||||
string path = GetChapterSavePath(chapter.chapterIndex);
|
||||
if (ES3.FileExists(path))
|
||||
ES3.DeleteFile(path);
|
||||
}
|
||||
}
|
||||
|
||||
_chapterSaves.Clear();
|
||||
|
||||
if (ES3.FileExists(VariablesPath))
|
||||
ES3.DeleteFile(VariablesPath);
|
||||
|
||||
variables = new StoryVariablesSave();
|
||||
|
||||
Debug.Log("Cleared all story saves (chapters + global variables).");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3e133cbb328fd394f820d7bc4136ffd7
|
||||
8
Assets/Scripts/NewStorySystem/Tree.meta
Normal file
8
Assets/Scripts/NewStorySystem/Tree.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f3a0060903a9da947ae9283956518086
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
81
Assets/Scripts/NewStorySystem/Tree/BlockConnectorView.cs
Normal file
81
Assets/Scripts/NewStorySystem/Tree/BlockConnectorView.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI.Extensions;
|
||||
|
||||
namespace Ichni.Story.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 故事树中两个 block 之间的连接线视图,使用 UILineRenderer 绘制 S 形曲线。
|
||||
/// </summary>
|
||||
public class BlockConnectorView : MonoBehaviour
|
||||
{
|
||||
// 判定两端口坐标是否塌缩为同一点的阈值(像素)。
|
||||
private const float DegenerateDistanceThreshold = 1f;
|
||||
|
||||
public UILineRenderer curve;
|
||||
public StoryBlockView startBlock;
|
||||
public StoryBlockView endBlock;
|
||||
|
||||
/// <summary>
|
||||
/// 设置连接线的起止 block 并重算曲线点。传入 null 时保留已有引用。
|
||||
/// 注意:必须在 block 的 RectTransform 完成布局(Canvas 已刷新)后调用,
|
||||
/// 否则端口世界坐标尚未生效,会导致所有顶点塌缩到同一点。
|
||||
/// </summary>
|
||||
public void SetCurve(StoryBlockView start = null, StoryBlockView end = null)
|
||||
{
|
||||
if (start != null) startBlock = start;
|
||||
if (end != null) endBlock = end;
|
||||
|
||||
if (startBlock == null || endBlock == null)
|
||||
{
|
||||
Debug.LogWarning("[BlockConnectorView] 起点或终点 block 未设置,无法绘制曲线。");
|
||||
return;
|
||||
}
|
||||
|
||||
RectTransform selfRect = (RectTransform)transform;
|
||||
|
||||
// ScreenSpaceCamera / WorldSpace 画布下的世界→屏幕转换需要对应相机;Overlay 传 null。
|
||||
Camera uiCamera = ResolveUICamera();
|
||||
|
||||
Vector2 startPosition = GetLocalPoint(startBlock.outPort, selfRect, uiCamera);
|
||||
Vector2 endPosition = GetLocalPoint(endBlock.inPort, selfRect, uiCamera);
|
||||
|
||||
if (Vector2.Distance(startPosition, endPosition) < DegenerateDistanceThreshold)
|
||||
{
|
||||
Debug.LogWarning(
|
||||
$"[BlockConnectorView] '{startBlock.blockId}' -> '{endBlock.blockId}' 端口坐标塌缩为同一点 " +
|
||||
$"(start={startPosition}, end={endPosition}),通常是在 block 布局完成前调用了 SetCurve。");
|
||||
}
|
||||
|
||||
// 两个控制点构成 S 形:先水平离开起点,再水平进入终点。
|
||||
float midX = (startPosition.x + endPosition.x) / 2f;
|
||||
Vector2 mid1 = new Vector2(midX, startPosition.y);
|
||||
Vector2 mid2 = new Vector2(midX, endPosition.y);
|
||||
|
||||
curve.Points = new[] { startPosition, mid1, mid2, endPosition };
|
||||
curve.SetVerticesDirty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用 <see cref="RectTransformUtility"/> 将 <paramref name="port"/> 的世界位置
|
||||
/// 转换到 <paramref name="space"/> 的局部坐标(相对 pivot),即 UILineRenderer 顶点所用的坐标系。
|
||||
/// </summary>
|
||||
private static Vector2 GetLocalPoint(RectTransform port, RectTransform space, Camera uiCamera)
|
||||
{
|
||||
Vector2 screenPoint = RectTransformUtility.WorldToScreenPoint(uiCamera, port.position);
|
||||
RectTransformUtility.ScreenPointToLocalPointInRectangle(space, screenPoint, uiCamera, out Vector2 localPoint);
|
||||
return localPoint;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析用于坐标转换的相机:Overlay 画布返回 null,其余(ScreenSpaceCamera / WorldSpace)返回画布相机。
|
||||
/// </summary>
|
||||
private Camera ResolveUICamera()
|
||||
{
|
||||
Canvas canvas = curve != null ? curve.canvas : GetComponentInParent<Canvas>();
|
||||
if (canvas == null || canvas.renderMode == RenderMode.ScreenSpaceOverlay)
|
||||
return null;
|
||||
|
||||
return canvas.worldCamera;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9d179a524aa28d74f9ae667fd78aed20
|
||||
28
Assets/Scripts/NewStorySystem/Tree/SongBlockView.cs
Normal file
28
Assets/Scripts/NewStorySystem/Tree/SongBlockView.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ichni.Story.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 音乐块视图(占位)。点击后将切换到曲目选择并自动选中对应歌曲,后续阶段接入。
|
||||
/// </summary>
|
||||
public class SongBlockView : StoryBlockView
|
||||
{
|
||||
[Header("Song Block")]
|
||||
public TMP_Text songNameText;
|
||||
|
||||
public override void Initialize(StoryBlockDefinition def, Vector2 position, StoryBlockState blockState)
|
||||
{
|
||||
base.Initialize(def, position, blockState);
|
||||
|
||||
if (songNameText != null)
|
||||
songNameText.text = def.songName;
|
||||
}
|
||||
|
||||
protected override void OnClicked()
|
||||
{
|
||||
// 占位:后续接入曲目选择流程
|
||||
Debug.Log($"[SongBlockView] 点击音乐块 '{blockId}' (song='{definition.songName}')。占位实现,曲目选择将在后续阶段接入。");
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/NewStorySystem/Tree/SongBlockView.cs.meta
Normal file
2
Assets/Scripts/NewStorySystem/Tree/SongBlockView.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ad764389c6c056c4d84cdf0155d7e7aa
|
||||
80
Assets/Scripts/NewStorySystem/Tree/StoryBlockView.cs
Normal file
80
Assets/Scripts/NewStorySystem/Tree/StoryBlockView.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using Sirenix.OdinInspector;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Ichni.Story.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 故事树中单个 block 的视图基类。负责通用的定位、状态视觉与点击分发。
|
||||
/// 具体类型(文本 / 音乐 / 教程)由子类实现 <see cref="OnClicked"/>。
|
||||
/// </summary>
|
||||
public abstract class StoryBlockView : SerializedMonoBehaviour
|
||||
{
|
||||
[Header("Block State")]
|
||||
public string blockId;
|
||||
public StoryBlockState state;
|
||||
|
||||
[Header("Common References")]
|
||||
public RectTransform blockRect;
|
||||
public RectTransform inPort;
|
||||
public RectTransform outPort;
|
||||
public Button button;
|
||||
|
||||
// 关联的定义,供子类读取类型专属字段
|
||||
protected StoryBlockDefinition definition;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化 block 视图。逻辑坐标已由控制器按网格换算为 <paramref name="position"/>
|
||||
/// (以 BlockContainer 左侧中线为原点、x 向右为正、y 向下为正的 anchoredPosition)。
|
||||
/// </summary>
|
||||
/// <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)
|
||||
{
|
||||
definition = def;
|
||||
blockId = def.blockId;
|
||||
state = blockState;
|
||||
|
||||
// 统一坐标约定:以 BlockContainer 的左侧中线为原点,向右为 +x、向下为 +y。
|
||||
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.anchoredPosition = position;
|
||||
|
||||
if (button != null)
|
||||
button.onClick.AddListener(HandleClick);
|
||||
|
||||
ApplyState(state);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 应用状态并更新视觉。基类默认按状态切换按钮可交互性;
|
||||
/// 子类可 override 以更新锁定图标、颜色等具体表现。
|
||||
/// </summary>
|
||||
public virtual void ApplyState(StoryBlockState newState)
|
||||
{
|
||||
state = newState;
|
||||
|
||||
if (button != null)
|
||||
button.interactable = state != StoryBlockState.Locked;
|
||||
}
|
||||
|
||||
private void HandleClick()
|
||||
{
|
||||
if (state == StoryBlockState.Locked)
|
||||
return;
|
||||
|
||||
if (StoryManager.instance != null && StoryManager.instance.treeController != null)
|
||||
StoryManager.instance.treeController.currentBlock = this;
|
||||
|
||||
OnClicked();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 点击(非锁定状态)时的具体行为,由子类实现。
|
||||
/// </summary>
|
||||
protected abstract void OnClicked();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 74cc3bad2b6adeb4b93cd509340a23be
|
||||
56
Assets/Scripts/NewStorySystem/Tree/StoryManager.cs
Normal file
56
Assets/Scripts/NewStorySystem/Tree/StoryManager.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System.Collections.Generic;
|
||||
using Ichni.Story.UI;
|
||||
using Sirenix.OdinInspector;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ichni.Story
|
||||
{
|
||||
/// <summary>
|
||||
/// 剧情系统协调器(单例)。持有故事树控制器、剧情页面与章节数据引用,
|
||||
/// 是外部系统(章节选择、存档等)与剧情系统交互的入口。
|
||||
/// </summary>
|
||||
public class StoryManager : SerializedMonoBehaviour
|
||||
{
|
||||
public static StoryManager instance;
|
||||
|
||||
[Header("References")]
|
||||
public StoryTreeController treeController;
|
||||
public StoryUIPage storyUIPage;
|
||||
public CharacterRegistry characterRegistry;
|
||||
|
||||
[Header("Chapter Data")]
|
||||
[Tooltip("章节索引 (chapterIndex) -> 该章节的 StoryData 资产")]
|
||||
public Dictionary<string, StoryData> storyDatas = new Dictionary<string, StoryData>();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
instance = this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打开指定章节,构建其故事树。
|
||||
/// </summary>
|
||||
public void OpenChapter(string chapterIndex)
|
||||
{
|
||||
treeController.BuildChapter(chapterIndex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据章节索引获取对应的 StoryData,未找到返回 null。
|
||||
/// </summary>
|
||||
public StoryData GetStoryData(string chapterIndex)
|
||||
{
|
||||
return storyDatas.TryGetValue(chapterIndex, out StoryData data) ? data : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清除全部剧情存档(所有章节故事树 + 全局变量 + 剧情解锁 key)。
|
||||
/// </summary>
|
||||
[Button]
|
||||
public void ClearAllStorySave()
|
||||
{
|
||||
GameSaveManager.instance.StorySaveModule.ClearAllStoryline();
|
||||
GameSaveManager.instance.SongSaveModule.ClearStoryKeys();
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/NewStorySystem/Tree/StoryManager.cs.meta
Normal file
2
Assets/Scripts/NewStorySystem/Tree/StoryManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a26cc5f52c5322d46b18240eb73c0a62
|
||||
390
Assets/Scripts/NewStorySystem/Tree/StoryTreeController.cs
Normal file
390
Assets/Scripts/NewStorySystem/Tree/StoryTreeController.cs
Normal file
@@ -0,0 +1,390 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Ichni.Story.UI;
|
||||
using Sirenix.OdinInspector;
|
||||
using UniRx;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ichni.Story
|
||||
{
|
||||
/// <summary>
|
||||
/// 故事树控制器。从 <see cref="StoryData"/> 一次性全量构建所有 block 视图与连接线,
|
||||
/// 布局由每个 block 的 (gridColumn, gridRow) 静态决定;每个 block 的
|
||||
/// <see cref="StoryBlockState"/> 由"已完成集合 + 解锁条件"实时推导。
|
||||
/// 存档只保存已完成 block 的 id 集合。
|
||||
/// </summary>
|
||||
public class StoryTreeController : MonoBehaviour
|
||||
{
|
||||
[Header("UI References")]
|
||||
[Tooltip("blocks 与 connectors 共用的单一容器(即 ScrollRect 的 content,localScale 必须为 1)")]
|
||||
public RectTransform content;
|
||||
public GameObject textBlockPrefab;
|
||||
public GameObject songBlockPrefab;
|
||||
public GameObject tutorialBlockPrefab;
|
||||
public GameObject connectorPrefab;
|
||||
|
||||
[Header("Layout Settings")]
|
||||
[Tooltip("网格单元格尺寸(决定相邻列/行的步距,全局统一)")]
|
||||
public Vector2 cellSize = new Vector2(400f, 200f);
|
||||
|
||||
[Tooltip("相邻单元格之间的间隔(水平 x、垂直 y)")]
|
||||
public Vector2 cellSpacing = new Vector2(120f, 150f);
|
||||
|
||||
public float marginLeft = 50f;
|
||||
public float marginRight = 50f;
|
||||
public float marginTop = 50f;
|
||||
public float marginBottom = 50f;
|
||||
public float minContentWidth = 2560f;
|
||||
public float minContentHeight = 1440f;
|
||||
|
||||
[Header("Runtime")]
|
||||
public StoryBlockView currentBlock;
|
||||
public List<StoryBlockView> blocks = new List<StoryBlockView>();
|
||||
public List<BlockConnectorView> connectors = new List<BlockConnectorView>();
|
||||
|
||||
private string _chapterIndex;
|
||||
private StoryData _storyData;
|
||||
|
||||
/// <summary>当前已构建章节的 StoryData(供对话控制器切换 YarnProject 等使用)。</summary>
|
||||
public StoryData ActiveStoryData => _storyData;
|
||||
|
||||
/// <summary>当前已构建章节的索引。</summary>
|
||||
public string ActiveChapterIndex => _chapterIndex;
|
||||
|
||||
// 当前章节已完成的 block id 集合(状态推导的唯一进度来源)
|
||||
private readonly HashSet<string> _completed = new HashSet<string>();
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// 存档系统未就绪时跳过;正式流程由 StoryManager.OpenChapter 在进入剧情页时触发构建。
|
||||
if (GameSaveManager.instance == null || GameSaveManager.instance.StorySaveModule == null)
|
||||
return;
|
||||
|
||||
if (ChapterSelectionManager.instance != null &&
|
||||
ChapterSelectionManager.instance.currentChapter != null)
|
||||
{
|
||||
BuildChapter(ChapterSelectionManager.instance.currentChapter.chapterIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 构建指定章节的故事树:全量生成所有 block 与连接线,状态由存档中的已完成集合推导。
|
||||
/// </summary>
|
||||
public void BuildChapter(string chapterIndex)
|
||||
{
|
||||
_chapterIndex = chapterIndex;
|
||||
_storyData = StoryManager.instance != null ? StoryManager.instance.GetStoryData(chapterIndex) : null;
|
||||
|
||||
if (_storyData == null)
|
||||
{
|
||||
Debug.LogError($"[StoryTreeController] 找不到章节 '{chapterIndex}' 的 StoryData,请在 StoryManager.storyDatas 中登记。");
|
||||
return;
|
||||
}
|
||||
|
||||
if (GameSaveManager.instance == null || GameSaveManager.instance.StorySaveModule == null)
|
||||
{
|
||||
Debug.LogWarning("[StoryTreeController] 存档系统尚未就绪(GameSaveManager.StorySaveModule 为空),跳过构建。");
|
||||
return;
|
||||
}
|
||||
|
||||
ClearTree();
|
||||
|
||||
// 载入已完成集合(新章节为空)
|
||||
ChapterStorySave save = GameSaveManager.instance.StorySaveModule.GetChapter(chapterIndex);
|
||||
_completed.Clear();
|
||||
foreach (string id in save.completedBlockIds)
|
||||
_completed.Add(id);
|
||||
|
||||
// 全量生成所有 block(位置按网格换算,状态按已完成集合 + 解锁条件推导)
|
||||
foreach (StoryBlockDefinition def in _storyData.blocks)
|
||||
GenerateBlock(def);
|
||||
|
||||
// 全量生成所有连接线
|
||||
foreach (StoryBlockDefinition def in _storyData.blocks)
|
||||
foreach (string nextId in def.nextBlockIds)
|
||||
GenerateConnector(def.blockId, nextId);
|
||||
|
||||
SetUpBackground();
|
||||
|
||||
// 连线在下一帧刷新(见 RefreshConnectors):等待 Unity 完成 Canvas 布局,
|
||||
// 使 block 端口的世界坐标稳定,避免端口坐标尚未生效时塌缩到同一点。
|
||||
RefreshConnectors();
|
||||
}
|
||||
|
||||
// ── Generation ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 按定义生成一个 block 视图:位置由网格换算,状态由推导得出。
|
||||
/// </summary>
|
||||
public StoryBlockView GenerateBlock(StoryBlockDefinition def)
|
||||
{
|
||||
GameObject prefab = def.blockType switch
|
||||
{
|
||||
StoryBlockType.Text => textBlockPrefab,
|
||||
StoryBlockType.Song => songBlockPrefab,
|
||||
StoryBlockType.Tutorial => tutorialBlockPrefab,
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (prefab == null)
|
||||
{
|
||||
Debug.LogError($"[StoryTreeController] block 类型 {def.blockType} 未配置预制体。");
|
||||
return null;
|
||||
}
|
||||
|
||||
StoryBlockView view = Instantiate(prefab, content).GetComponent<StoryBlockView>();
|
||||
if (view == null)
|
||||
{
|
||||
Debug.LogError($"[StoryTreeController] 预制体 '{prefab.name}' 缺少 StoryBlockView 组件。");
|
||||
return null;
|
||||
}
|
||||
|
||||
view.Initialize(def, GetGridPosition(def), DeriveState(def));
|
||||
blocks.Add(view);
|
||||
return view;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在两个已存在的 block 之间生成连接线。
|
||||
/// </summary>
|
||||
public void GenerateConnector(string fromBlockId, string toBlockId)
|
||||
{
|
||||
StoryBlockView from = GetBlockView(fromBlockId);
|
||||
StoryBlockView to = GetBlockView(toBlockId);
|
||||
|
||||
if (from == null || to == null)
|
||||
{
|
||||
Debug.LogWarning($"[StoryTreeController] 无法连线:'{fromBlockId}' -> '{toBlockId}'(block 不存在)。");
|
||||
return;
|
||||
}
|
||||
|
||||
// 连接线与 block 同处一个容器(content);置为第一个子物体,渲染在所有 block 之后方。
|
||||
GameObject connectorObject = Instantiate(connectorPrefab, content);
|
||||
connectorObject.transform.SetAsFirstSibling();
|
||||
|
||||
BlockConnectorView connector = connectorObject.GetComponent<BlockConnectorView>();
|
||||
if (connector == null)
|
||||
{
|
||||
Debug.LogError($"[StoryTreeController] 连接线预制体 '{connectorPrefab.name}' 缺少 BlockConnectorView 组件。");
|
||||
Destroy(connectorObject);
|
||||
return;
|
||||
}
|
||||
|
||||
// 仅记录起止 block 引用;曲线在布局刷新后由 RefreshConnectors 统一计算。
|
||||
connector.startBlock = from;
|
||||
connector.endBlock = to;
|
||||
connectors.Add(connector);
|
||||
}
|
||||
|
||||
// ── State / Unlocking ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 依据"已完成集合 + 解锁条件"推导单个 block 的状态。
|
||||
/// 已完成 → Completed;否则满足解锁条件 → Current;否则 → Locked。
|
||||
/// 解锁条件为空视为无条件满足(章节起始即为 Current)。
|
||||
/// </summary>
|
||||
private StoryBlockState DeriveState(StoryBlockDefinition def)
|
||||
{
|
||||
if (_completed.Contains(def.blockId))
|
||||
return StoryBlockState.Completed;
|
||||
|
||||
if (def.unlockCondition.IsSatisfied(GetVariable, IsBlockCompleted))
|
||||
return StoryBlockState.Current;
|
||||
|
||||
return StoryBlockState.Locked;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重新推导并应用所有 block 的状态(在完成某 block 或变量变化后调用)。
|
||||
/// </summary>
|
||||
public void RefreshAllStates()
|
||||
{
|
||||
foreach (StoryBlockView view in blocks)
|
||||
{
|
||||
StoryBlockDefinition def = _storyData.GetBlock(view.blockId);
|
||||
if (def == null) continue;
|
||||
|
||||
view.ApplyState(DeriveState(def));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 标记某 block 完成,推导重算并保存。对话系统(阶段 2)在对话结束时调用。
|
||||
/// </summary>
|
||||
public void OnBlockCompleted(string blockId)
|
||||
{
|
||||
if (!_completed.Add(blockId))
|
||||
return; // 已完成,无需处理
|
||||
|
||||
RefreshAllStates();
|
||||
SaveChapter();
|
||||
}
|
||||
|
||||
private bool IsBlockCompleted(string blockId) => _completed.Contains(blockId);
|
||||
|
||||
// 阶段 2 将改由 StoryVariableStorage 提供;此处从全局变量存档读取作占位
|
||||
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;
|
||||
}
|
||||
|
||||
// ── Persistence ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 保存当前章节进度(已完成 block 集合)。选项由对话系统维护、不在此覆盖。
|
||||
/// </summary>
|
||||
[Button]
|
||||
public void SaveChapter()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_chapterIndex)) return;
|
||||
|
||||
ChapterStorySave save = GameSaveManager.instance.StorySaveModule.GetChapter(_chapterIndex);
|
||||
save.chapterIndex = _chapterIndex;
|
||||
save.completedBlockIds = _completed.ToList();
|
||||
|
||||
GameSaveManager.instance.StorySaveModule.SaveChapter(save);
|
||||
}
|
||||
|
||||
// ── Layout ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 将 block 的网格坐标 (gridColumn, gridRow) 换算为 BlockContainer 中的 anchoredPosition。
|
||||
/// 约定左侧中线为原点:列向右为正,行向下为正、向上为负(anchoredPosition.y 取负)。
|
||||
/// gridColumn / gridRow 支持负数与小数,可用于微调 block 位置。
|
||||
/// </summary>
|
||||
private Vector2 GetGridPosition(StoryBlockDefinition def)
|
||||
{
|
||||
float x = marginLeft + def.gridColumn * (cellSize.x + cellSpacing.x);
|
||||
float y = -def.gridRow * (cellSize.y + cellSpacing.y);
|
||||
return new Vector2(x, y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据 block 分布计算并设置 content(BlockContainer) 尺寸。
|
||||
/// block 采用左中 pivot:anchoredPosition.x 为左边缘,y 相对垂直中线(上正下负)。
|
||||
/// </summary>
|
||||
public void SetUpBackground()
|
||||
{
|
||||
if (blocks.Count == 0)
|
||||
{
|
||||
content.sizeDelta = new Vector2(minContentWidth, minContentHeight);
|
||||
return;
|
||||
}
|
||||
|
||||
float maxRight = 0f;
|
||||
float maxVertical = 0f; // 距垂直中线的最大延伸(上下取较大者,用于对称扩展 content 高度)
|
||||
|
||||
foreach (StoryBlockView block in blocks)
|
||||
{
|
||||
RectTransform rect = block.blockRect;
|
||||
Vector2 pos = rect.anchoredPosition;
|
||||
Vector2 size = rect.sizeDelta;
|
||||
|
||||
float rightEdge = pos.x + size.x;
|
||||
float topExtent = pos.y + size.y * 0.5f;
|
||||
float bottomExtent = -(pos.y - size.y * 0.5f);
|
||||
|
||||
if (rightEdge > maxRight) maxRight = rightEdge;
|
||||
if (topExtent > maxVertical) maxVertical = topExtent;
|
||||
if (bottomExtent > maxVertical) maxVertical = bottomExtent;
|
||||
}
|
||||
|
||||
float width = Mathf.Max(maxRight + marginRight, minContentWidth);
|
||||
float height = Mathf.Max(maxVertical * 2f + marginTop + marginBottom, minContentHeight);
|
||||
|
||||
content.sizeDelta = new Vector2(width, height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重算所有连接线曲线(在 block 位置确定后调用)。
|
||||
/// </summary>
|
||||
public void RefreshConnectors()
|
||||
{
|
||||
Observable.NextFrame().Subscribe(_ =>
|
||||
{
|
||||
foreach (BlockConnectorView connector in connectors)
|
||||
{
|
||||
connector.SetCurve();
|
||||
}
|
||||
}).AddTo(this);
|
||||
}
|
||||
|
||||
// ── Helpers / Debug ─────────────────────────────────────────────────────
|
||||
|
||||
public StoryBlockView GetBlockView(string blockId) =>
|
||||
blocks.FirstOrDefault(b => b.blockId == blockId);
|
||||
|
||||
private void ClearTree()
|
||||
{
|
||||
foreach (StoryBlockView block in blocks)
|
||||
if (block != null) Destroy(block.gameObject);
|
||||
blocks.Clear();
|
||||
|
||||
foreach (BlockConnectorView connector in connectors)
|
||||
if (connector != null) Destroy(connector.gameObject);
|
||||
connectors.Clear();
|
||||
|
||||
if (content != null)
|
||||
content.sizeDelta = Vector2.zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 编辑器测试用:以当前章节重新构建故事树。
|
||||
/// </summary>
|
||||
[Button]
|
||||
public void DebugBuildCurrentChapter()
|
||||
{
|
||||
if (ChapterSelectionManager.instance != null &&
|
||||
ChapterSelectionManager.instance.currentChapter != null)
|
||||
{
|
||||
BuildChapter(ChapterSelectionManager.instance.currentChapter.chapterIndex);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning("[StoryTreeController] 当前没有选中的章节。");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 编辑器测试用:模拟完成当前选中的 block(验证解锁重算与存档),阶段 2 前用于替代对话。
|
||||
/// </summary>
|
||||
[Button]
|
||||
public void DebugCompleteCurrentBlock()
|
||||
{
|
||||
if (currentBlock == null)
|
||||
{
|
||||
Debug.LogWarning("[StoryTreeController] currentBlock 为空,请先点击一个 block。");
|
||||
return;
|
||||
}
|
||||
|
||||
OnBlockCompleted(currentBlock.blockId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 编辑器测试用:清空当前章节进度并重建(全部回到未完成的初始推导状态)。
|
||||
/// </summary>
|
||||
[Button]
|
||||
public void DebugResetChapterProgress()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_chapterIndex))
|
||||
{
|
||||
Debug.LogWarning("[StoryTreeController] 尚未构建任何章节。");
|
||||
return;
|
||||
}
|
||||
|
||||
_completed.Clear();
|
||||
SaveChapter();
|
||||
BuildChapter(_chapterIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7b00483ab980b3248a62618eeb0bcb4b
|
||||
95
Assets/Scripts/NewStorySystem/Tree/TextBlockView.cs
Normal file
95
Assets/Scripts/NewStorySystem/Tree/TextBlockView.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
using System.Collections.Generic;
|
||||
using Ichni.Story.Dialogue;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ichni.Story.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 文本块视图。点击后(阶段 2)将通过 Yarn Spinner 进入对应节点的对话。
|
||||
/// </summary>
|
||||
public class TextBlockView : StoryBlockView
|
||||
{
|
||||
[Header("Text Block")]
|
||||
public TMP_Text storyIdText;
|
||||
public TMP_Text titleText;
|
||||
|
||||
[Tooltip("三种状态对应的背景预制体,生成 block 时按当前状态实例化为第一个子物体")]
|
||||
public Dictionary<StoryBlockState, GameObject> blockVisuals = new Dictionary<StoryBlockState, GameObject>();
|
||||
|
||||
/// <summary>该文本块对应的 Yarn 节点名。</summary>
|
||||
public string YarnNodeName { get; private set; }
|
||||
|
||||
// 当前已实例化的状态背景对象;状态切换时销毁并替换
|
||||
private GameObject _currentVisual;
|
||||
|
||||
public override void Initialize(StoryBlockDefinition def, Vector2 position, StoryBlockState blockState)
|
||||
{
|
||||
// 注意:base.Initialize 末尾会调用 ApplyState(state),届时已生成对应背景
|
||||
base.Initialize(def, position, blockState);
|
||||
|
||||
YarnNodeName = def.yarnNodeName;
|
||||
|
||||
if (storyIdText != null)
|
||||
storyIdText.text = def.blockId;
|
||||
|
||||
// 阶段 5 将接入 Unity Localization;此处占位直接显示 titleKey
|
||||
if (titleText != null)
|
||||
titleText.text = def.titleKey;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 应用状态:在基类逻辑(按钮可交互性)之外,切换到对应状态的背景视觉。
|
||||
/// </summary>
|
||||
public override void ApplyState(StoryBlockState newState)
|
||||
{
|
||||
base.ApplyState(newState);
|
||||
UpdateVisual(newState);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按当前状态实例化对应的背景预制体,作为第一个子物体(位于文本/端口/按钮之下),
|
||||
/// 并拉伸铺满整个 block。状态切换时先销毁旧背景。
|
||||
/// </summary>
|
||||
private void UpdateVisual(StoryBlockState newState)
|
||||
{
|
||||
if (_currentVisual != null)
|
||||
{
|
||||
Destroy(_currentVisual);
|
||||
_currentVisual = null;
|
||||
}
|
||||
|
||||
if (blockVisuals == null ||
|
||||
!blockVisuals.TryGetValue(newState, out GameObject prefab) ||
|
||||
prefab == null)
|
||||
{
|
||||
Debug.LogWarning($"[TextBlockView] block '{blockId}' 缺少状态 {newState} 的背景预制体(blockVisuals 未配置)。");
|
||||
return;
|
||||
}
|
||||
|
||||
_currentVisual = Instantiate(prefab, blockRect);
|
||||
_currentVisual.transform.SetAsFirstSibling();
|
||||
|
||||
// 背景铺满整个 block
|
||||
if (_currentVisual.transform is RectTransform visualRect)
|
||||
{
|
||||
visualRect.anchorMin = Vector2.zero;
|
||||
visualRect.anchorMax = Vector2.one;
|
||||
visualRect.offsetMin = Vector2.zero;
|
||||
visualRect.offsetMax = Vector2.zero;
|
||||
visualRect.localScale = Vector3.one;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnClicked()
|
||||
{
|
||||
if (StoryDialogueController.instance == null)
|
||||
{
|
||||
Debug.LogWarning($"[TextBlockView] 场景中缺少 StoryDialogueController,无法播放 block '{blockId}' 的对话。");
|
||||
return;
|
||||
}
|
||||
|
||||
StoryDialogueController.instance.PlayBlock(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/NewStorySystem/Tree/TextBlockView.cs.meta
Normal file
2
Assets/Scripts/NewStorySystem/Tree/TextBlockView.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 28d84946aa995484dbcbe24b7aab1652
|
||||
28
Assets/Scripts/NewStorySystem/Tree/TutorialBlockView.cs
Normal file
28
Assets/Scripts/NewStorySystem/Tree/TutorialBlockView.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ichni.Story.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 教程块视图(占位)。点击后将进入该章节的教程游玩,后续阶段接入。
|
||||
/// </summary>
|
||||
public class TutorialBlockView : StoryBlockView
|
||||
{
|
||||
[Header("Tutorial Block")]
|
||||
public TMP_Text tutorialNameText;
|
||||
|
||||
public override void Initialize(StoryBlockDefinition def, Vector2 position, StoryBlockState blockState)
|
||||
{
|
||||
base.Initialize(def, position, blockState);
|
||||
|
||||
if (tutorialNameText != null)
|
||||
tutorialNameText.text = def.tutorialName;
|
||||
}
|
||||
|
||||
protected override void OnClicked()
|
||||
{
|
||||
// 占位:后续接入教程进入流程
|
||||
Debug.Log($"[TutorialBlockView] 点击教程块 '{blockId}' (tutorial='{definition.tutorialName}')。占位实现,教程进入将在后续阶段接入。");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 66b3d929576d6c54ca51069ae0e313e5
|
||||
@@ -104,15 +104,5 @@ namespace SLSUtilities.General
|
||||
// 最后赋值 target.anchoredPosition = localPos;
|
||||
return localPos;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取目标 RectTransform 在目标空间 RectTransform 中的本地位置
|
||||
/// </summary>
|
||||
public static Vector2 GetLocalUIPosition(RectTransform targetRect, RectTransform targetSpace)
|
||||
{
|
||||
Vector2 screenPos = RectTransformUtility.WorldToScreenPoint(null, targetRect.position);
|
||||
RectTransformUtility.ScreenPointToLocalPointInRectangle(targetSpace, screenPos, null, out Vector2 localPos);
|
||||
return localPos;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,8 +36,7 @@ namespace Ichni
|
||||
SongSaveModule.LoadStoryUnlockKeys();
|
||||
|
||||
StorySaveModule = new StorySaveModule();
|
||||
StorySaveModule.LoadStoryVariables();
|
||||
StorySaveModule.LoadChoices();
|
||||
StorySaveModule.LoadVariables();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,113 +225,5 @@ namespace Ichni
|
||||
}
|
||||
}
|
||||
|
||||
public partial class StorySaveModule
|
||||
{
|
||||
public Dictionary<string, List<TutorialBlockSave>> tutorialBlockSaves;
|
||||
public Dictionary<string, List<SongBlockSave>> songBlockSaves;
|
||||
public Dictionary<string, List<DialogBlockSave>> dialogBlockSaves;
|
||||
public Dictionary<string, List<BlockConnectorSave>> connectorSaves;
|
||||
|
||||
public Dictionary<string, int> storyVariables;
|
||||
public Dictionary<string, int> selectedChoices;
|
||||
|
||||
private string GetStorySavePath(string chapterName) => Application.persistentDataPath + "/StorySaves/" + chapterName + ".json";
|
||||
private string StoryVariablesPath => Application.persistentDataPath + "/StorySaves/StoryVariables.json";
|
||||
private string ChoicesPath => Application.persistentDataPath + "/StorySaves/Choices.json";
|
||||
|
||||
public StorySaveModule()
|
||||
{
|
||||
tutorialBlockSaves = new Dictionary<string, List<TutorialBlockSave>>();
|
||||
songBlockSaves = new Dictionary<string, List<SongBlockSave>>();
|
||||
dialogBlockSaves = new Dictionary<string, List<DialogBlockSave>>();
|
||||
connectorSaves = new Dictionary<string, List<BlockConnectorSave>>();
|
||||
storyVariables = new Dictionary<string, int>();
|
||||
selectedChoices = new Dictionary<string, int>();
|
||||
//Debug.Log("Story Variables path: " + StoryVariablesPath);
|
||||
}
|
||||
}
|
||||
|
||||
public partial class StorySaveModule
|
||||
{
|
||||
public bool IsNewStoryline(string chapterName)
|
||||
{
|
||||
return !ES3.FileExists(GetStorySavePath(chapterName));
|
||||
}
|
||||
|
||||
public void LoadStoryline(string chapterName)
|
||||
{
|
||||
tutorialBlockSaves[chapterName] = ES3.Load<List<TutorialBlockSave>>("TutorialBlockSaves", GetStorySavePath(chapterName));
|
||||
songBlockSaves[chapterName] = ES3.Load<List<SongBlockSave>>("SongBlockSaves", GetStorySavePath(chapterName));
|
||||
dialogBlockSaves[chapterName] = ES3.Load<List<DialogBlockSave>>("TextBlockSaves", GetStorySavePath(chapterName));
|
||||
connectorSaves[chapterName] = ES3.Load<List<BlockConnectorSave>>("BlockConnectorSaves", GetStorySavePath(chapterName));
|
||||
}
|
||||
|
||||
public void SaveStoryline(string chapterName, List<TutorialBlockSave> tutorialBlocks,
|
||||
List<SongBlockSave> songBlocks, List<DialogBlockSave> dialogBlocks,
|
||||
List<BlockConnectorSave> connectors)
|
||||
{
|
||||
ES3.Save("TutorialBlockSaves", tutorialBlocks, GetStorySavePath(chapterName));
|
||||
ES3.Save("SongBlockSaves", songBlocks, GetStorySavePath(chapterName));
|
||||
ES3.Save("TextBlockSaves", dialogBlocks, GetStorySavePath(chapterName));
|
||||
ES3.Save("BlockConnectorSaves", connectors, GetStorySavePath(chapterName));
|
||||
|
||||
SaveStoryVariables();
|
||||
SaveChoices();
|
||||
}
|
||||
|
||||
public void ClearAllStoryline()
|
||||
{
|
||||
string path = GetStorySavePath("Chapter0");
|
||||
if (ES3.FileExists(path))
|
||||
{
|
||||
ES3.DeleteFile(path);
|
||||
Debug.Log("Cleared all story saves at: " + path);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning("No story saves found at: " + path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public partial class StorySaveModule
|
||||
{
|
||||
public void SaveStoryVariables()
|
||||
{
|
||||
string path = Application.persistentDataPath + "/StorySaves/" + "StoryVariables.json";
|
||||
ES3.Save("StoryVariables", storyVariables, path);
|
||||
}
|
||||
|
||||
public void LoadStoryVariables()
|
||||
{
|
||||
string path = Application.persistentDataPath + "/StorySaves/" + "StoryVariables.json";
|
||||
if (ES3.FileExists(path))
|
||||
{
|
||||
storyVariables = ES3.Load<Dictionary<string, int>>("StoryVariables", path);
|
||||
}
|
||||
else
|
||||
{
|
||||
storyVariables = new Dictionary<string, int>();
|
||||
}
|
||||
}
|
||||
|
||||
public void SaveChoices()
|
||||
{
|
||||
string path = Application.persistentDataPath + "/StorySaves/" + "Choices.json";
|
||||
ES3.Save("Choices", selectedChoices, path);
|
||||
}
|
||||
|
||||
public void LoadChoices()
|
||||
{
|
||||
string path = Application.persistentDataPath + "/StorySaves/" + "Choices.json";
|
||||
if (ES3.FileExists(path))
|
||||
{
|
||||
selectedChoices = ES3.Load<Dictionary<string, int>>("Choices", path);
|
||||
}
|
||||
else
|
||||
{
|
||||
selectedChoices = new Dictionary<string, int>();
|
||||
}
|
||||
}
|
||||
}
|
||||
// StorySaveModule 已迁移至 NewStorySystem/Save/StorySaveModule.cs(按章节存树/选项,全局存变量)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ichni.Story
|
||||
{
|
||||
public class StoryBlockSave
|
||||
{
|
||||
public string blockName;
|
||||
public Vector2 position;
|
||||
public StoryBlockState state;
|
||||
|
||||
public StoryBlockSave(string blockName, Vector2 position, StoryBlockState state)
|
||||
{
|
||||
this.blockName = blockName;
|
||||
this.state = state;
|
||||
this.position = position;
|
||||
}
|
||||
}
|
||||
|
||||
public class TutorialBlockSave : StoryBlockSave
|
||||
{
|
||||
public TutorialBlockSave(string blockName, Vector2 position, StoryBlockState state) : base(blockName, position, state)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public class DialogBlockSave : StoryBlockSave
|
||||
{
|
||||
public DialogBlockSave(string blockName, Vector2 position, StoryBlockState state) : base(blockName, position, state)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public class SongBlockSave : StoryBlockSave
|
||||
{
|
||||
public SongBlockSave(string blockName, Vector2 position, StoryBlockState state) : base(blockName, position, state)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public class BlockConnectorSave
|
||||
{
|
||||
public string startBlockName;
|
||||
public string endBlockName;
|
||||
|
||||
public BlockConnectorSave(string startBlockName, string endBlockName)
|
||||
{
|
||||
this.startBlockName = startBlockName;
|
||||
this.endBlockName = endBlockName;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9ff8f51d49d96524fab4c38c7f846368
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Localization.Settings;
|
||||
using UnityEngine.Rendering;
|
||||
using UnityEngine.Rendering.Universal;
|
||||
|
||||
@@ -65,6 +66,7 @@ namespace Ichni
|
||||
public void ApplyLanguage()
|
||||
{
|
||||
I2.Loc.LocalizationManager.CurrentLanguage = MenuManager.instance.languageList[languageIndex];
|
||||
LocalizationSettings.SelectedLocale = LocalizationSettings.AvailableLocales.Locales[languageIndex];
|
||||
I2.Loc.LocalizationManager.UpdateSources();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,447 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using Ichni.Story.UI;
|
||||
using Sirenix.OdinInspector;
|
||||
using SLSUtilities.General;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
using UnityEngine.Serialization;
|
||||
|
||||
namespace Ichni.Story
|
||||
{
|
||||
public partial class DialogManager : SerializedMonoBehaviour
|
||||
{
|
||||
public static DialogManager instance;
|
||||
|
||||
public List<TextAsset> dialogTextAssets;
|
||||
|
||||
public bool isPlayingDialog;
|
||||
public bool isPlayingChoice;
|
||||
|
||||
public string currentDialog;
|
||||
public int currentDialogSentenceIndex;
|
||||
public string currentFinalType;
|
||||
|
||||
public Dictionary<string, List<string>> functionDictionary;
|
||||
public Dictionary<string, List<DialogSentence>> dialogDictionary;
|
||||
public Dictionary<string, ChoiceGroup> choiceDictionary;
|
||||
public Dictionary<string, List<Condition>> conditionDictionary;
|
||||
public List<UnityAction> dialogEndActions;
|
||||
|
||||
private string currentLoadingDialog;
|
||||
|
||||
public DialogUIPage dialogUIPage;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
instance = this;
|
||||
}
|
||||
|
||||
public void SetDialog(string dialogName)
|
||||
{
|
||||
string chapter = ChapterSelectionManager.instance.currentChapter.chapterIndex;
|
||||
TextAsset dialog = Resources.Load<TextAsset>("Story/" + chapter + "/Dialogs/" + dialogName);
|
||||
dialogUIPage.dialogContentFrame.ClearAllSentences();
|
||||
SetDialog(new List<TextAsset> { dialog });
|
||||
}
|
||||
|
||||
public void SetDialog(List<TextAsset> dialogFiles, string dialogParagraphName = "")
|
||||
{
|
||||
dialogUIPage.FadeIn();
|
||||
|
||||
isPlayingDialog = true;
|
||||
|
||||
currentDialog = "NULL";
|
||||
|
||||
dialogEndActions = new List<UnityAction>();
|
||||
|
||||
LoadDialog(dialogFiles, out string firstHeader);
|
||||
|
||||
currentDialog = dialogParagraphName == "" ? firstHeader : dialogParagraphName;
|
||||
}
|
||||
|
||||
public void PlayNextDialogParagraph(string nextDialog, bool invokeFunctions = true)
|
||||
{
|
||||
currentDialog = nextDialog;
|
||||
currentDialogSentenceIndex = 0;
|
||||
|
||||
if (invokeFunctions && functionDictionary.TryGetValue(currentDialog, out List<string> functionList))
|
||||
{
|
||||
functionList.ForEach(x => StoryInterpreters.FunctionInterpreter.Eval(x));
|
||||
}
|
||||
|
||||
if (choiceDictionary.ContainsKey(currentDialog))
|
||||
{
|
||||
currentFinalType = "Choice";
|
||||
}
|
||||
else if (conditionDictionary.ContainsKey(currentDialog))
|
||||
{
|
||||
currentFinalType = "Condition";
|
||||
}
|
||||
else
|
||||
{
|
||||
currentFinalType = "None";
|
||||
}
|
||||
}
|
||||
|
||||
public void PlayDialog()
|
||||
{
|
||||
if(currentDialog == "NULL")
|
||||
{
|
||||
throw new Exception("Current dialog is NULL");
|
||||
}
|
||||
|
||||
if (isPlayingChoice)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (dialogDictionary[currentDialog].Count > 0 && currentDialogSentenceIndex < dialogDictionary[currentDialog].Count)
|
||||
{
|
||||
DialogSentence currentSentence = dialogDictionary[currentDialog][currentDialogSentenceIndex];
|
||||
|
||||
string interpretedContent = currentSentence.GetInterpretedContent();
|
||||
|
||||
dialogUIPage.dialogContentFrame.PlaySentence(currentSentence.characterName, interpretedContent);
|
||||
currentDialogSentenceIndex++;
|
||||
|
||||
if (currentDialogSentenceIndex <= dialogDictionary[currentDialog].Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if(currentDialogSentenceIndex >= dialogDictionary[currentDialog].Count)
|
||||
{
|
||||
if (currentFinalType == "Choice")
|
||||
{
|
||||
isPlayingChoice = true;
|
||||
dialogUIPage.dialogContentFrame.PlayChoice(choiceDictionary[currentDialog]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentFinalType == "Condition")
|
||||
{
|
||||
foreach (var condition in conditionDictionary[currentDialog])
|
||||
{
|
||||
if (condition.GetConditionResult())
|
||||
{
|
||||
PlayNextDialogParagraph(condition.nextDialogName);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentFinalType == "None" && currentDialogSentenceIndex >= dialogDictionary[currentDialog].Count)
|
||||
{
|
||||
isPlayingDialog = false;
|
||||
dialogUIPage.FadeOut();
|
||||
|
||||
if (StoryManager.instance.storyline.currentBlock.state == StoryBlockState.Current)
|
||||
{
|
||||
StoryManager.instance.storyline.currentBlock.state = StoryBlockState.Completed;
|
||||
StoryManager.instance.storyUIPage.messageBox.Clear();
|
||||
dialogEndActions.ForEach(action => action.Invoke());
|
||||
StoryManager.instance.storyUIPage.messageBox.SetUp();
|
||||
StoryManager.instance.storyline.SaveStoryline(ChapterSelectionManager.instance.currentChapter.chapterIndex);
|
||||
Debug.Log("Dialog completed, setting block state to Completed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void RevealDialog()
|
||||
{
|
||||
string finalType;
|
||||
int max = 0;
|
||||
Debug.Log($"Revealing dialog: {currentDialog}, currentFinalType: {currentFinalType}");
|
||||
do
|
||||
{
|
||||
finalType = currentFinalType;
|
||||
currentDialogSentenceIndex = 0;
|
||||
|
||||
foreach (DialogSentence sentence in dialogDictionary[currentDialog])
|
||||
{
|
||||
string interpretedContent = sentence.GetInterpretedContent();
|
||||
dialogUIPage.dialogContentFrame.PlaySentence(sentence.characterName, interpretedContent);
|
||||
currentDialogSentenceIndex++;
|
||||
}
|
||||
|
||||
if (finalType == "Choice")
|
||||
{
|
||||
ChoiceGroup choiceGroup = choiceDictionary[currentDialog];
|
||||
int choiceIndex = GameSaveManager.instance.StorySaveModule.selectedChoices[choiceGroup.choiceName];
|
||||
dialogUIPage.dialogContentFrame.SelectChoice(choiceGroup, choiceIndex);
|
||||
}
|
||||
|
||||
if (finalType == "Condition")
|
||||
{
|
||||
foreach (var condition in conditionDictionary[currentDialog])
|
||||
{
|
||||
if (condition.GetConditionResult())
|
||||
{
|
||||
PlayNextDialogParagraph(condition.nextDialogName, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
max++;
|
||||
|
||||
if (max > 1024)
|
||||
{
|
||||
throw new Exception("An infinite loop may detected in dialog parsing. Please check the dialog structure.");
|
||||
}
|
||||
|
||||
} while (finalType != "None");
|
||||
}
|
||||
}
|
||||
|
||||
public partial class DialogManager
|
||||
{
|
||||
public void LoadDialog(List<TextAsset> dialogFiles, out string firstHeader)
|
||||
{
|
||||
ClearDictionaries();
|
||||
|
||||
firstHeader = string.Empty;
|
||||
|
||||
dialogTextAssets = dialogFiles;
|
||||
List<string> dialogLines = new List<string>();
|
||||
|
||||
foreach (TextAsset textAsset in dialogTextAssets)
|
||||
{
|
||||
dialogLines.AddRange(ExtractValidFragments(textAsset.text));
|
||||
}
|
||||
|
||||
dialogLines.RemoveAll(line => line.Trim() == "");
|
||||
|
||||
//dialogLines.ForEach(Debug.Log);
|
||||
|
||||
foreach (string line in dialogLines)
|
||||
{
|
||||
if (!ParseHeader(line))
|
||||
{
|
||||
if (!ParseChoiceModule(line))
|
||||
{
|
||||
if (!ParseConditionModule(line))
|
||||
{
|
||||
if (!ParseDialogSentence(line))
|
||||
{
|
||||
throw new Exception($"Invalid dialog line: {line}"); // 抛出异常,提示不合法的对话行
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (firstHeader == string.Empty)
|
||||
{
|
||||
firstHeader = currentDialog;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//dialogDictionary.RemoveWhere((header, sentences) => sentences == null || sentences.Count == 0);
|
||||
choiceDictionary.RemoveWhere((header, choices) => choices == null || choices.choices.Count == 0);
|
||||
conditionDictionary.RemoveWhere((header, conditions) => conditions == null || conditions.Count == 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从原始大文本中提取所有以 '$' 开头的有效片段,忽略以 '#' 开头的注释片段。
|
||||
/// 拆分依据:每当遇到 '$' 或 '#' 字符,即视为一个新片段的起始。
|
||||
/// </summary>
|
||||
/// <param name="inputText">未分割的完整文本(可能包含任意换行或连续内容)。</param>
|
||||
/// <returns>剥离首 '$' 后的有效文本列表。</returns>
|
||||
public static List<string> ExtractValidFragments(string inputText)
|
||||
{
|
||||
if (inputText == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(inputText));
|
||||
}
|
||||
|
||||
// 正则:(?<prefix>[$#]) // 片段起始前缀
|
||||
// (?<content>.*? ) // 非贪婪捕获所有内容
|
||||
// (?=(?:[$#])|\z) // 直到下一个 '$'、'#' 或文末
|
||||
|
||||
const string pattern = @"(?<prefix>[$#])(?<content>.*?)(?=(?:[$#])|\z)";
|
||||
MatchCollection matches = Regex.Matches(inputText, pattern, RegexOptions.Singleline);
|
||||
|
||||
var result = new List<string>(matches.Count);
|
||||
foreach (Match m in matches)
|
||||
{
|
||||
char prefix = m.Groups["prefix"].Value[0];
|
||||
string content = m.Groups["content"].Value;
|
||||
|
||||
if (prefix == '$')
|
||||
{
|
||||
result.Add(content.Trim());
|
||||
}
|
||||
// prefix == '#' 时自动忽略
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public partial class DialogManager
|
||||
{
|
||||
public void ClearDictionaries()
|
||||
{
|
||||
dialogDictionary.Clear();
|
||||
choiceDictionary.Clear();
|
||||
conditionDictionary.Clear();
|
||||
functionDictionary.Clear();
|
||||
}
|
||||
|
||||
public bool ParseHeader(string line)
|
||||
{
|
||||
//格式:[currentLoadingDialog]{Function0();Function1();Function2();}
|
||||
line = line.Trim();
|
||||
|
||||
string dialogTitle = line.Split("{")[0];
|
||||
if (dialogTitle[0] != '[' || dialogTitle[^1] != ']')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
currentLoadingDialog = dialogTitle.Replace("[", "").Replace("]", "");
|
||||
|
||||
dialogDictionary.Add(currentLoadingDialog, new List<DialogSentence>());
|
||||
//choiceDictionary.Add(currentLoadingDialog, new ChoiceGroup("Error"));
|
||||
conditionDictionary.Add(currentLoadingDialog, new List<Condition>());
|
||||
|
||||
if (currentDialog == "NULL")
|
||||
{
|
||||
currentDialog = currentLoadingDialog;
|
||||
}
|
||||
|
||||
if (!line.Contains("{")) // 这个Header没有函数需要执行
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
string functions = line.Split("{")[1];
|
||||
if (functions.Contains("}"))
|
||||
{
|
||||
functions = functions.Split("}")[0];
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new System.Exception("Dialog header's function list must be enclosed in {}.");
|
||||
}
|
||||
|
||||
functions = functions.Replace(" ", "").Replace("\n", "").Replace("\r", "").Trim(); //忽略空格,换行
|
||||
|
||||
List<string> functionList = functions.Split(';').ToList(); //分割函数
|
||||
functionList = functionList.Where(x => !string.IsNullOrEmpty(x)).ToList(); //去除空函数
|
||||
functionDictionary.Add(currentLoadingDialog, functionList);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ParseDialogSentence(string line)
|
||||
{
|
||||
//speakerName:sentence
|
||||
|
||||
string[] sentenceData;
|
||||
if (line.Contains(":"))
|
||||
{
|
||||
sentenceData = line.Split(":", 2);
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string character = sentenceData[0];
|
||||
string speakerName = character;
|
||||
|
||||
DialogSentence dialogSentence = new DialogSentence
|
||||
{
|
||||
characterName = speakerName.Trim(),
|
||||
content = sentenceData[1].Trim()
|
||||
};
|
||||
|
||||
dialogDictionary[currentLoadingDialog].Add(dialogSentence);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ParseChoiceModule(string line)
|
||||
{
|
||||
//$Choice(ChoiceName){
|
||||
//choiceText0->[nextDialogName0];
|
||||
//choiceText1->[nextDialogName1];
|
||||
//}
|
||||
|
||||
line = line.Trim();
|
||||
|
||||
if (line.Contains("Choice"))
|
||||
{
|
||||
string[] choiceModuleData = line.Split('{');
|
||||
|
||||
string choiceName = choiceModuleData[0].Split('(')[1].Replace(")", "").Trim();
|
||||
ChoiceGroup choiceGroup = new ChoiceGroup(choiceName);
|
||||
|
||||
string[] choiceData = choiceModuleData[1].Split(';');
|
||||
for (var index = 0; index < choiceData.Length - 1; index++)
|
||||
{
|
||||
choiceData[index] = choiceData[index].Replace(" ", "").Replace("\n", "").Replace("\r", "").Trim();
|
||||
|
||||
string choiceText = choiceData[index].Split("->[")[0].Trim();
|
||||
string nextDialogName = choiceData[index].Split("->[")[1].Replace("]", "").Trim();
|
||||
|
||||
choiceGroup.choices.Add(new Choice(choiceText, nextDialogName));
|
||||
}
|
||||
|
||||
choiceDictionary[currentLoadingDialog] = choiceGroup;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析条件模块
|
||||
/// </summary>
|
||||
/// <param name="line"></param>
|
||||
/// <returns></returns>
|
||||
private bool ParseConditionModule(string line)
|
||||
{
|
||||
//$Condition{
|
||||
//conditionSentence0->[nextDialogName0];
|
||||
//conditionSentence1->[nextDialogName1];
|
||||
//}
|
||||
|
||||
if (line.Contains("Condition"))
|
||||
{
|
||||
string[] conditionModuleData = line.Split('{');
|
||||
|
||||
List<Condition> conditions = new List<Condition>();
|
||||
|
||||
string[] conditionData = conditionModuleData[1].Split(';');
|
||||
|
||||
for (var index = 0; index < conditionData.Length - 1; index++)
|
||||
{
|
||||
conditionData[index] = conditionData[index].Replace(" ", "").Replace("\n", "").Replace("\r", "").Trim();
|
||||
Condition condition = new Condition
|
||||
{
|
||||
conditionSentence = conditionData[index].Split("->[")[0].Trim(),
|
||||
nextDialogName = conditionData[index].Split("->[")[1].Replace("]", "").Trim(),
|
||||
};
|
||||
|
||||
conditions.Add(condition);
|
||||
}
|
||||
|
||||
conditionDictionary[currentLoadingDialog] = conditions;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9e89b9d7eea97734baa166072b050239
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,106 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ichni.Story
|
||||
{
|
||||
public class DialogSentence
|
||||
{
|
||||
public string content;
|
||||
public string audioEventName;
|
||||
|
||||
public string characterName;
|
||||
|
||||
public DialogSentence()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public DialogSentence(string content, string audioEventName, string characterName)
|
||||
{
|
||||
this.content = content;
|
||||
this.audioEventName = audioEventName;
|
||||
this.characterName = characterName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 匹配{@FUNCTION},解析函数并返回解析后的语句内容。
|
||||
/// </summary>
|
||||
public string GetInterpretedContent()
|
||||
{
|
||||
List<string> parts = new List<string>();
|
||||
Regex regex = new Regex(@"\{\@.*?\}");
|
||||
int lastIndex = 0;
|
||||
|
||||
foreach (Match match in regex.Matches(content))
|
||||
{
|
||||
if (match.Index > lastIndex)
|
||||
{
|
||||
parts.Add(content.Substring(lastIndex, match.Index - lastIndex));
|
||||
}
|
||||
parts.Add(match.Value);
|
||||
lastIndex = match.Index + match.Length;
|
||||
}
|
||||
|
||||
if (lastIndex < content.Length)
|
||||
{
|
||||
parts.Add(content.Substring(lastIndex));
|
||||
}
|
||||
|
||||
for (int i = 0; i < parts.Count; i++)
|
||||
{
|
||||
if (parts[i].StartsWith("{@") && parts[i].EndsWith("}"))
|
||||
{
|
||||
string expression = parts[i].Substring(2, parts[i].Length - 3);
|
||||
object result = StoryInterpreters.FunctionInterpreter.Eval(expression);
|
||||
parts[i] = result.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
return string.Join("", parts);
|
||||
}
|
||||
}
|
||||
|
||||
public class ChoiceGroup
|
||||
{
|
||||
public string choiceName;
|
||||
public List<Choice> choices;
|
||||
|
||||
public ChoiceGroup(string choiceName)
|
||||
{
|
||||
this.choiceName = choiceName;
|
||||
this.choices = new List<Choice>();
|
||||
}
|
||||
}
|
||||
|
||||
public class Choice
|
||||
{
|
||||
public string choiceText;
|
||||
public string nextDialogName;
|
||||
|
||||
public Choice(string choiceText, string nextDialogName)
|
||||
{
|
||||
this.choiceText = choiceText;
|
||||
this.nextDialogName = nextDialogName;
|
||||
}
|
||||
}
|
||||
|
||||
public class Condition
|
||||
{
|
||||
public string conditionSentence;
|
||||
public string nextDialogName;
|
||||
|
||||
public bool GetConditionResult()
|
||||
{
|
||||
bool result = StoryInterpreters.ConditionInterpreter.Eval<bool>(conditionSentence);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public class DialogCharacter
|
||||
{
|
||||
public string name;
|
||||
public string title;
|
||||
public Dictionary<string, Sprite> emotions;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d32abf19c3d0f4546b2b86f9fcc4e117
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,104 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DynamicExpresso;
|
||||
using Ichni.Menu;
|
||||
using Ichni.Story;
|
||||
using Ichni.Story.UI;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ichni.Story
|
||||
{
|
||||
public static partial class StoryInterpreters
|
||||
{
|
||||
public static readonly Interpreter FunctionInterpreter;
|
||||
public static readonly Interpreter ConditionInterpreter;
|
||||
|
||||
static StoryInterpreters()
|
||||
{
|
||||
FunctionInterpreter = new Interpreter();
|
||||
ConditionInterpreter = new Interpreter();
|
||||
|
||||
SetFunctionInterpreter();
|
||||
SetConditionInterpreter();
|
||||
}
|
||||
|
||||
static void SetFunctionInterpreter()
|
||||
{
|
||||
FunctionInterpreter.SetFunction("SetVariable", new Action<string, int>(SetStoryVariable));
|
||||
FunctionInterpreter.SetFunction("GetVariable", new Func<string, int>(GetStoryVariable));
|
||||
FunctionInterpreter.SetFunction("GenerateDialogBlock", new Action<string>(GenerateDialogBlock));
|
||||
FunctionInterpreter.SetFunction("GenerateSongBlock", new Action<string>(GenerateSongBlock));
|
||||
FunctionInterpreter.SetFunction("SetUnlockKey", new Action<string>(SetUnlockKey));
|
||||
}
|
||||
|
||||
static void SetConditionInterpreter()
|
||||
{
|
||||
ConditionInterpreter.SetFunction("GetVariable", new Func<string, int>(GetStoryVariable));
|
||||
}
|
||||
}
|
||||
|
||||
public static partial class StoryInterpreters
|
||||
{
|
||||
/// <summary>
|
||||
/// 设置全局变量的值
|
||||
/// </summary>
|
||||
static void SetStoryVariable(string variableName, int value)
|
||||
{
|
||||
GameSaveManager.instance.StorySaveModule.storyVariables[variableName] = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取全局变量的值
|
||||
/// </summary>
|
||||
static int GetStoryVariable(string variableName)
|
||||
{
|
||||
if (GameSaveManager.instance.StorySaveModule.storyVariables.TryGetValue(variableName, out int value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
throw new ArgumentException($"Global variable '{variableName}' not found.");
|
||||
}
|
||||
}
|
||||
|
||||
public static partial class StoryInterpreters
|
||||
{
|
||||
static void GenerateDialogBlock(string blockName)
|
||||
{
|
||||
StoryBlockUIBase currentBlock = StoryManager.instance.storyline.currentBlock;
|
||||
Vector2 positionOffset = new Vector2(500, 0);
|
||||
DialogBlockUI newBlock = StoryManager.instance.storyline.GenerateDialogBlock(blockName, currentBlock.blockPosition + positionOffset, StoryBlockState.Current);
|
||||
StoryManager.instance.storyline.GenerateConnector(currentBlock, newBlock);
|
||||
StoryManager.instance.storyline.SetUpBackground();
|
||||
StoryManager.instance.storyline.connectors.ForEach(c => c.SetCurve());
|
||||
}
|
||||
|
||||
static void GenerateSongBlock(string blockName)
|
||||
{
|
||||
StoryBlockUIBase currentBlock = StoryManager.instance.storyline.currentBlock;
|
||||
Vector2 positionOffset = new Vector2(500, 0);
|
||||
SongBlockUI newBlock = StoryManager.instance.storyline.GenerateSongBlock(blockName, currentBlock.blockPosition + positionOffset, StoryBlockState.Current);
|
||||
StoryManager.instance.storyline.GenerateConnector(currentBlock, newBlock);
|
||||
}
|
||||
|
||||
static void SetUnlockKey(string key)
|
||||
{
|
||||
if (GameSaveManager.instance.SongSaveModule.storyUnlockKeys.Add(key))
|
||||
{
|
||||
GameSaveManager.instance.SongSaveModule.SaveStoryUnlockKeys();
|
||||
|
||||
DialogManager.instance.dialogEndActions.Add(() =>
|
||||
{
|
||||
ChapterSelectionUnit currentChapter = ChapterSelectionManager.instance.currentChapter;
|
||||
List<string> unlockedSongs = currentChapter.GetRelatedSongNamesOfUnlockKey(key);
|
||||
foreach (string songName in unlockedSongs)
|
||||
{
|
||||
StoryManager.instance.storyUIPage.messageBox.AddInfo(
|
||||
"Message/Unlock_Song_Title", "Message/Unlock_Song",
|
||||
() => StoryManager.instance.storyUIPage.messageBox.SetParameter("SongName", songName));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dd119fb631aebb548a637407d15c5eea
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,43 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Ichni.Story.UI;
|
||||
using Ichni.UI;
|
||||
using Sirenix.OdinInspector;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Serialization;
|
||||
|
||||
namespace Ichni.Story
|
||||
{
|
||||
public partial class StoryManager : SerializedMonoBehaviour
|
||||
{
|
||||
public static StoryManager instance;
|
||||
|
||||
[FormerlySerializedAs("storylineDisplay")] public Storyline storyline;
|
||||
public StoryUIPage storyUIPage;
|
||||
|
||||
public Dictionary<string, StoryData> storyDatas;
|
||||
|
||||
|
||||
void Awake()
|
||||
{
|
||||
instance = this;
|
||||
}
|
||||
}
|
||||
|
||||
public partial class StoryManager
|
||||
{
|
||||
[Button]
|
||||
public void ClearAllStorySave()
|
||||
{
|
||||
GameSaveManager.instance.StorySaveModule.ClearAllStoryline();
|
||||
GameSaveManager.instance.SongSaveModule.ClearStoryKeys();
|
||||
}
|
||||
}
|
||||
|
||||
public enum StoryBlockState
|
||||
{
|
||||
Locked,
|
||||
Current,
|
||||
Completed
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e58008720832d5045ada32d1161faa69
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,106 +0,0 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Sirenix.OdinInspector;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Serialization;
|
||||
|
||||
namespace Ichni.Story
|
||||
{
|
||||
[CreateAssetMenu(fileName = "StoryData", menuName = "Ichni/Story/StoryData")]
|
||||
public class StoryData : SerializedScriptableObject
|
||||
{
|
||||
public List<DialogBlockData> dialogBlockDatas; // 剧情单元格名称列表
|
||||
public List<SongBlockData> songBlockDatas; // 音乐单元格名称列表
|
||||
public List<TutorialBlockData> tutorialBlockDatas; // 教程单元格名称列表
|
||||
public List<InitialBlockData> initialBlocks; // 初始剧情单元格列表,包含所有初始剧情单元格的名称
|
||||
|
||||
public StoryBlockData GetDataByName(string blockName, out Type dataType)
|
||||
{
|
||||
foreach (var block in tutorialBlockDatas.Where(block => block.blockName == blockName))
|
||||
{
|
||||
dataType = typeof(TutorialBlockData);
|
||||
return block;
|
||||
}
|
||||
|
||||
foreach (var block in songBlockDatas.Where(block => block.blockName == blockName))
|
||||
{
|
||||
dataType = typeof(SongBlockData);
|
||||
return block;
|
||||
}
|
||||
|
||||
foreach (var block in dialogBlockDatas.Where(block => block.blockName == blockName))
|
||||
{
|
||||
dataType = typeof(DialogBlockData);
|
||||
return block;
|
||||
}
|
||||
|
||||
throw new ArgumentException($"No block found with name: {blockName}");
|
||||
}
|
||||
}
|
||||
|
||||
[InlineProperty]
|
||||
[Serializable]
|
||||
public class InitialBlockData
|
||||
{
|
||||
public string blockName;
|
||||
public StoryBlockState initialState; // 初始状态
|
||||
public Vector2 blockPosition; // 初始位置
|
||||
public List<string> nextBlocks; // 下一步可选的剧情单元格名称列表
|
||||
}
|
||||
|
||||
[InlineProperty]
|
||||
[Serializable]
|
||||
public class StoryBlockData
|
||||
{
|
||||
[FoldoutGroup("$blockName", true)]
|
||||
public string blockName;
|
||||
[FoldoutGroup("$blockName")]
|
||||
public string blockID;
|
||||
[FoldoutGroup("$blockName")]
|
||||
public Vector2 blockSize;
|
||||
}
|
||||
|
||||
[InlineProperty]
|
||||
[Serializable]
|
||||
public class TutorialBlockData : StoryBlockData
|
||||
{
|
||||
[FoldoutGroup("$blockName")]
|
||||
public string tutorialName;
|
||||
|
||||
|
||||
public TutorialBlockData()
|
||||
{
|
||||
this.blockSize = new Vector2(400, 200);
|
||||
}
|
||||
}
|
||||
|
||||
[InlineProperty]
|
||||
[Serializable]
|
||||
public class DialogBlockData : StoryBlockData
|
||||
{
|
||||
[FoldoutGroup("$blockName")]
|
||||
public string dialogTitle;
|
||||
|
||||
|
||||
public DialogBlockData()
|
||||
{
|
||||
this.blockSize = new Vector2(400, 200);
|
||||
}
|
||||
}
|
||||
|
||||
[InlineProperty]
|
||||
[Serializable]
|
||||
public class SongBlockData : StoryBlockData
|
||||
{
|
||||
[FoldoutGroup("$blockName")]
|
||||
public string songName;
|
||||
|
||||
|
||||
public SongBlockData()
|
||||
{
|
||||
this.blockSize = new Vector2(400, 200);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7ab917c50249812429ebd44d6574497c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,39 +0,0 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using Ichni.Story.UI;
|
||||
using SLSUtilities.General;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI.Extensions;
|
||||
|
||||
namespace Ichni.Story
|
||||
{
|
||||
public class BlockConnectorUI : MonoBehaviour
|
||||
{
|
||||
public UILineRenderer curve;
|
||||
public StoryBlockUIBase startBlock;
|
||||
public StoryBlockUIBase endBlock;
|
||||
|
||||
public void SetCurve(StoryBlockUIBase startBlock = null, StoryBlockUIBase endBlock = null)
|
||||
{
|
||||
this.startBlock ??= startBlock;
|
||||
this.endBlock ??= endBlock;
|
||||
|
||||
if(this.startBlock == null || this.endBlock == null)
|
||||
{
|
||||
Debug.LogWarning("Start or end block is not set for the curve.");
|
||||
return;
|
||||
}
|
||||
|
||||
Vector2 startPosition = SpaceConverter.GetLocalUIPosition(this.startBlock.outPort, GetComponent<RectTransform>());
|
||||
Vector2 endPosition = SpaceConverter.GetLocalUIPosition(this.endBlock.inPort, GetComponent<RectTransform>());
|
||||
|
||||
Vector2 mid1 = (startPosition + endPosition) / 2;
|
||||
Vector2 mid2 = (startPosition + endPosition) / 2;
|
||||
|
||||
mid1.y = startPosition.y;
|
||||
mid2.y = endPosition.y;
|
||||
|
||||
curve.Points = new Vector2[] { startPosition, /*mid1*/ mid2, endPosition};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 98bbf463dca50cc43961c0bb2b8d4f22
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,53 +0,0 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Ichni.Story.UI
|
||||
{
|
||||
public class DialogBlockUI : StoryBlockUIBase
|
||||
{
|
||||
public string blockTitle;
|
||||
public TMP_Text titleText;
|
||||
|
||||
public Button button;
|
||||
public List<ChoiceGroupUI> choiceGroups;
|
||||
|
||||
public void Initialize(string blockName, Vector2 position, Vector2 positionOffset,
|
||||
Vector2 size, StoryBlockState state, string blockTitle)
|
||||
{
|
||||
base.Initialize(blockName, position, positionOffset, size, state);
|
||||
|
||||
this.blockTitle = blockTitle;
|
||||
titleText.text = blockTitle;
|
||||
|
||||
button.onClick.AddListener(() =>
|
||||
{
|
||||
state = this.state;
|
||||
|
||||
if(state == StoryBlockState.Locked) return;
|
||||
|
||||
StoryManager.instance.storyline.currentBlock = this;
|
||||
|
||||
if (state == StoryBlockState.Current)
|
||||
{
|
||||
DialogManager.instance.SetDialog(blockName);
|
||||
DialogManager.instance.PlayNextDialogParagraph(DialogManager.instance.currentDialog);
|
||||
}
|
||||
else if (state == StoryBlockState.Completed)
|
||||
{
|
||||
DialogManager.instance.SetDialog(blockName);
|
||||
DialogManager.instance.PlayNextDialogParagraph(DialogManager.instance.currentDialog, false);
|
||||
DialogManager.instance.RevealDialog();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public override StoryBlockSave GetBlockSave()
|
||||
{
|
||||
return new DialogBlockSave(blockName, blockPosition, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b976128eb3ec59e4e866dbb610441706
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,76 +0,0 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Ichni.Menu;
|
||||
using Ichni.RhythmGame;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Serialization;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Ichni.Story.UI
|
||||
{
|
||||
public class SongBlockUI : StoryBlockUIBase
|
||||
{
|
||||
public string songName;
|
||||
public Button button;
|
||||
public TMP_Text songNameText;
|
||||
public RectTransform beatmapStatusMarkContainer;
|
||||
|
||||
public GameObject beatmapStatusMarkPrefab;
|
||||
|
||||
public void Initialize(string blockName, Vector2 position, Vector2 positionOffset,
|
||||
Vector2 size, StoryBlockState state, string songName)
|
||||
{
|
||||
base.Initialize(blockName, position, positionOffset, size, state);
|
||||
|
||||
this.songName = songName;
|
||||
songNameText.text = songName;
|
||||
|
||||
button.onClick.AddListener(() =>
|
||||
{
|
||||
//MenuManager.instance.prepareUIPage.SetUpPrepareUIPage(songName);
|
||||
//MenuManager.instance.prepareUIPage.FadeIn();
|
||||
});
|
||||
|
||||
SetUpBeatmapStatusMarks();
|
||||
}
|
||||
|
||||
public override StoryBlockSave GetBlockSave()
|
||||
{
|
||||
return new SongBlockSave(blockName, blockPosition, state);
|
||||
}
|
||||
|
||||
public void SetUpBeatmapStatusMarks()
|
||||
{
|
||||
SongStatusSave songStatusSave = GameSaveManager.instance.SongSaveModule.songStatusSaves[songName];
|
||||
|
||||
string chapter = ChapterSelectionManager.instance.currentChapter.chapterIndex;
|
||||
ChapterSelectionUnit cpt = ChapterSelectionManager.instance.chapters.First(c => c.chapterIndex == chapter);
|
||||
SongItemData song = cpt.songs.First(s => s.songName == this.songName);
|
||||
for (var index = 0; index < song.difficultyDataList.Count; index++)
|
||||
{
|
||||
var difficulty = song.difficultyDataList[index];
|
||||
var beatmapSave = songStatusSave.beatmapSaves[index];
|
||||
|
||||
if (beatmapSave.isAllPerfect)
|
||||
{
|
||||
GameObject mark = Instantiate(beatmapStatusMarkPrefab, beatmapStatusMarkContainer);
|
||||
mark.GetComponent<Image>().color = difficulty.color;
|
||||
mark.transform.GetChild(0).GetComponent<TMP_Text>().color = difficulty.color;
|
||||
mark.transform.GetChild(0).GetComponent<TMP_Text>().text = "AP";
|
||||
break;
|
||||
}
|
||||
|
||||
if (beatmapSave.isFullCombo)
|
||||
{
|
||||
GameObject mark = Instantiate(beatmapStatusMarkPrefab, beatmapStatusMarkContainer);
|
||||
mark.GetComponent<Image>().color = difficulty.color;
|
||||
mark.transform.GetChild(0).GetComponent<TMP_Text>().color = difficulty.color;
|
||||
mark.transform.GetChild(0).GetComponent<TMP_Text>().text = "FC";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5efe0a39fe908354e9ab6d1edfdb8843
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,30 +0,0 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Serialization;
|
||||
|
||||
namespace Ichni.Story.UI
|
||||
{
|
||||
public abstract class StoryBlockUIBase : MonoBehaviour
|
||||
{
|
||||
public string blockName;
|
||||
public Vector2 blockPosition;
|
||||
public StoryBlockState state;
|
||||
|
||||
public RectTransform blockRect;
|
||||
public RectTransform inPort;
|
||||
public RectTransform outPort;
|
||||
|
||||
protected void Initialize(string blockName, Vector2 position, Vector2 positionOffset, Vector2 size, StoryBlockState state)
|
||||
{
|
||||
this.blockName = blockName;
|
||||
this.blockPosition = position;
|
||||
this.state = state;
|
||||
|
||||
blockRect.anchoredPosition = position + positionOffset;
|
||||
blockRect.sizeDelta = size;
|
||||
}
|
||||
|
||||
public abstract StoryBlockSave GetBlockSave();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d489f986a1eea1e4c938658f0fd468ca
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,52 +0,0 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using DG.Tweening;
|
||||
using Ichni.Menu;
|
||||
using SLSUtilities.WwiseAssistance;
|
||||
using TMPro;
|
||||
using UniRx;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Ichni.Story.UI
|
||||
{
|
||||
public class TutorialBlockUI : StoryBlockUIBase
|
||||
{
|
||||
public Button button;
|
||||
public string tutorialName;
|
||||
public TMP_Text tutorialNameText;
|
||||
|
||||
public void Initialize(string blockName, Vector2 position, Vector2 positionOffset, Vector2 size, StoryBlockState state, string tutorialName)
|
||||
{
|
||||
base.Initialize(blockName, position, positionOffset, size, state);
|
||||
|
||||
this.tutorialName = tutorialName;
|
||||
tutorialNameText.text = tutorialName;
|
||||
|
||||
button.onClick.AddListener(EnterTutorial);
|
||||
}
|
||||
|
||||
public override StoryBlockSave GetBlockSave()
|
||||
{
|
||||
return new TutorialBlockSave(blockName, blockPosition, state);
|
||||
}
|
||||
|
||||
private void EnterTutorial()
|
||||
{
|
||||
ChapterSelectionUnit chapter = ChapterSelectionManager.instance.currentChapter;
|
||||
|
||||
SongItemData song = ChapterSelectionManager.instance.tutorialCollection.songs[chapter.chapterIndex];
|
||||
DifficultyData difficulty = song.difficultyDataList[0];
|
||||
InformationTransistor.instance.SetInformation(chapter, song, difficulty);
|
||||
InformationTransistor.instance.isReturnedFromTutorial = true;
|
||||
InformationTransistor.instance.isReturnedFromGame = false;
|
||||
|
||||
AudioManager.Post(AK.EVENTS.ENTERTOGAME);
|
||||
SongSelectionManager.instance.StopPreviewSong();
|
||||
|
||||
DOTween.KillAll();
|
||||
Observable.Timer(TimeSpan.FromSeconds(0.6f)).Subscribe(_ => { MenuManager.instance.EnterGameScene(); });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 821e50e1519236a46b03eb1f005acebe
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,11 +0,0 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ichni.Story.UI
|
||||
{
|
||||
public class ChoiceButtonUI : MonoBehaviour
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 90f34f59ff260c44796d71c51b7c0ee6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,45 +0,0 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using I2.Loc;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Ichni.Story.UI
|
||||
{
|
||||
public class ChoiceGroupUI : MonoBehaviour
|
||||
{
|
||||
public GameObject choiceButtonPrefab;
|
||||
public RectTransform container;
|
||||
|
||||
public List<Button> choiceButtonList;
|
||||
|
||||
public string choiceName;
|
||||
public int choiceIndex;
|
||||
|
||||
public void Initialize(ChoiceGroup choiceGroup)
|
||||
{
|
||||
this.choiceName = choiceGroup.choiceName;
|
||||
choiceButtonList = new List<Button>();
|
||||
|
||||
for (var index = 0; index < choiceGroup.choices.Count; index++)
|
||||
{
|
||||
var choice = choiceGroup.choices[index];
|
||||
int cIndex = index; // Capture the current index for the listener
|
||||
|
||||
GameObject choiceButton = Instantiate(choiceButtonPrefab, container);
|
||||
choiceButton.GetComponentInChildren<Localize>().SetTerm(ChapterSelectionManager.instance.currentChapter.chapterIndex + "/" + choice.choiceText);
|
||||
choiceButton.GetComponent<Button>().onClick.AddListener(() =>
|
||||
{
|
||||
DialogManager.instance.PlayNextDialogParagraph(choice.nextDialogName);
|
||||
DialogManager.instance.isPlayingChoice = false;
|
||||
choiceButtonList.ForEach(b => b.interactable = false);
|
||||
DialogManager.instance.PlayDialog();
|
||||
this.choiceIndex = cIndex;
|
||||
GameSaveManager.instance.StorySaveModule.selectedChoices[choiceName] = cIndex;
|
||||
});
|
||||
choiceButtonList.Add(choiceButton.GetComponent<Button>());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a653477cd0de8794b810214793b04cc9
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,81 +0,0 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using DG.Tweening;
|
||||
using DG.Tweening.Core;
|
||||
using DG.Tweening.Plugins.Options;
|
||||
using I2.Loc;
|
||||
using Ichni.Story.UI;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
using UnityEngine.Serialization;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Ichni.Story
|
||||
{
|
||||
public class DialogContentFrame : MonoBehaviour, IPointerClickHandler
|
||||
{
|
||||
public GameObject textPrefab;
|
||||
public GameObject choiceGroupPrefab;
|
||||
|
||||
public RectTransform dialogContentContainer;
|
||||
public List<DialogTextUI> dialogTexts;
|
||||
public List<ChoiceGroupUI> choiceGroups;
|
||||
|
||||
public void PlaySentence(string speakerName, string content)
|
||||
{
|
||||
DialogTextUI dialogTextUI = Instantiate(textPrefab, dialogContentContainer).GetComponent<DialogTextUI>();
|
||||
dialogTextUI.speakerNameText.SetTerm("Characters/" + speakerName);
|
||||
dialogTextUI.contentText.SetTerm(ChapterSelectionManager.instance.currentChapter.chapterIndex +"/" +content);
|
||||
dialogTexts.Add(dialogTextUI);
|
||||
}
|
||||
|
||||
public ChoiceGroupUI PlayChoice(ChoiceGroup choiceGroup)
|
||||
{
|
||||
ChoiceGroupUI choiceGroupUI = Instantiate(choiceGroupPrefab, dialogContentContainer).GetComponent<ChoiceGroupUI>();
|
||||
choiceGroupUI.Initialize(choiceGroup);
|
||||
choiceGroups.Add(choiceGroupUI);
|
||||
|
||||
return choiceGroupUI;
|
||||
}
|
||||
|
||||
public void SelectChoice(ChoiceGroup choiceGroup, int index)
|
||||
{
|
||||
ChoiceGroupUI choiceGroupUI = PlayChoice(choiceGroup);
|
||||
for (var buttonIndex = 0; buttonIndex < choiceGroupUI.choiceButtonList.Count; buttonIndex++)
|
||||
{
|
||||
Button b = choiceGroupUI.choiceButtonList[buttonIndex];
|
||||
b.interactable = false;
|
||||
|
||||
if (buttonIndex == index)
|
||||
{
|
||||
b.image.color = Color.red;
|
||||
}
|
||||
}
|
||||
|
||||
DialogManager.instance.PlayNextDialogParagraph(choiceGroup.choices[index].nextDialogName, false);
|
||||
}
|
||||
|
||||
public void ClearAllSentences()
|
||||
{
|
||||
foreach (DialogTextUI dialogText in dialogTexts)
|
||||
{
|
||||
Destroy(dialogText.gameObject);
|
||||
}
|
||||
|
||||
foreach (ChoiceGroupUI choiceGroup in choiceGroups)
|
||||
{
|
||||
Destroy(choiceGroup.gameObject);
|
||||
}
|
||||
|
||||
dialogTexts.Clear();
|
||||
choiceGroups.Clear();
|
||||
}
|
||||
|
||||
public void OnPointerClick(PointerEventData eventData)
|
||||
{
|
||||
DialogManager.instance.PlayDialog();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 14874d8a3e4a31941879415479892501
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,16 +0,0 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using I2.Loc;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Ichni.Story.UI
|
||||
{
|
||||
public class DialogTextUI : MonoBehaviour
|
||||
{
|
||||
public Image background;
|
||||
public Localize speakerNameText;
|
||||
public Localize contentText;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 831ccff3dc06bfc4884663d623af866d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,26 +0,0 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using Ichni.UI;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.Serialization;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Ichni.Story.UI
|
||||
{
|
||||
public class DialogUIPage : UIPageBase
|
||||
{
|
||||
public Button closeButton;
|
||||
public DialogContentFrame dialogContentFrame;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
closeButton.onClick.AddListener(() =>
|
||||
{
|
||||
FadeOut();
|
||||
dialogContentFrame.ClearAllSentences();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 17254192719abee4f9222246fd403de5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,272 +0,0 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Sirenix.OdinInspector;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Serialization;
|
||||
|
||||
namespace Ichni.Story.UI
|
||||
{
|
||||
public partial class Storyline : MonoBehaviour
|
||||
{
|
||||
[Header("UI References")]
|
||||
public RectTransform content; // Content of ScrollRect
|
||||
[FormerlySerializedAs("textBlockPrefab")] public GameObject dialogBlockPrefab; // Prefab of UI Node
|
||||
public GameObject musicBlockPrefab;
|
||||
public GameObject tutorialBlockPrefab;
|
||||
public GameObject connectionCurvePrefab; // Prefab of connection curve
|
||||
|
||||
[Header("Layout Settings")]
|
||||
public float marginLeft = 50f; // additive space on the left
|
||||
public float marginRight = 50f; // Extra space on the right
|
||||
public float marginTop = 50f; // Extra space on the top
|
||||
public float marginBottom = 50f; // Extra space on the bottom
|
||||
public RectTransform connectionContainer;
|
||||
|
||||
public StoryBlockUIBase currentBlock;
|
||||
|
||||
public List<StoryBlockUIBase> storyBlocks;
|
||||
public List<DialogBlockUI> dialogBlocks;
|
||||
public List<SongBlockUI> songBlocks;
|
||||
public List<TutorialBlockUI> tutorialBlocks;
|
||||
public List<BlockConnectorUI> connectors;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
storyBlocks = new List<StoryBlockUIBase>();
|
||||
dialogBlocks = new List<DialogBlockUI>();
|
||||
songBlocks = new List<SongBlockUI>();
|
||||
tutorialBlocks = new List<TutorialBlockUI>();
|
||||
connectors = new List<BlockConnectorUI>();
|
||||
|
||||
SetUpStoryline(ChapterSelectionManager.instance.currentChapter.chapterIndex);
|
||||
|
||||
SetUpBackground();
|
||||
connectionContainer.SetParent(content);
|
||||
connectionContainer.SetAsFirstSibling();
|
||||
}
|
||||
}
|
||||
|
||||
public partial class Storyline
|
||||
{
|
||||
public TutorialBlockUI GenerateTutorialBlock(string blockName, Vector2 position, StoryBlockState state)
|
||||
{
|
||||
TutorialBlockUI block = Instantiate(tutorialBlockPrefab, content).GetComponent<TutorialBlockUI>();
|
||||
StoryData storyData = StoryManager.instance.storyDatas[ChapterSelectionManager.instance.currentChapter.chapterIndex];
|
||||
TutorialBlockData blockData = storyData.tutorialBlockDatas.FirstOrDefault(data => data.blockName == blockName);
|
||||
|
||||
if (blockData == null) throw new KeyNotFoundException("There is no block with name " + blockName);
|
||||
|
||||
block.Initialize(blockData.blockName, position, new Vector2(marginLeft, 0), blockData.blockSize, state, blockData.tutorialName);
|
||||
|
||||
storyBlocks.Add(block);
|
||||
tutorialBlocks.Add(block);
|
||||
|
||||
return block;
|
||||
}
|
||||
|
||||
public DialogBlockUI GenerateDialogBlock(string blockName, Vector2 position, StoryBlockState state)
|
||||
{
|
||||
DialogBlockUI block = Instantiate(dialogBlockPrefab, content).GetComponent<DialogBlockUI>();
|
||||
StoryData storyData = StoryManager.instance.storyDatas[ChapterSelectionManager.instance.currentChapter.chapterIndex];
|
||||
DialogBlockData blockData = storyData.dialogBlockDatas.FirstOrDefault(data => data.blockName == blockName);
|
||||
|
||||
if (blockData == null) throw new KeyNotFoundException("There is no block with name " + blockName);
|
||||
|
||||
block.Initialize(blockData.blockName, position, new Vector2(marginLeft, 0), blockData.blockSize, state, blockData.dialogTitle);
|
||||
|
||||
storyBlocks.Add(block);
|
||||
dialogBlocks.Add(block);
|
||||
|
||||
return block;
|
||||
}
|
||||
|
||||
public SongBlockUI GenerateSongBlock(string blockName, Vector2 position, StoryBlockState state)
|
||||
{
|
||||
SongBlockUI block = Instantiate(musicBlockPrefab, content).GetComponent<SongBlockUI>();
|
||||
StoryData storyData = StoryManager.instance.storyDatas[ChapterSelectionManager.instance.currentChapter.chapterIndex];
|
||||
SongBlockData blockData = storyData.songBlockDatas.FirstOrDefault(data => data.blockName == blockName);
|
||||
|
||||
if (blockData == null) throw new KeyNotFoundException("There is no block with name " + blockName);
|
||||
|
||||
block.Initialize(blockName,position,new Vector2(marginLeft, 0), blockData.blockSize, state, blockData.songName);
|
||||
|
||||
storyBlocks.Add(block);
|
||||
songBlocks.Add(block);
|
||||
|
||||
return block;
|
||||
}
|
||||
|
||||
public void GenerateConnector(StoryBlockUIBase startBlock, StoryBlockUIBase endBlock)
|
||||
{
|
||||
BlockConnectorUI connector = Instantiate(connectionCurvePrefab, connectionContainer).GetComponent<BlockConnectorUI>();
|
||||
connector.SetCurve(startBlock, endBlock);
|
||||
connectors.Add(connector);
|
||||
}
|
||||
|
||||
public void GenerateConnector(string startBlockName, string endBlockName)
|
||||
{
|
||||
StoryBlockUIBase startBlock = storyBlocks.FirstOrDefault(block => block.blockName == startBlockName);
|
||||
StoryBlockUIBase endBlock = storyBlocks.FirstOrDefault(block => block.blockName == endBlockName);
|
||||
GenerateConnector(startBlock, endBlock);
|
||||
}
|
||||
}
|
||||
|
||||
public partial class Storyline
|
||||
{
|
||||
private void ClearStoryline()
|
||||
{
|
||||
foreach (var block in storyBlocks)
|
||||
{
|
||||
Destroy(block.gameObject);
|
||||
}
|
||||
storyBlocks.Clear();
|
||||
dialogBlocks.Clear();
|
||||
songBlocks.Clear();
|
||||
tutorialBlocks.Clear();
|
||||
|
||||
foreach (var connector in connectors)
|
||||
{
|
||||
Destroy(connector.gameObject);
|
||||
}
|
||||
connectors.Clear();
|
||||
|
||||
content.sizeDelta = Vector2.zero;
|
||||
}
|
||||
|
||||
public void SetUpBackground()
|
||||
{
|
||||
float maxRight = float.MinValue;
|
||||
|
||||
foreach (var block in storyBlocks)
|
||||
{
|
||||
float rightEdge = block.blockRect.anchoredPosition.x + block.blockRect.sizeDelta.x * 0.5f;
|
||||
if (rightEdge > maxRight)
|
||||
{
|
||||
maxRight = rightEdge;
|
||||
}
|
||||
}
|
||||
|
||||
maxRight += marginRight;
|
||||
|
||||
if (maxRight < 2560f)
|
||||
{
|
||||
maxRight = 2560f;
|
||||
}
|
||||
|
||||
|
||||
float lowY = float.MaxValue;
|
||||
|
||||
foreach (var block in storyBlocks)
|
||||
{
|
||||
float bottomEdge = block.blockRect.anchoredPosition.y - block.blockRect.sizeDelta.y * 0.5f;
|
||||
if (bottomEdge < lowY)
|
||||
{
|
||||
lowY = bottomEdge;
|
||||
}
|
||||
}
|
||||
|
||||
float maxHeight = Mathf.Abs(lowY) + marginTop + marginBottom;
|
||||
|
||||
if (maxHeight < 1440f)
|
||||
{
|
||||
maxHeight = 1440f;
|
||||
}
|
||||
|
||||
content.sizeDelta = new Vector2(maxRight, maxHeight);
|
||||
//connectionContainer.sizeDelta = new Vector2(maxRight, maxHeight);
|
||||
}
|
||||
}
|
||||
|
||||
public partial class Storyline
|
||||
{
|
||||
public void SetUpStoryline(string chapterIndex)
|
||||
{
|
||||
if (GameSaveManager.instance.StorySaveModule.IsNewStoryline(chapterIndex))
|
||||
{
|
||||
ResetStory(chapterIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
GameSaveManager.instance.StorySaveModule.LoadStoryline(chapterIndex);
|
||||
|
||||
foreach (var blockSave in GameSaveManager.instance.StorySaveModule.tutorialBlockSaves[chapterIndex])
|
||||
{
|
||||
GenerateTutorialBlock(blockSave.blockName, blockSave.position, blockSave.state);
|
||||
}
|
||||
|
||||
foreach (var blockSave in GameSaveManager.instance.StorySaveModule.songBlockSaves[chapterIndex])
|
||||
{
|
||||
GenerateSongBlock(blockSave.blockName, blockSave.position, blockSave.state);
|
||||
}
|
||||
|
||||
foreach (var blockSave in GameSaveManager.instance.StorySaveModule.dialogBlockSaves[chapterIndex])
|
||||
{
|
||||
GenerateDialogBlock(blockSave.blockName, blockSave.position, blockSave.state);
|
||||
}
|
||||
|
||||
foreach (var connectorSave in GameSaveManager.instance.StorySaveModule.connectorSaves[chapterIndex])
|
||||
{
|
||||
GenerateConnector(connectorSave.startBlockName, connectorSave.endBlockName);
|
||||
}
|
||||
}
|
||||
|
||||
[Button]
|
||||
public void SaveStoryline(string chapterName)
|
||||
{
|
||||
List<TutorialBlockSave> tutorialBlockSaves =
|
||||
tutorialBlocks.Select(block => block.GetBlockSave() as TutorialBlockSave).ToList();
|
||||
|
||||
List<SongBlockSave> songBlockSaves =
|
||||
songBlocks.Select(block => block.GetBlockSave() as SongBlockSave).ToList();
|
||||
|
||||
List<DialogBlockSave> dialogBlockSaves =
|
||||
dialogBlocks.Select(block => block.GetBlockSave() as DialogBlockSave).ToList();
|
||||
|
||||
List<BlockConnectorSave> connectorSaves =
|
||||
connectors.Select(connector => new BlockConnectorSave(connector.startBlock.blockName, connector.endBlock.blockName)).ToList();
|
||||
|
||||
GameSaveManager.instance.StorySaveModule.SaveStoryline(
|
||||
chapterName, tutorialBlockSaves, songBlockSaves, dialogBlockSaves, connectorSaves);
|
||||
}
|
||||
|
||||
[Button]
|
||||
public void ResetStory(string chapterName)
|
||||
{
|
||||
ClearStoryline();
|
||||
|
||||
StoryData storyData = StoryManager.instance.storyDatas[chapterName];
|
||||
List<InitialBlockData> initialBlocks = storyData.initialBlocks;
|
||||
|
||||
foreach (InitialBlockData blockData in initialBlocks)
|
||||
{
|
||||
storyData.GetDataByName(blockData.blockName, out Type dataType);
|
||||
|
||||
if (dataType == typeof(TutorialBlockData))
|
||||
{
|
||||
GenerateTutorialBlock(blockData.blockName, blockData.blockPosition, blockData.initialState);
|
||||
}
|
||||
else if (dataType == typeof(DialogBlockData))
|
||||
{
|
||||
GenerateDialogBlock(blockData.blockName, blockData.blockPosition, blockData.initialState);
|
||||
}
|
||||
else if (dataType == typeof(SongBlockData))
|
||||
{
|
||||
GenerateSongBlock(blockData.blockName, blockData.blockPosition, blockData.initialState);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (InitialBlockData blockData in initialBlocks)
|
||||
{
|
||||
foreach (string nextBlockName in blockData.nextBlocks)
|
||||
{
|
||||
GenerateConnector(blockData.blockName, nextBlockName);
|
||||
}
|
||||
}
|
||||
|
||||
SetUpBackground();
|
||||
SaveStoryline(chapterName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 42d37c15aeeaf1d4abbdc13962bb8b70
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -87,6 +87,7 @@ namespace Ichni.UI
|
||||
ChapterSelectionManager.instance.currentChapter = connectedChapter;
|
||||
AudioManager.SetSwitch(connectedChapter.chapterSwitch);
|
||||
ChapterSelectionManager.instance.chapterSelectionUIPage.FadeOut();
|
||||
StoryManager.instance.OpenChapter(connectedChapter.chapterIndex);
|
||||
StoryManager.instance.storyUIPage.FadeIn();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user