Compare commits

..

4 Commits

Author SHA1 Message Date
31d91e546d 修复打包错误的问题
Signed-off-by: TRADER_FOER <lhf190@outlook.com>
2026-07-23 15:19:06 +08:00
9e5471b4c4 与编辑器同步,增加sync优化bloom,添加打包时自动打包主题包的特性
(怎么这么多meta文件啊)

Signed-off-by: TRADER_FOER <lhf190@outlook.com>
2026-07-23 14:52:15 +08:00
SoulliesOfficial
02ee7fc72e Merge branch 'master' of https://git.hoshino.fan/Soullies/ichni_Official 2026-07-22 04:17:52 -04:00
SoulliesOfficial
810d019619 剧情+对话完善 2026-07-21 15:24:42 -04:00
158 changed files with 7588 additions and 4057 deletions

View File

@@ -213,7 +213,7 @@ Material:
- _Dst: 10
- _DstBlend: 0
- _DstBlendAlpha: 0
- _EdgeValue: 0.92061317
- _EdgeValue: 0.5453637
- _EnvironmentReflections: 1
- _FNLfanxiangkaiguan: 0
- _Face: 1
@@ -258,7 +258,7 @@ Material:
- _Mask_scale: 1
- _Metallic: 0
- _OcclusionStrength: 1
- _Opacity: 0.07938683
- _Opacity: 0.45463628
- _Parallax: 0.005
- _Pass: 0
- _QueueOffset: 0

View File

@@ -5,6 +5,7 @@ using System.IO;
using System.Linq;
using Ichni;
using UnityEditor;
using UnityEditor.Build;
using UnityEngine;
/// <summary>
@@ -44,35 +45,79 @@ public class BuildAssetBundle
}
}
public static void BuildForTarget(BuildTarget target)
{
string platformDirectory = GetPlatformDirectory(target);
if (string.IsNullOrEmpty(platformDirectory))
{
Debug.LogWarning($"Skip AssetBundle build for unsupported build target: {target}");
return;
}
string uri = Path.Combine(BuildPrepare(), platformDirectory);
if (Directory.Exists(uri) == false)
{
Directory.CreateDirectory(uri);
}
AssetBundleManifest manifest = BuildPipeline.BuildAssetBundles(uri, BuildAssetBundleOptions.None, target);
if (manifest == null)
{
throw new BuildFailedException($"AssetBundle build failed for target: {target}");
}
GenerateAbstracts(manifest);
}
private static string GetPlatformDirectory(BuildTarget target)
{
return target switch
{
BuildTarget.StandaloneWindows => "Windows64",
BuildTarget.StandaloneWindows64 => "Windows64",
BuildTarget.StandaloneOSX => "OSX",
BuildTarget.Android => "Android",
BuildTarget.iOS => "IOS",
_ => string.Empty
};
}
[MenuItem("AssetBundleTools/BuildToWindows64")]
public static void BuildToWindows64()
{
string uri = BuildPrepare() + "/Windows64";
AssetBundleManifest manifestWin64 = BuildPipeline.BuildAssetBundles(uri, BuildAssetBundleOptions.None, BuildTarget.StandaloneWindows64);
GenerateAbstracts(manifestWin64);
BuildForTarget(BuildTarget.StandaloneWindows64);
}
[MenuItem("AssetBundleTools/BuildToOSX")]
public static void BuildToOSX()
{
string uri = BuildPrepare() + "/OSX";
AssetBundleManifest manifestOSX = BuildPipeline.BuildAssetBundles(uri, BuildAssetBundleOptions.None, BuildTarget.StandaloneOSX);
GenerateAbstracts(manifestOSX);
BuildForTarget(BuildTarget.StandaloneOSX);
}
[MenuItem("AssetBundleTools/BuildToAndroid")]
public static void BuildToAndroid()
{
string uri = BuildPrepare() + "/Android";
AssetBundleManifest manifestAndroid = BuildPipeline.BuildAssetBundles(uri, BuildAssetBundleOptions.None, BuildTarget.Android);
GenerateAbstracts(manifestAndroid);
BuildForTarget(BuildTarget.Android);
}
[MenuItem("AssetBundleTools/BuildToIOS")]
public static void BuildToIOS()
{
string uri = BuildPrepare() + "/IOS";
AssetBundleManifest manifestIOS = BuildPipeline.BuildAssetBundles(uri, BuildAssetBundleOptions.None, BuildTarget.iOS);
GenerateAbstracts(manifestIOS);
BuildForTarget(BuildTarget.iOS);
}
}
}
[InitializeOnLoad]
public static class AssetBundleBuildPlayerHandler
{
static AssetBundleBuildPlayerHandler()
{
BuildPlayerWindow.RegisterBuildPlayerHandler(BuildPlayerWithAssetBundles);
}
private static void BuildPlayerWithAssetBundles(BuildPlayerOptions options)
{
Debug.Log($"Rebuild AssetBundles before player build: {options.target}");
BuildAssetBundle.BuildForTarget(options.target);
BuildPlayerWindow.DefaultBuildMethods.BuildPlayer(options);
}
}

Binary file not shown.

View File

@@ -17,7 +17,7 @@ GameObject:
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
m_IsActive: 0
--- !u!224 &4551339722402108901
RectTransform:
m_ObjectHideFlags: 0
@@ -376,20 +376,23 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_enableWordWrapping: 1
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 00000000
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -710,20 +713,23 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_enableWordWrapping: 1
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 00000000
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -920,20 +926,23 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_enableWordWrapping: 1
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 00000000
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -968,7 +977,7 @@ GameObject:
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
m_IsActive: 0
--- !u!224 &3053449365611135054
RectTransform:
m_ObjectHideFlags: 0
@@ -1129,20 +1138,23 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_enableWordWrapping: 1
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 00000000
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -1339,20 +1351,23 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_enableWordWrapping: 1
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 00000000
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -1473,20 +1488,23 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_enableWordWrapping: 1
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 00000000
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -1773,20 +1791,23 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_enableWordWrapping: 1
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 00000000
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -1947,20 +1968,23 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_enableWordWrapping: 1
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -2082,20 +2106,23 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_enableWordWrapping: 1
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -2333,20 +2360,23 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_enableWordWrapping: 1
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -2468,20 +2498,23 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_enableWordWrapping: 1
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -2603,20 +2636,23 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_enableWordWrapping: 1
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 00000000
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -3019,20 +3055,23 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_enableWordWrapping: 1
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -3327,20 +3366,23 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_enableWordWrapping: 1
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 00000000
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -4045,20 +4087,23 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_enableWordWrapping: 1
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -4145,6 +4190,7 @@ MonoBehaviour:
enterSongSelectionButton: {fileID: 5935354699868473682}
connectedChapter: {fileID: 0}
chapterName:
isLocked: 0
isExpanded: 0
isDuringAnimation: 0
bottomTip: {fileID: 3369054462927452463}
@@ -4477,20 +4523,23 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_enableWordWrapping: 1
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 00000000
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -4694,20 +4743,23 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_enableWordWrapping: 1
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 00000000
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -5137,20 +5189,23 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_enableWordWrapping: 1
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 00000000
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0

View File

@@ -101,7 +101,7 @@ MonoBehaviour:
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_DisabledColor: {r: 1, g: 1, b: 1, a: 1}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:

View File

@@ -101,7 +101,7 @@ MonoBehaviour:
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_DisabledColor: {r: 1, g: 1, b: 1, a: 1}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:
@@ -116,7 +116,7 @@ MonoBehaviour:
m_SelectedTrigger: Selected
m_DisabledTrigger: Disabled
m_Interactable: 1
m_TargetGraphic: {fileID: 1269722336407503250}
m_TargetGraphic: {fileID: 0}
m_OnClick:
m_PersistentCalls:
m_Calls: []

View File

@@ -101,7 +101,7 @@ MonoBehaviour:
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_DisabledColor: {r: 1, g: 1, b: 1, a: 1}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:

View File

@@ -11,6 +11,7 @@ GameObject:
- component: {fileID: 7895181244729415272}
- component: {fileID: 5622993008979475129}
- component: {fileID: 7873337981602853883}
- component: {fileID: 3155030838568361280}
m_Layer: 5
m_Name: Text (TMP) (1)
m_TagString: Untagged
@@ -138,6 +139,18 @@ MonoBehaviour:
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!225 &3155030838568361280
CanvasGroup:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1266875872687906005}
m_Enabled: 1
m_Alpha: 1
m_Interactable: 1
m_BlocksRaycasts: 0
m_IgnoreParentGroups: 0
--- !u!1 &2549272938280010857
GameObject:
m_ObjectHideFlags: 0
@@ -182,126 +195,6 @@ CanvasRenderer:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2549272938280010857}
m_CullTransparentMesh: 1
--- !u!1 &2947357181438598616
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 808855937929311006}
- component: {fileID: 3938143345111524688}
- component: {fileID: 1269722336407503250}
- component: {fileID: 3931523710101296998}
m_Layer: 5
m_Name: Image
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 0
--- !u!224 &808855937929311006
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2947357181438598616}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 5350638854831129010}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &3938143345111524688
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2947357181438598616}
m_CullTransparentMesh: 1
--- !u!114 &1269722336407503250
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2947357181438598616}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 21300000, guid: ba2e784cfa5cc0549bf66051f872367c, type: 3}
m_Type: 0
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!114 &3931523710101296998
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2947357181438598616}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Navigation:
m_Mode: 3
m_WrapAround: 0
m_SelectOnUp: {fileID: 0}
m_SelectOnDown: {fileID: 0}
m_SelectOnLeft: {fileID: 0}
m_SelectOnRight: {fileID: 0}
m_Transition: 1
m_Colors:
m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:
m_HighlightedSprite: {fileID: 0}
m_PressedSprite: {fileID: 0}
m_SelectedSprite: {fileID: 0}
m_DisabledSprite: {fileID: 0}
m_AnimationTriggers:
m_NormalTrigger: Normal
m_HighlightedTrigger: Highlighted
m_PressedTrigger: Pressed
m_SelectedTrigger: Selected
m_DisabledTrigger: Disabled
m_Interactable: 1
m_TargetGraphic: {fileID: 1269722336407503250}
m_OnClick:
m_PersistentCalls:
m_Calls: []
--- !u!1 &5694744930588099522
GameObject:
m_ObjectHideFlags: 0
@@ -357,6 +250,7 @@ GameObject:
- component: {fileID: 9095470796084918386}
- component: {fileID: 606259940963463531}
- component: {fileID: 8391808794720387191}
- component: {fileID: 8456607026273325819}
m_Layer: 5
m_Name: Text (TMP)
m_TagString: Untagged
@@ -484,6 +378,18 @@ MonoBehaviour:
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!225 &8456607026273325819
CanvasGroup:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7991069327751894070}
m_Enabled: 1
m_Alpha: 1
m_Interactable: 1
m_BlocksRaycasts: 0
m_IgnoreParentGroups: 0
--- !u!1 &8279096577214194803
GameObject:
m_ObjectHideFlags: 0
@@ -514,7 +420,6 @@ RectTransform:
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 808855937929311006}
- {fileID: 9095470796084918386}
- {fileID: 7895181244729415272}
- {fileID: 5784757909052703056}
@@ -638,6 +543,6 @@ MonoBehaviour:
blockRect: {fileID: 5350638854831129010}
inPort: {fileID: 5784757909052703056}
outPort: {fileID: 4937946904652960982}
button: {fileID: 3931523710101296998}
button: {fileID: 0}
storyIdText: {fileID: 8391808794720387191}
titleText: {fileID: 8391808794720387191}

View File

@@ -101,7 +101,7 @@ MonoBehaviour:
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_DisabledColor: {r: 1, g: 1, b: 1, a: 1}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:

View File

@@ -101,7 +101,7 @@ MonoBehaviour:
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_DisabledColor: {r: 1, g: 1, b: 1, a: 1}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:
@@ -116,7 +116,7 @@ MonoBehaviour:
m_SelectedTrigger: Selected
m_DisabledTrigger: Disabled
m_Interactable: 1
m_TargetGraphic: {fileID: 1269722336407503250}
m_TargetGraphic: {fileID: 0}
m_OnClick:
m_PersistentCalls:
m_Calls: []

View File

@@ -101,7 +101,7 @@ MonoBehaviour:
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_DisabledColor: {r: 1, g: 1, b: 1, a: 1}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:

View File

@@ -221,7 +221,7 @@ MonoBehaviour:
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_DisabledColor: {r: 1, g: 1, b: 1, a: 1}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:

View File

@@ -101,7 +101,7 @@ MonoBehaviour:
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_DisabledColor: {r: 1, g: 1, b: 1, a: 1}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:

View File

@@ -150,126 +150,6 @@ MonoBehaviour:
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!1 &2947357181438598616
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 808855937929311006}
- component: {fileID: 464408401004507889}
- component: {fileID: 5684991247721594449}
- component: {fileID: 3931523710101296998}
m_Layer: 5
m_Name: Button
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &808855937929311006
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2947357181438598616}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 5350638854831129010}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &464408401004507889
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2947357181438598616}
m_CullTransparentMesh: 1
--- !u!114 &5684991247721594449
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2947357181438598616}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Image
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 0}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 0}
m_Type: 0
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!114 &3931523710101296998
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2947357181438598616}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Navigation:
m_Mode: 3
m_WrapAround: 0
m_SelectOnUp: {fileID: 0}
m_SelectOnDown: {fileID: 0}
m_SelectOnLeft: {fileID: 0}
m_SelectOnRight: {fileID: 0}
m_Transition: 1
m_Colors:
m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:
m_HighlightedSprite: {fileID: 0}
m_PressedSprite: {fileID: 0}
m_SelectedSprite: {fileID: 0}
m_DisabledSprite: {fileID: 0}
m_AnimationTriggers:
m_NormalTrigger: Normal
m_HighlightedTrigger: Highlighted
m_PressedTrigger: Pressed
m_SelectedTrigger: Selected
m_DisabledTrigger: Disabled
m_Interactable: 1
m_TargetGraphic: {fileID: 5684991247721594449}
m_OnClick:
m_PersistentCalls:
m_Calls: []
--- !u!1 &3472041800732464321
GameObject:
m_ObjectHideFlags: 0
@@ -281,6 +161,7 @@ GameObject:
- component: {fileID: 6776097071409445873}
- component: {fileID: 5860881451225714455}
- component: {fileID: 4081607830298877796}
- component: {fileID: 5567213864031973997}
m_Layer: 5
m_Name: TitleText
m_TagString: Untagged
@@ -408,6 +289,18 @@ MonoBehaviour:
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!225 &5567213864031973997
CanvasGroup:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3472041800732464321}
m_Enabled: 1
m_Alpha: 1
m_Interactable: 1
m_BlocksRaycasts: 0
m_IgnoreParentGroups: 0
--- !u!1 &7991069327751894070
GameObject:
m_ObjectHideFlags: 0
@@ -419,6 +312,7 @@ GameObject:
- component: {fileID: 9095470796084918386}
- component: {fileID: 606259940963463531}
- component: {fileID: 8391808794720387191}
- component: {fileID: 7157907970610375421}
m_Layer: 5
m_Name: StoryIdText
m_TagString: Untagged
@@ -546,6 +440,18 @@ MonoBehaviour:
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!225 &7157907970610375421
CanvasGroup:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7991069327751894070}
m_Enabled: 1
m_Alpha: 1
m_Interactable: 1
m_BlocksRaycasts: 0
m_IgnoreParentGroups: 0
--- !u!1 &8279096577214194803
GameObject:
m_ObjectHideFlags: 0
@@ -580,7 +486,6 @@ RectTransform:
- {fileID: 6776097071409445873}
- {fileID: 8088187650405042001}
- {fileID: 5120337310990590007}
- {fileID: 808855937929311006}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
@@ -687,6 +592,6 @@ MonoBehaviour:
blockRect: {fileID: 5350638854831129010}
inPort: {fileID: 8088187650405042001}
outPort: {fileID: 5120337310990590007}
button: {fileID: 3931523710101296998}
button: {fileID: 0}
storyIdText: {fileID: 8391808794720387191}
titleText: {fileID: 4081607830298877796}

View File

@@ -1,94 +1,5 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &629758055316317495
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 5620483555661743440}
- component: {fileID: 808211794615970900}
- component: {fileID: 1717850242396468755}
m_Layer: 5
m_Name: Button
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &5620483555661743440
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 629758055316317495}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 5350638854831129010}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 2.75, y: -1}
m_SizeDelta: {x: -25.5, y: -22}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &808211794615970900
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 629758055316317495}
m_CullTransparentMesh: 1
--- !u!114 &1717850242396468755
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 629758055316317495}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Navigation:
m_Mode: 3
m_WrapAround: 0
m_SelectOnUp: {fileID: 0}
m_SelectOnDown: {fileID: 0}
m_SelectOnLeft: {fileID: 0}
m_SelectOnRight: {fileID: 0}
m_Transition: 0
m_Colors:
m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:
m_HighlightedSprite: {fileID: 0}
m_PressedSprite: {fileID: 0}
m_SelectedSprite: {fileID: 0}
m_DisabledSprite: {fileID: 0}
m_AnimationTriggers:
m_NormalTrigger: Normal
m_HighlightedTrigger: Highlighted
m_PressedTrigger: Pressed
m_SelectedTrigger: Selected
m_DisabledTrigger: Disabled
m_Interactable: 1
m_TargetGraphic: {fileID: 0}
m_OnClick:
m_PersistentCalls:
m_Calls: []
--- !u!1 &847642077185086423
GameObject:
m_ObjectHideFlags: 0
@@ -257,6 +168,7 @@ GameObject:
- component: {fileID: 1824825661128247125}
- component: {fileID: 6839066121088903434}
- component: {fileID: 4619121194727073750}
- component: {fileID: 6000241608163664205}
m_Layer: 5
m_Name: Mask
m_TagString: Untagged
@@ -335,6 +247,50 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Mask
m_ShowMaskGraphic: 0
--- !u!114 &6000241608163664205
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2685032725291833036}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Button
m_Navigation:
m_Mode: 3
m_WrapAround: 0
m_SelectOnUp: {fileID: 0}
m_SelectOnDown: {fileID: 0}
m_SelectOnLeft: {fileID: 0}
m_SelectOnRight: {fileID: 0}
m_Transition: 1
m_Colors:
m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:
m_HighlightedSprite: {fileID: 0}
m_PressedSprite: {fileID: 0}
m_SelectedSprite: {fileID: 0}
m_DisabledSprite: {fileID: 0}
m_AnimationTriggers:
m_NormalTrigger: Normal
m_HighlightedTrigger: Highlighted
m_PressedTrigger: Pressed
m_SelectedTrigger: Selected
m_DisabledTrigger: Disabled
m_Interactable: 1
m_TargetGraphic: {fileID: 5963302735353550978}
m_OnClick:
m_PersistentCalls:
m_Calls: []
--- !u!1 &2947357181438598616
GameObject:
m_ObjectHideFlags: 0
@@ -1053,7 +1009,6 @@ RectTransform:
- {fileID: 530893922272850372}
- {fileID: 5732796955640118753}
- {fileID: 4908100188787544557}
- {fileID: 5620483555661743440}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
@@ -1095,7 +1050,7 @@ MonoBehaviour:
blockRect: {fileID: 5350638854831129010}
inPort: {fileID: 5732796955640118753}
outPort: {fileID: 4908100188787544557}
button: {fileID: 1717850242396468755}
button: {fileID: 6000241608163664205}
illustrationImage: {fileID: 5963302735353550978}
illustrationRect: {fileID: 4492029902276622460}
songNameText: {fileID: 8391808794720387191}

View File

@@ -55,6 +55,7 @@ GameObject:
- component: {fileID: 808855937929311006}
- component: {fileID: 3938143345111524688}
- component: {fileID: 1269722336407503250}
- component: {fileID: 2589979841992238672}
m_Layer: 5
m_Name: Image
m_TagString: Untagged
@@ -119,63 +120,18 @@ MonoBehaviour:
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!1 &2971854550119419744
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 6723909345398867291}
- component: {fileID: 1883720140888459607}
- component: {fileID: 1506827284251123157}
m_Layer: 5
m_Name: Button
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &6723909345398867291
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2971854550119419744}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 5350638854831129010}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0.5, y: -7.5}
m_SizeDelta: {x: -31, y: -29}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &1883720140888459607
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2971854550119419744}
m_CullTransparentMesh: 1
--- !u!114 &1506827284251123157
--- !u!114 &2589979841992238672
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2971854550119419744}
m_GameObject: {fileID: 2947357181438598616}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
m_Name:
m_EditorClassIdentifier:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Button
m_Navigation:
m_Mode: 3
m_WrapAround: 0
@@ -183,7 +139,7 @@ MonoBehaviour:
m_SelectOnDown: {fileID: 0}
m_SelectOnLeft: {fileID: 0}
m_SelectOnRight: {fileID: 0}
m_Transition: 0
m_Transition: 1
m_Colors:
m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
@@ -204,7 +160,7 @@ MonoBehaviour:
m_SelectedTrigger: Selected
m_DisabledTrigger: Disabled
m_Interactable: 1
m_TargetGraphic: {fileID: 0}
m_TargetGraphic: {fileID: 1269722336407503250}
m_OnClick:
m_PersistentCalls:
m_Calls: []
@@ -263,6 +219,7 @@ GameObject:
- component: {fileID: 9095470796084918386}
- component: {fileID: 606259940963463531}
- component: {fileID: 8391808794720387191}
- component: {fileID: 2409804111795149599}
m_Layer: 5
m_Name: Text (TMP)
m_TagString: Untagged
@@ -390,6 +347,18 @@ MonoBehaviour:
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!225 &2409804111795149599
CanvasGroup:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7991069327751894070}
m_Enabled: 1
m_Alpha: 1
m_Interactable: 1
m_BlocksRaycasts: 0
m_IgnoreParentGroups: 0
--- !u!1 &8279096577214194803
GameObject:
m_ObjectHideFlags: 0
@@ -424,7 +393,6 @@ RectTransform:
- {fileID: 9095470796084918386}
- {fileID: 5784757909052703056}
- {fileID: 4937946904652960982}
- {fileID: 6723909345398867291}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0.5}
@@ -466,5 +434,5 @@ MonoBehaviour:
blockRect: {fileID: 5350638854831129010}
inPort: {fileID: 5784757909052703056}
outPort: {fileID: 4937946904652960982}
button: {fileID: 1506827284251123157}
button: {fileID: 2589979841992238672}
tutorialNameText: {fileID: 8391808794720387191}

View File

@@ -0,0 +1,407 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &65537282130585173
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1643897006236830438}
- component: {fileID: 6202551277621388703}
- component: {fileID: 6295076540922261237}
m_Layer: 5
m_Name: Speaker
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &1643897006236830438
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 65537282130585173}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 5159032239856795640}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0.5}
m_AnchorMax: {x: 0, y: 0.5}
m_AnchoredPosition: {x: 225, y: 0}
m_SizeDelta: {x: 250, y: 100}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &6202551277621388703
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 65537282130585173}
m_CullTransparentMesh: 1
--- !u!114 &6295076540922261237
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 65537282130585173}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TextMeshProUGUI
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: "Speaker: \n"
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: 87c1b020037c81841b3d98dd497cbdeb, type: 2}
m_sharedMaterial: {fileID: -5812992765432063453, guid: 87c1b020037c81841b3d98dd497cbdeb,
type: 2}
m_fontSharedMaterials: []
m_fontMaterial: {fileID: 0}
m_fontMaterials: []
m_fontColor32:
serializedVersion: 2
rgba: 4294967295
m_fontColor: {r: 1, g: 1, b: 1, a: 1}
m_enableVertexGradient: 0
m_colorMode: 3
m_fontColorGradient:
topLeft: {r: 1, g: 1, b: 1, a: 1}
topRight: {r: 1, g: 1, b: 1, a: 1}
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
bottomRight: {r: 1, g: 1, b: 1, a: 1}
m_fontColorGradientPreset: {fileID: 0}
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 42
m_fontSizeBase: 42
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 1
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 0
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
m_horizontalMapping: 0
m_verticalMapping: 0
m_uvLineOffset: 0
m_geometrySortingOrder: 0
m_IsTextObjectScaleStatic: 0
m_VertexBufferAutoSizeReduction: 0
m_useMaxVisibleDescender: 1
m_pageToDisplay: 1
m_margin: {x: 0, y: 0, z: 0, w: 0}
m_isUsingLegacyAnimationComponent: 0
m_isVolumetricText: 0
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!1 &1609519350888832602
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 4891349041236814278}
- component: {fileID: 4388088537228770510}
- component: {fileID: 5176135775194928437}
m_Layer: 5
m_Name: Content
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &4891349041236814278
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1609519350888832602}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 5159032239856795640}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0.5}
m_AnchorMax: {x: 1, y: 0.5}
m_AnchoredPosition: {x: 175.00002, y: 0}
m_SizeDelta: {x: -349.99997, y: 100}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &4388088537228770510
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1609519350888832602}
m_CullTransparentMesh: 1
--- !u!114 &5176135775194928437
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1609519350888832602}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TextMeshProUGUI
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: Content
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: 87c1b020037c81841b3d98dd497cbdeb, type: 2}
m_sharedMaterial: {fileID: -5812992765432063453, guid: 87c1b020037c81841b3d98dd497cbdeb,
type: 2}
m_fontSharedMaterials: []
m_fontMaterial: {fileID: 0}
m_fontMaterials: []
m_fontColor32:
serializedVersion: 2
rgba: 4294967295
m_fontColor: {r: 1, g: 1, b: 1, a: 1}
m_enableVertexGradient: 0
m_colorMode: 3
m_fontColorGradient:
topLeft: {r: 1, g: 1, b: 1, a: 1}
topRight: {r: 1, g: 1, b: 1, a: 1}
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
bottomRight: {r: 1, g: 1, b: 1, a: 1}
m_fontColorGradientPreset: {fileID: 0}
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 42
m_fontSizeBase: 42
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 1
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 0
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
m_horizontalMapping: 0
m_verticalMapping: 0
m_uvLineOffset: 0
m_geometrySortingOrder: 0
m_IsTextObjectScaleStatic: 0
m_VertexBufferAutoSizeReduction: 0
m_useMaxVisibleDescender: 1
m_pageToDisplay: 1
m_margin: {x: 0, y: 0, z: 0, w: 0}
m_isUsingLegacyAnimationComponent: 0
m_isVolumetricText: 0
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!1 &3327801269455335983
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 5159032239856795640}
- component: {fileID: 5965612576668694196}
m_Layer: 5
m_Name: HistoryText
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &5159032239856795640
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3327801269455335983}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 1643897006236830438}
- {fileID: 4891349041236814278}
- {fileID: 572619156665643296}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 2560, y: 100}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &5965612576668694196
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3327801269455335983}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: eaf151163c994d31bfabde12ede0fa98, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::Ichni.Story.UI.DialogueHistoryEntryView
speakerText: {fileID: 6295076540922261237}
contentText: {fileID: 5176135775194928437}
choiceIndicator: {fileID: 4440795975948891807}
--- !u!1 &4440795975948891807
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 572619156665643296}
- component: {fileID: 7654607494418361635}
- component: {fileID: 4823002412552371432}
m_Layer: 5
m_Name: ChoiceIndicator
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &572619156665643296
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4440795975948891807}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 5159032239856795640}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0.5}
m_AnchorMax: {x: 0, y: 0.5}
m_AnchoredPosition: {x: 50, y: 0}
m_SizeDelta: {x: 25, y: 25}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &7654607494418361635
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4440795975948891807}
m_CullTransparentMesh: 1
--- !u!114 &4823002412552371432
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4440795975948891807}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Image
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 0}
m_Type: 0
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 75e7242449b105748a3d9a0864ce1391
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,387 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &1508090727333609782
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 4686535100579303134}
- component: {fileID: 287764825431730093}
- component: {fileID: 604884221485964818}
- component: {fileID: -2678516924990041575}
- component: {fileID: 4403268097300063903}
m_Layer: 5
m_Name: HelperTalk
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &4686535100579303134
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1508090727333609782}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 7502641187092686647}
- {fileID: 3218827982254574115}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 700, y: 270}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &287764825431730093
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1508090727333609782}
m_CullTransparentMesh: 1
--- !u!114 &604884221485964818
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1508090727333609782}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 21300000, guid: 8746c977baa2dfb42a2e1acd589d3c5b, type: 3}
m_Type: 0
m_PreserveAspect: 1
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!225 &-2678516924990041575
CanvasGroup:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1508090727333609782}
m_Enabled: 1
m_Alpha: 1
m_Interactable: 1
m_BlocksRaycasts: 1
m_IgnoreParentGroups: 0
--- !u!114 &4403268097300063903
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1508090727333609782}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e8bca20cc0f14b97944b95bdcf47d506, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::Ichni.Story.StoryHelperTalk
speakerText: {fileID: 966082263634710134}
contentText: {fileID: 5450284912810547205}
showDuration: 0.18
visibleDuration: 4.5
hideDuration: 0.16
initialScaleMultiplier: 0.92
--- !u!1 &3587908678025149924
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 3218827982254574115}
- component: {fileID: 904411096527340721}
- component: {fileID: 5450284912810547205}
m_Layer: 5
m_Name: HelperText
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &3218827982254574115
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3587908678025149924}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 4686535100579303134}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 17.5, y: -7.5}
m_SizeDelta: {x: -85, y: -165}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &904411096527340721
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3587908678025149924}
m_CullTransparentMesh: 1
--- !u!114 &5450284912810547205
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3587908678025149924}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: "\u95F9\u9EBB\u4E86"
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: 73f9a9bb620a2b040ab7347b390452fe, type: 2}
m_sharedMaterial: {fileID: 1291443754377433884, guid: 73f9a9bb620a2b040ab7347b390452fe,
type: 2}
m_fontSharedMaterials: []
m_fontMaterial: {fileID: 0}
m_fontMaterials: []
m_fontColor32:
serializedVersion: 2
rgba: 4294967295
m_fontColor: {r: 1, g: 1, b: 1, a: 1}
m_enableVertexGradient: 0
m_colorMode: 3
m_fontColorGradient:
topLeft: {r: 1, g: 1, b: 1, a: 1}
topRight: {r: 1, g: 1, b: 1, a: 1}
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
bottomRight: {r: 1, g: 1, b: 1, a: 1}
m_fontColorGradientPreset: {fileID: 0}
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 32
m_fontSizeBase: 32
m_fontWeight: 400
m_enableAutoSizing: 1
m_fontSizeMin: 18
m_fontSizeMax: 32
m_fontStyle: 0
m_HorizontalAlignment: 1
m_VerticalAlignment: 256
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
m_horizontalMapping: 0
m_verticalMapping: 0
m_uvLineOffset: 0
m_geometrySortingOrder: 0
m_IsTextObjectScaleStatic: 0
m_VertexBufferAutoSizeReduction: 0
m_useMaxVisibleDescender: 1
m_pageToDisplay: 1
m_margin: {x: 0, y: 0, z: 0, w: 0}
m_isUsingLegacyAnimationComponent: 0
m_isVolumetricText: 0
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!1 &7936195258843532652
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 7502641187092686647}
- component: {fileID: 5167521932169979174}
- component: {fileID: 966082263634710134}
m_Layer: 5
m_Name: HelperName
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &7502641187092686647
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7936195258843532652}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 4686535100579303134}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 200, y: -55}
m_SizeDelta: {x: 300, y: 45}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &5167521932169979174
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7936195258843532652}
m_CullTransparentMesh: 1
--- !u!114 &966082263634710134
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7936195258843532652}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: "\u95F9\u9EBB\u4E86"
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: 73f9a9bb620a2b040ab7347b390452fe, type: 2}
m_sharedMaterial: {fileID: 1291443754377433884, guid: 73f9a9bb620a2b040ab7347b390452fe,
type: 2}
m_fontSharedMaterials: []
m_fontMaterial: {fileID: 0}
m_fontMaterials: []
m_fontColor32:
serializedVersion: 2
rgba: 4294967295
m_fontColor: {r: 1, g: 1, b: 1, a: 1}
m_enableVertexGradient: 0
m_colorMode: 3
m_fontColorGradient:
topLeft: {r: 1, g: 1, b: 1, a: 1}
topRight: {r: 1, g: 1, b: 1, a: 1}
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
bottomRight: {r: 1, g: 1, b: 1, a: 1}
m_fontColorGradientPreset: {fileID: 0}
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 37.2
m_fontSizeBase: 32
m_fontWeight: 400
m_enableAutoSizing: 1
m_fontSizeMin: 18
m_fontSizeMax: 42
m_fontStyle: 0
m_HorizontalAlignment: 1
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
m_horizontalMapping: 0
m_verticalMapping: 0
m_uvLineOffset: 0
m_geometrySortingOrder: 0
m_IsTextObjectScaleStatic: 0
m_VertexBufferAutoSizeReduction: 0
m_useMaxVisibleDescender: 1
m_pageToDisplay: 1
m_margin: {x: 0, y: 0, z: 0, w: 0}
m_isUsingLegacyAnimationComponent: 0
m_isVolumetricText: 0
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 40df11d39d80e2f4785e492c3b1d1a10
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -10,6 +10,7 @@ GameObject:
m_Component:
- component: {fileID: 4290474858249249208}
- component: {fileID: 483471027588563956}
- component: {fileID: 6009244213662721001}
m_Layer: 5
m_Name: StoryTimelineMarker
m_TagString: Untagged
@@ -48,10 +49,55 @@ MonoBehaviour:
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 5fa49b969e984456985eec86f0275d7d, type: 3}
m_Name:
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::Ichni.Story.UI.StoryTimelineMarkerView
markerRect: {fileID: 4290474858249249208}
labelText: {fileID: 3350283590570236064}
button: {fileID: 6009244213662721001}
--- !u!114 &6009244213662721001
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1574009245387883632}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
m_Name: ""
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Button
m_Navigation:
m_Mode: 3
m_WrapAround: 0
m_SelectOnUp: {fileID: 0}
m_SelectOnDown: {fileID: 0}
m_SelectOnLeft: {fileID: 0}
m_SelectOnRight: {fileID: 0}
m_Transition: 0
m_Colors:
m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
m_HighlightedColor: {r: 1, g: 1, b: 1, a: 1}
m_PressedColor: {r: 1, g: 1, b: 1, a: 1}
m_SelectedColor: {r: 1, g: 1, b: 1, a: 1}
m_DisabledColor: {r: 1, g: 1, b: 1, a: 0.5}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:
m_HighlightedSprite: {fileID: 0}
m_PressedSprite: {fileID: 0}
m_SelectedSprite: {fileID: 0}
m_DisabledSprite: {fileID: 0}
m_AnimationTriggers:
m_NormalTrigger: Normal
m_HighlightedTrigger: Highlighted
m_PressedTrigger: Pressed
m_SelectedTrigger: Selected
m_DisabledTrigger: Disabled
m_Interactable: 1
m_TargetGraphic: {fileID: 940605879243566513}
m_OnClick:
m_PersistentCalls:
m_Calls: []
--- !u!1 &4209846266689669036
GameObject:
m_ObjectHideFlags: 0

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e30166288c8c5bd438351410a483e5b0
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: f5d5e70a908942c4b909fdb6034752b7
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 32c3385d4214445468554a51c232cf0f
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 8771390f79aaef64387378be17877c84
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 46fc263f248fc7e4d864bfb43d691615
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
namespace Ichni.RhythmGame.Beatmap
{
public class PositionSync_BM : GameElement_BM
{
public FlexibleBool_BM enabling;
public Guid syncTargetObjectGuid;
public PositionSync_BM()
{
}
public PositionSync_BM(string elementName, Guid elementGuid, List<string> tags,
GameElement_BM attachedElement, FlexibleBool_BM enabling, Guid syncTargetObjectGuid)
: base(elementName, elementGuid, tags, attachedElement)
{
this.enabling = enabling;
this.syncTargetObjectGuid = syncTargetObjectGuid;
}
public override void ExecuteBM()
{
matchedElement = PositionSync.GenerateElement(elementName, elementGuid, tags, false,
GetElement(attachedElementGuid), GetElement(syncTargetObjectGuid),
enabling?.ConvertToGameType() ?? new FlexibleBool());
matchedElement.matchedBM = this;
}
public override void AfterExecute()
{
if (matchedElement is PositionSync positionSync)
{
positionSync.syncTargetObject = GetElement(syncTargetObjectGuid);
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4b7660debb70462f8139aca3ccf9d839

View File

@@ -0,0 +1,20 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
public class FirmStartMenu : MonoBehaviour
{
[RuntimeInitializeOnLoadMethod]
static void Initialize()
{
string startSceneName = "MenuScene";
Scene scene = SceneManager.GetActiveScene();
if (scene.name.Equals(startSceneName))
{
return;
}
SceneManager.LoadScene(startSceneName);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 90353b44635c5f548be73e0fc1dd92ff

View File

@@ -0,0 +1,112 @@
using System;
using System.Collections.Generic;
using Ichni.RhythmGame.Beatmap;
using UnityEngine;
namespace Ichni.RhythmGame
{
/// <summary>
/// Synchronizes this element's position to another element's world position.
/// </summary>
public partial class PositionSync : AnimationBase
{
#region [] Exposed Fields & References
public TransformSubmodule targetTransformSubmodule;
public GameElement syncTargetObject;
public FlexibleBool enabling;
#endregion
#region [] Lifecycle & Factory
public static PositionSync GenerateElement(string elementName, Guid id,
List<string> tags, bool isFirstGenerated, GameElement animatedObject,
GameElement syncTargetObject, FlexibleBool enabling)
{
PositionSync sync = Instantiate(GameManager.Instance.basePrefabs.emptyObject).AddComponent<PositionSync>();
sync.Initialize(elementName, id, tags, isFirstGenerated, animatedObject);
sync.animatedObject = animatedObject;
sync.syncTargetObject = syncTargetObject;
sync.enabling = enabling ?? new FlexibleBool();
sync.animationReturnType = FlexibleReturnType.Before;
sync.targetTransformSubmodule = (animatedObject as IHaveTransformSubmodule)?.transformSubmodule;
return sync;
}
public override void SetDefaultSubmodules()
{
timeDurationSubmodule = new TimeDurationSubmodule(this);
}
public override void OnDelete()
{
base.OnDelete();
targetTransformSubmodule?.MarkPositionDirty();
}
#endregion
#region [] Core Animation Logic
public override void ScheduledUpdate(UpdatePhase phase, float songTime)
{
base.ScheduledUpdate(phase, songTime);
// 与 CreatorStudio 保持一致:这里在 Animation 阶段读取 target world position。
// 如果 target 是 TrackPercentPoint当前帧 TrackFollower 尚未执行,因此读到的可能是上一帧位置。
// 这个一帧延后是有意保留的兼容行为;好处是随后 Apply 阶段能刷新被同步的 PathNode/Track
// 让本帧后续 TrackFollower/Note 使用更新后的轨道数据。
if (phase == UpdatePhase.Animation && enabling != null && enabling.value)
{
ApplyPositionSync();
}
}
protected override void UpdateAnimation(float songTime, bool forceUpdate)
{
if (enabling == null)
{
animationReturnType = FlexibleReturnType.None;
return;
}
enabling.UpdateFlexibleBool(songTime);
if (enabling.value)
{
animationReturnType = FlexibleReturnType.MiddleExecuting;
targetTransformSubmodule?.MarkPositionDirty();
}
else if (animationReturnType != FlexibleReturnType.MiddleInterval)
{
animationReturnType = FlexibleReturnType.MiddleInterval;
targetTransformSubmodule?.MarkPositionDirty();
}
}
private void ApplyPositionSync()
{
if (targetTransformSubmodule == null || syncTargetObject == null)
{
return;
}
Transform self = targetTransformSubmodule.attachedGameElement.transform;
Transform parent = self.parent;
Vector3 targetWorldPosition = syncTargetObject.transform.position;
Vector3 desiredLocalPosition = parent != null
? parent.InverseTransformPoint(targetWorldPosition)
: targetWorldPosition;
Vector3 syncOffset = desiredLocalPosition - targetTransformSubmodule.originalPosition;
targetTransformSubmodule.positionOffset += syncOffset;
targetTransformSubmodule.MarkPositionDirty();
}
public override void ApplyTimeOffset(float offset)
{
base.ApplyTimeOffset(offset);
enabling?.animations.ForEach(anim => anim.ApplyTimeOffset(offset));
}
#endregion
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2931ecd4815b44a4b105371752d14bcd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -136,7 +136,7 @@ namespace Ichni.RhythmGame
}
private bool HaveAnimation() => attachedGameElement.childElementList
.Any(element => element is Displacement or Swirl or Scale or LookAt);
.Any(element => element is Displacement or Swirl or Scale or LookAt or PositionSync);
public override void CheckAndRemoveObservers()
{

View File

@@ -1,4 +1,5 @@
using Ichni.RhythmGame.Beatmap;
using SLSUtilities.Rendering.PostProcessing;
using UnityEngine;
using UnityEngine.Rendering.Universal;
@@ -10,11 +11,13 @@ namespace Ichni.RhythmGame
public float peak;
public AnimationCurve intensityCurve;
private Bloom _bloom;
private AnimeBloom _bloom;
private static float _bloomBasicValue = -1;
#endregion
#region [] Initialization
public BloomEffect(float effectTime, float peak, AnimationCurve intensityCurve)
public BloomEffect(float effectTime, float peak, AnimationCurve intensityCurve)
: base(effectTime) // 激活时间线
{
this.effectTime = effectTime;
@@ -28,6 +31,10 @@ namespace Ichni.RhythmGame
{
PostProcessingManager.GlobalVolume.profile.TryGet(out _bloom);
}
if (_bloomBasicValue < 0 && _bloom != null)
{
_bloomBasicValue = _bloom.intensity.value;
}
}
#endregion
@@ -42,7 +49,7 @@ namespace Ichni.RhythmGame
if (_bloom != null)
{
float currentIntensity = intensityCurve.Evaluate(effectProgressPercent) * peak;
_bloom.intensity.value = currentIntensity;
_bloom.intensity.value = _bloomBasicValue + currentIntensity;
}
}
@@ -52,7 +59,7 @@ namespace Ichni.RhythmGame
private void ResetEffect()
{
if (_bloom != null) _bloom.intensity.value = 0f;
if (_bloom != null) _bloom.intensity.value = _bloomBasicValue;
}
#endregion

View File

@@ -126,7 +126,7 @@ namespace Ichni.RhythmGame
}
protected void SetEnableEmission() => meshRenderer.material.SetInt("_EnableEmission", enableEmission ? 1 : 0);
protected void SetEnableZWrite() => meshRenderer.material.SetInt("_ZWrite", zWrite ? 1 : 0);
protected void SetEnableZWrite() => meshRenderer.material.SetFloat("_ZWrite", zWrite ? 1 : 0);
protected void SetEmissionIntensity() => meshRenderer.material.SetColor("_EmissionColor", Color.white * Mathf.Pow(2, emissionIntensity));
protected void SetUV()
{

View File

@@ -2,6 +2,7 @@ using System.Collections;
using System.Collections.Generic;
using AK.Wwise;
using Ichni.Menu;
using Ichni.Story;
using UnityEngine;
using UnityEngine.Serialization;
@@ -28,6 +29,12 @@ namespace Ichni
/// </summary>
public MenuReturnDestination menuReturnDestination;
/// <summary>
/// 由剧情 SongBlock 发起的单次选曲上下文。它是运行时路由数据,不属于玩家存档;
/// 仅用于校验目标歌曲、决定返回页面并还原剧情树浏览位置。
/// </summary>
[System.NonSerialized] public StorySongEntryContext storySongEntryContext = new StorySongEntryContext();
public ChapterSelectionUnit chapter;
public SongItemData song;
public DifficultyData difficulty;
@@ -57,7 +64,12 @@ namespace Ichni
{
// 保留旧入口,供现有选曲流程在播放过场前预先写入歌曲信息。
// 真正进入 GameScene 时MenuManager.EnterGame 会补充 SongSelection 返回目标。
PrepareGameLaunch(chapter, song, difficulty, MenuReturnDestination.None);
if (PrepareGameLaunch(chapter, song, difficulty, MenuReturnDestination.None))
{
// 只有玩家真正确认“进入游戏”且命中剧情目标歌曲时,才完成对应 SongBlock。
// 单纯从剧情打开选曲、浏览其它歌曲或返回菜单都不会写入剧情进度。
StoryManager.instance?.HandleSongSelectionConfirmed(chapter, song);
}
}
/// <summary>
@@ -79,6 +91,10 @@ namespace Ichni
chapterSwitch = chapter.chapterSwitch;
songSwitch = song.songSwitch;
menuReturnDestination = returnDestination;
// “确认进入游戏”是歌曲尝试的统一边界。无论入口来自普通选曲、Story SongBlock 或教程,
// 都由 SongSaves 记录这一次尝试StoryTree 会在构建/返回时据此同步 SongBlock 完成状态。
GameSaveManager.instance?.SongSaveModule?.MarkSongTried(song.songName);
return true;
}

View File

@@ -81,6 +81,7 @@ namespace Ichni
// 不能依赖旧场景中残留的 Block 实例。
StoryManager.instance?.OpenChapter(ChapterSelectionManager.instance.currentChapter.chapterIndex);
storyUIPage.FadeIn();
StoryManager.instance?.RestoreStoryAfterGameReturn();
}
// 返回目标是一次性运行态信息。无论是否能恢复页面,都不能让它在下次重进菜单时重复生效。
@@ -96,7 +97,14 @@ namespace Ichni
{
public void EnterGame()
{
EnterGame(MenuReturnDestination.SongSelection);
// 剧情 SongBlock 仅在玩家确认进入指定目标歌曲后才要求返回剧情;
// 普通选曲与从剧情入口改选其它歌曲都继续返回选曲页。
StorySongEntryContext context = InformationTransistor.instance?.storySongEntryContext;
MenuReturnDestination destination = context != null && context.isActive &&
context.returnToStoryAfterGame
? MenuReturnDestination.Story
: MenuReturnDestination.SongSelection;
EnterGame(destination);
}
/// <summary>

View File

@@ -40,14 +40,14 @@ namespace Ichni.Story
[LabelText("Emotion Sprites")]
[ShowIf("portraitStyle", PortraitStyle.Sprite)]
[DictionaryDrawerSettings(KeyLabel = "Emotion Name", ValueLabel = "Sprite")]
[InfoBox("Key 为情绪名(如 normal / happy / angryValue 为对应 Sprite 资产。")]
[Tooltip("Key 为情绪名(如 normal / happy / angryValue 为对应 Sprite 资产。")]
public Dictionary<string, Sprite> emotionSprites = new Dictionary<string, Sprite>();
// ── Dynamic (Live2D / Spine) ─────────────────────────────────────────────
[LabelText("Dynamic Portrait Prefab")]
[ShowIf("IsDynamicPortrait")]
[InfoBox("Live2D / Spine 立绘预制体,需挂载实现 IPortraitRenderer 接口的组件(阶段 3 制作)。")]
[Tooltip("Live2D / Spine 立绘预制体,需挂载实现 IPortraitRenderer 接口的组件。")]
public GameObject dynamicPortraitPrefab;
private bool IsDynamicPortrait => portraitStyle == PortraitStyle.Live2D || portraitStyle == PortraitStyle.Spine;

View File

@@ -14,7 +14,7 @@ namespace Ichni.Story
{
[LabelText("Characters")]
[ListDrawerSettings(ShowFoldout = true, DraggableItems = true)]
[InfoBox("将所有角色的 CharacterData 资产拖入此列表,系统运行时会自动按 characterId 建立索引。")]
[Tooltip("将所有角色的 CharacterData 资产拖入此列表,系统运行时会自动按 characterId 建立索引。")]
public List<CharacterData> characters = new List<CharacterData>();
// 运行时缓存,首次 TryGet 时延迟构建

View File

@@ -26,6 +26,25 @@ namespace Ichni.Story
Tutorial
}
/// <summary>
/// 一个 Block 如何自动处理指向它的直接连接线。
/// <para>连接线表达基础流程关系,因此默认要求所有直接前置 Block 都已完成;该结果会与
/// <see cref="StoryBlockDefinition.unlockCondition"/> 按 AND 合并。</para>
/// <para>路线汇合时应使用 <see cref="RequireAnyDirectPredecessor"/>;仅用于视觉连线、
/// 不应影响可用性的连接则使用 <see cref="IgnoreConnections"/>。</para>
/// </summary>
public enum ConnectedBlockUnlockRule
{
/// <summary>默认值:全部直接前置 Block 都必须完成。</summary>
RequireAllDirectPredecessors = 0,
/// <summary>至少一个直接前置 Block 完成即可,适用于互斥分支的汇合节点。</summary>
RequireAnyDirectPredecessor = 1,
/// <summary>忽略连接线,只按手写 Unlock Condition 判断。</summary>
IgnoreConnections = 2
}
/// <summary>
/// TextBlock 的叙事层级。层级只决定剧情树中的视觉样式和 checkpoint 候选资格,
/// 不会自行改变 Block 的解锁条件或 Yarn 执行逻辑。
@@ -53,34 +72,59 @@ namespace Ichni.Story
/// 故事树中单个 block 的完整数据定义,由 <see cref="StoryData"/> 持有。
/// </summary>
[InlineProperty]
[HideReferenceObjectPicker]
[Serializable]
public class StoryBlockDefinition
{
[FoldoutGroup("$blockId", false)]
[LabelText("Block ID")]
// 仅用于 Odin Inspector 的动态折叠标题,不参与序列化或运行时状态推导。
// 在列表中即可识别 Block 的身份、类型与布局位置,减少频繁展开确认的需要。
private string EditorHeader => string.IsNullOrWhiteSpace(blockId)
? "New Block"
: $"{blockId} · {blockType} · C{gridColumn:0.##} / R{gridRow:0.##}";
[FoldoutGroup("$EditorHeader", false)]
[HorizontalGroup("$EditorHeader/Identity", 0.58f)]
[LabelText("ID")]
[Tooltip("Block 的稳定唯一 ID。连接、条件、Timeline Marker 与存档均依赖该值;建议使用英文、数字和下划线。")]
public string blockId;
[FoldoutGroup("$blockId")]
[FoldoutGroup("$EditorHeader")]
[HorizontalGroup("$EditorHeader/Identity")]
[LabelText("Type")]
[Tooltip("决定该 Block 的运行时入口与下方显示的专属配置。")]
public StoryBlockType blockType;
[FoldoutGroup("$blockId")]
[LabelText("Grid Column")]
[FoldoutGroup("$EditorHeader")]
[HorizontalGroup("$EditorHeader/Layout")]
[LabelText("Column")]
[Tooltip("故事树网格中的列:向右为正,(0,0) 位于容器左侧中部。可为负数与小数(用于微调)。")]
public float gridColumn;
[FoldoutGroup("$blockId")]
[LabelText("Grid Row")]
[FoldoutGroup("$EditorHeader")]
[HorizontalGroup("$EditorHeader/Layout")]
[LabelText("Row")]
[Tooltip("故事树网格中的行向下为正、向上为负0 对应垂直中线。可为小数(用于微调)。")]
public float gridRow;
[FoldoutGroup("$blockId")]
[LabelText("Next Block IDs")]
[FoldoutGroup("$EditorHeader/Flow", false)]
[LabelText("Next Blocks")]
[Tooltip("从本 Block 指向的后续 Block ID。该列表既决定连接线也会成为后续 Block 的自动前置条件来源。")]
[ListDrawerSettings(ShowFoldout = true, DefaultExpandedState = false, ShowItemCount = true)]
public List<string> nextBlockIds = new List<string>();
[FoldoutGroup("$blockId")]
[LabelText("Unlock Condition")]
[InfoBox("留空表示无条件解锁(章节起始即可用)。")]
/// <summary>
/// 自动把所有指向本 Block 的直接连接线转化为基础解锁条件。
/// 没有前置连接线的章节起始 Block 不受此字段限制;默认“全部前置完成”再与手写条件按 AND 合并。
/// </summary>
[FoldoutGroup("$EditorHeader/Flow")]
[LabelText("Connected Rule")]
[Tooltip("默认 Require All所有直接前置 Block 完成后才可用。互斥路线汇合使用 Require Any纯视觉连接使用 Ignore。")]
public ConnectedBlockUnlockRule connectedBlockUnlockRule =
ConnectedBlockUnlockRule.RequireAllDirectPredecessors;
[FoldoutGroup("$EditorHeader/Rules", false)]
[LabelText("Unlock")]
[Tooltip("可选的额外可用条件;它与 Connected Rule 按 AND 合并。没有前置连接的起始 Block 通常留空即可。")]
public StoryCondition unlockCondition = new StoryCondition();
/// <summary>
@@ -88,38 +132,41 @@ namespace Ichni.Story
/// 因而可直接使用剧情变量、Block 完成状态及 AND / OR / NOT 组合。
/// 条件满足时,该 Block 的状态固定为 Forbidden且优先于 Completed / Current / Locked。
/// </summary>
[FoldoutGroup("$blockId")]
[LabelText("Forbidden Condition")]
[InfoBox("可选。留空表示此 Block 不会被条件禁用;满足时显示为 Forbidden 且不可点击。")]
[FoldoutGroup("$EditorHeader/Rules")]
[LabelText("Forbidden")]
[Tooltip("可选。满足时该 Block 固定显示为 Forbidden 且不可点击;留空表示不会被条件禁用。")]
public StoryCondition forbiddenCondition = new StoryCondition();
// ── Text Block ──────────────────────────────────────────────────────────
[FoldoutGroup("$blockId")]
[FoldoutGroup("$EditorHeader/Text Content", false)]
[ShowIf("blockType", StoryBlockType.Text)]
[LabelText("Yarn Node Name")]
[LabelText("Yarn Node")]
[Tooltip("点击 TextBlock 后由 Yarn Spinner 开始执行的节点名。")]
public string yarnNodeName;
[FoldoutGroup("$blockId")]
[FoldoutGroup("$EditorHeader/Text Content")]
[ShowIf("blockType", StoryBlockType.Text)]
[LabelText("Title Key (Localization)")]
[LabelText("Title Key")]
[Tooltip("该 TextBlock 标题使用的 Unity Localization Entry Key留空时视觉层可使用 Block ID 作为开发阶段回退。")]
public string titleKey;
/// <summary>
/// TextBlock 的视觉和叙事层级。Important 用于主线转折与后续 checkpoint
/// Secondary 用于支线或补充剧情。默认 Important以保持现有 TextBlock 的呈现语义。
/// </summary>
[FoldoutGroup("$blockId")]
[FoldoutGroup("$EditorHeader/Text Content")]
[ShowIf("blockType", StoryBlockType.Text)]
[LabelText("Text Importance")]
[LabelText("Importance")]
[Tooltip("Important 用于主线转折与后续 CheckpointSecondary 用于支线或补充剧情,并决定对应的 Block Prefab。")]
public TextBlockImportance textImportance = TextBlockImportance.Important;
// ── Song Block ──────────────────────────────────────────────────────────
[FoldoutGroup("$blockId")]
[FoldoutGroup("$EditorHeader/Song Content", false)]
[ShowIf("blockType", StoryBlockType.Song)]
[LabelText("Song Name (Stable ID)")]
[InfoBox("对应 ChapterSelectionUnit 中 SongItemData.songName 的稳定歌曲标识。它用于查找歌曲资料与进入选曲,不能填写 displaySongName、翻译文本或作曲者名称。")]
[LabelText("Song ID")]
[Tooltip("对应 ChapterSelectionUnit 中 SongItemData.songName 的稳定歌曲标识。不能填写 displaySongName、翻译文本或作曲者名称。")]
public string songName;
/// <summary>
@@ -127,18 +174,19 @@ namespace Ichni.Story
/// Mask 可视区域固定为当前 Prefab 的尺寸;在不露出上下空白的前提下,安全范围为 -74.25 到 74.25。
/// 该值是最终坐标而非偏移量0 表示居中,负值向下移动,正值向上移动。
/// </summary>
[FoldoutGroup("$blockId")]
[FoldoutGroup("$EditorHeader/Song Content")]
[ShowIf("blockType", StoryBlockType.Song)]
[LabelText("Illustration Local Y")]
[LabelText("Illustration Y")]
[Range(-74.25f, 74.25f)]
[InfoBox("用于微调 16:9 插画在 SongBlock Mask 中的纵向裁切。请保持 SongBlockUI 的 Illustration 尺寸为 400 x 225。")]
[Tooltip("微调 16:9 插画在 SongBlock Mask 中的纵向裁切。SongBlockUI 的 Illustration 尺寸应保持为 400 × 225。")]
public float illustrationLocalY;
// ── Tutorial Block ──────────────────────────────────────────────────────
[FoldoutGroup("$blockId")]
[FoldoutGroup("$EditorHeader/Tutorial Content", false)]
[ShowIf("blockType", StoryBlockType.Tutorial)]
[LabelText("Tutorial Name")]
[LabelText("Display Name")]
[Tooltip("仅用于 StoryBlock 的显示与编辑识别,不参与教程查找。")]
public string tutorialName;
/// <summary>
@@ -146,29 +194,32 @@ namespace Ichni.Story
/// 只用于运行时查找,不使用展示名称或章节名称作为逻辑匹配条件。
/// 命名统一使用小写英文、数字和下划线,例如 <c>chapter0_intro</c>。
/// </summary>
[FoldoutGroup("$blockId")]
[FoldoutGroup("$EditorHeader/Tutorial Content")]
[ShowIf("blockType", StoryBlockType.Tutorial)]
[LabelText("Tutorial Key")]
[Tooltip("TutorialCollection 中的稳定 Key。使用小写英文、数字和下划线例如 chapter0_intro。")]
public string tutorialKey;
/// <summary>
/// 教程被玩家选择“游玩”或“跳过”后写入的全局剧情变量。
/// 教程被玩家选择“游玩”或“跳过”后写入的当前章节剧情变量。
/// 后续 Block 应通过 <see cref="VariableCondition"/> 检查该变量是否等于 1 来解锁。
/// 命名统一使用小写英文、数字和下划线,例如
/// <c>story_tutorial_chapter0_intro_resolved</c>。
/// </summary>
[FoldoutGroup("$blockId")]
[FoldoutGroup("$EditorHeader/Tutorial Content")]
[ShowIf("blockType", StoryBlockType.Tutorial)]
[LabelText("Progress Variable")]
[Tooltip("玩家选择游玩或跳过教程后写入 true 的章节剧情变量;后续 Block 可通过条件检查它。")]
public string tutorialProgressVariable;
/// <summary>
/// 要启动的教程难度的稳定存档 ID对应 <see cref="Ichni.Menu.DifficultyData.saveDifficultyId"/>。
/// 不使用难度列表下标,避免调整难度排列后教程进入错误谱面。
/// </summary>
[FoldoutGroup("$blockId")]
[FoldoutGroup("$EditorHeader/Tutorial Content")]
[ShowIf("blockType", StoryBlockType.Tutorial)]
[LabelText("Tutorial Difficulty Save ID")]
[LabelText("Difficulty Save ID")]
[Tooltip("要启动教程谱面的 DifficultyData.saveDifficultyId。不要使用难度列表下标以免排序变化后指向错误谱面。")]
public int tutorialDifficultySaveId = -1;
/// <summary>
@@ -196,12 +247,13 @@ namespace Ichni.Story
/// 例如 unlockCondition 留空即允许,而 forbiddenCondition 留空即不禁用。</para>
/// </summary>
[InlineProperty]
[HideReferenceObjectPicker]
[Serializable]
public class StoryCondition
{
[HideLabel]
[SerializeReference]
[InfoBox("留空表示未配置条件。可选择 All Of(AND) / Any Of(OR) / Not 复合节点进行任意嵌套。")]
[Tooltip("留空表示未配置条件。可选择 All Of(AND) / Any Of(OR) / Not 复合节点进行任意嵌套。")]
public StoryConditionNode root;
/// <summary>
@@ -219,5 +271,15 @@ namespace Ichni.Story
{
return root != null && root.Evaluate(getVariable, isBlockCompleted);
}
#if UNITY_EDITOR
/// <summary>
/// Odin 条件 Drawer 使用的只读摘要;不参与运行时求值和序列化。
/// </summary>
public string GetEditorSummary()
{
return root == null ? "None" : root.GetEditorSummary();
}
#endif
}
}

View File

@@ -19,6 +19,13 @@ namespace Ichni.Story
/// <param name="getVariable">按变量名返回其整型值的委托。</param>
/// <param name="isBlockCompleted">按 blockId 判断该 Block 是否已完成的委托。</param>
public abstract bool Evaluate(Func<string, int> getVariable, Func<string, bool> isBlockCompleted);
#if UNITY_EDITOR
/// <summary>
/// 返回 Inspector 中使用的紧凑摘要。条件编辑器只读取该文本,不影响运行时求值逻辑。
/// </summary>
public virtual string GetEditorSummary() => GetType().Name;
#endif
}
/// <summary>
@@ -43,6 +50,10 @@ namespace Ichni.Story
return true;
}
#if UNITY_EDITOR
public override string GetEditorSummary() => $"All Of ({conditions?.Count ?? 0})";
#endif
}
/// <summary>
@@ -70,6 +81,10 @@ namespace Ichni.Story
return false;
}
#if UNITY_EDITOR
public override string GetEditorSummary() => $"Any Of ({conditions?.Count ?? 0})";
#endif
}
/// <summary>
@@ -87,6 +102,10 @@ namespace Ichni.Story
{
return condition == null || !condition.Evaluate(getVariable, isBlockCompleted);
}
#if UNITY_EDITOR
public override string GetEditorSummary() => $"Not ({condition?.GetEditorSummary() ?? "None"})";
#endif
}
/// <summary>
@@ -97,12 +116,20 @@ namespace Ichni.Story
public class BlockCompletedCondition : StoryConditionNode
{
[LabelText("Block ID")]
[LabelWidth(50)]
[Tooltip("必须已完成的 StoryBlock ID。可用于表达显式前置条件通常连接关系已由 Connected Rule 自动处理。")]
public string blockId;
public override bool Evaluate(Func<string, int> getVariable, Func<string, bool> isBlockCompleted)
{
return !string.IsNullOrEmpty(blockId) && isBlockCompleted(blockId);
}
#if UNITY_EDITOR
public override string GetEditorSummary() => string.IsNullOrWhiteSpace(blockId)
? "Block Completed: <missing ID>"
: $"Block Completed: {blockId}";
#endif
}
/// <summary>
@@ -113,13 +140,22 @@ namespace Ichni.Story
[LabelText("Variable Compare")]
public class VariableCondition : StoryConditionNode
{
[LabelText("Variable Name")]
[HorizontalGroup("VariableComparison", 0.50f)]
[LabelText("Var")]
[LabelWidth(24)]
[Tooltip("章节剧情变量名。使用小写英文、数字和下划线;可由 Yarn 的 set_* 指令写入。")]
public string variableName;
[LabelText("Comparison")]
[HorizontalGroup("VariableComparison", 0.22f)]
[LabelText("Op")]
[LabelWidth(20)]
[Tooltip("变量值与目标值之间的比较方式。")]
public VariableComparison comparison;
[HorizontalGroup("VariableComparison")]
[LabelText("Value")]
[LabelWidth(35)]
[Tooltip("用于比较的整数目标值。Bool 条件使用 0 / 1。")]
public int value;
public override bool Evaluate(Func<string, int> getVariable, Func<string, bool> isBlockCompleted)
@@ -136,5 +172,67 @@ namespace Ichni.Story
_ => false
};
}
#if UNITY_EDITOR
public override string GetEditorSummary()
{
string variable = string.IsNullOrWhiteSpace(variableName) ? "<missing variable>" : variableName;
string operation = comparison switch
{
VariableComparison.Equal => "==",
VariableComparison.NotEqual => "!=",
VariableComparison.Greater => ">",
VariableComparison.GreaterOrEqual => ">=",
VariableComparison.Less => "<",
VariableComparison.LessOrEqual => "<=",
_ => "?"
};
return $"{variable} {operation} {value}";
}
#endif
}
}
#if UNITY_EDITOR
namespace Ichni.Story.Editor
{
/// <summary>
/// 通用 StoryCondition 的 Odin Inspector Drawer。
/// <para>Unlock、Forbidden 与 Helper Availability 共用同一条件模型;此 Drawer 只改变编辑器中的
/// 折叠呈现方式,不修改条件树的序列化结构或运行时求值结果。</para>
/// <para>默认仅显示可读摘要。需要调整条件时再展开,避免 SerializeReference 条件树长期占据 Block 的编辑空间。</para>
/// </summary>
public class StoryConditionDrawer : Sirenix.OdinInspector.Editor.OdinValueDrawer<StoryCondition>
{
private bool _isExpanded;
protected override void DrawPropertyLayout(UnityEngine.GUIContent label)
{
StoryCondition condition = ValueEntry.SmartValue;
string summary = condition == null ? "None" : condition.GetEditorSummary();
// 父属性若保留了简短标签(例如 Helper 的 Availability则与摘要合并显示
// StoryBlock 中使用 HideLabel 时只保留摘要,避免重复占用左侧标签列。
string prefix = label == null || string.IsNullOrWhiteSpace(label.text)
? string.Empty
: $"{label.text}: ";
UnityEngine.GUIContent foldoutLabel = new UnityEngine.GUIContent(
prefix + summary,
"留空表示未配置条件。展开后可选择 All Of (AND)、Any Of (OR)、Not、Block Completed 或 Variable Compare。"
);
_isExpanded = UnityEditor.EditorGUILayout.Foldout(_isExpanded, foldoutLabel, true);
if (!_isExpanded)
{
return;
}
UnityEditor.EditorGUI.indentLevel++;
// 继续调用 Odin 的默认 SerializeReference 绘制链,使类型选择、嵌套条件和 Undo 行为保持原样。
CallNextDrawer(UnityEngine.GUIContent.none);
UnityEditor.EditorGUI.indentLevel--;
}
}
}
#endif

View File

@@ -0,0 +1,780 @@
#if UNITY_EDITOR
using System.Collections.Generic;
using System.Linq;
using Sirenix.OdinInspector;
using UnityEditor;
using UnityEngine;
namespace Ichni.Story
{
/// <summary>
/// StoryData 的编辑器扩展:集中存放只读 Validation 与故事树 Preview。
/// 与运行时数据查询拆分后,修改剧情结构时无需在同一文件中穿过大量 Inspector 绘制代码。
/// </summary>
public partial class StoryData
{
private enum StoryPreviewMode { Detailed, Simple }
// 编辑器 UI 临时状态(不参与序列化)
[System.NonSerialized] private StoryPreviewMode _previewMode = StoryPreviewMode.Detailed;
[System.NonSerialized] private Vector2 _previewPan;
[System.NonSerialized] private bool _isPanningPreview;
[System.NonSerialized] private bool _isPreviewExpanded;
[System.NonSerialized] private bool _requestPreviewFit;
private const float PreviewViewportHeight = 380f;
private const float PreviewPaddingLeft = 24f;
// 预览与运行时共用同一套“列 / 行代表 Block 中心点”的布局语义。
// 这里的尺寸直接对应当前四种 Block Prefab 的设计尺寸;若未来修改 Prefab 尺寸,必须同步更新本组常量。
private const float ImportantBlockWidth = 600f;
private const float ImportantBlockHeight = 375f;
private const float SecondaryBlockWidth = 400f;
private const float SecondaryBlockHeight = 200f;
private const float SongBlockWidth = 400f;
private const float SongBlockHeight = 100f;
private const float TutorialBlockWidth = 400f;
private const float TutorialBlockHeight = 100f;
// 与 StoryTreeController 默认 columnStep / rowStep 保持比例一致,
// 因而预览中的相邻列、行间距也能反映运行时的实际布局关系。
private const float RuntimeColumnStep = 720f;
private const float RuntimeRowStep = 525f;
private const float DetailedPreviewScale = 0.25f;
private const float SimplePreviewScale = 0.10f;
[PropertyOrder(-9)]
[Button("Validate Story Data", ButtonSizes.Medium)]
[Tooltip("只读检查当前 StoryData 的引用、ID 与必要字段,不会自动修改资产,也不会检查歌曲/谱面的导入内容。")]
private void ValidateStoryData()
{
List<string> issues = new List<string>();
HashSet<string> blockIds = new HashSet<string>();
HashSet<string> variableKeys = new HashSet<string>();
HashSet<string> markerIds = new HashSet<string>();
if (string.IsNullOrWhiteSpace(chapterIndex))
{
issues.Add("[Error] Chapter Index 为空。");
}
if (yarnProject == null)
{
issues.Add("[Warning] Yarn Project 未配置TextBlock 无法开始对应的 Yarn 节点。");
}
foreach (StoryVariableDefinition variable in initialVariables)
{
if (variable == null)
{
issues.Add("[Warning] Initial Variables 中存在空项。");
continue;
}
if (!IsValidStableKey(variable.key))
{
issues.Add($"[Warning] Initial Variable Key '{variable.key}' 为空或不符合小写英文、数字、下划线的命名规则。");
continue;
}
if (!variableKeys.Add(variable.key))
{
issues.Add($"[Error] Initial Variable Key '{variable.key}' 重复。");
}
}
foreach (StoryBlockDefinition block in blocks)
{
if (block == null)
{
issues.Add("[Warning] Blocks 中存在空项。");
continue;
}
if (string.IsNullOrWhiteSpace(block.blockId))
{
issues.Add("[Error] 存在未填写 Block ID 的 Block。");
continue;
}
if (!blockIds.Add(block.blockId))
{
issues.Add($"[Error] Block ID '{block.blockId}' 重复。");
}
}
foreach (StoryBlockDefinition block in blocks)
{
if (block == null || string.IsNullOrWhiteSpace(block.blockId))
{
continue;
}
ValidateBlock(block, blockIds, issues);
ValidateCondition(block.unlockCondition, $"Block '{block.blockId}' Unlock Condition", blockIds, issues);
ValidateCondition(block.forbiddenCondition, $"Block '{block.blockId}' Forbidden Condition", blockIds, issues);
}
ValidateTextBlockYarnNodes(issues);
HashSet<string> reachableBlockIds = ValidateStoryGraph(blockIds, issues);
foreach (StoryTimelineMarkerDefinition marker in timelineMarkers)
{
if (marker == null)
{
issues.Add("[Warning] Timeline Markers 中存在空项。");
continue;
}
if (!IsValidStableKey(marker.markerId))
{
issues.Add($"[Error] Timeline Marker ID '{marker.markerId}' 为空或不符合命名规则。");
}
else if (!markerIds.Add(marker.markerId))
{
issues.Add($"[Error] Timeline Marker ID '{marker.markerId}' 重复。");
}
StoryBlockDefinition anchor = GetBlock(marker.anchorBlockId);
if (anchor == null)
{
issues.Add($"[Error] Timeline Marker '{marker.markerId}' 指向不存在的 Anchor Block '{marker.anchorBlockId}'。");
}
else if (anchor.blockType != StoryBlockType.Text)
{
issues.Add($"[Error] Timeline Marker '{marker.markerId}' 的 Anchor Block '{marker.anchorBlockId}' 不是 TextBlock。");
}
if (string.IsNullOrWhiteSpace(marker.labelKey))
{
issues.Add($"[Warning] Timeline Marker '{marker.markerId}' 未配置 Label Key。");
}
}
ValidateTimelineMarkerAnchors(reachableBlockIds, issues);
ValidateExclusiveRouteGroups(blockIds, issues);
ValidateLegacyYarnSyntax(issues);
if (issues.Count == 0)
{
Debug.Log($"[StoryData] '{name}' validation passed.", this);
}
else
{
// Unity Console 的折叠列表不会为单条日志中的换行自动增加行高,
// 把整份报告拼成多行字符串会导致文本重叠。摘要与每个问题分别输出,
// 既保持 Console 可读,也能让使用者逐条双击定位所属 StoryData。
Debug.LogWarning($"[StoryData] '{name}' validation found {issues.Count} issue(s). See the following entries.", this);
for (int index = 0; index < issues.Count; index++)
{
string issue = issues[index];
string message = $"[StoryData] '{name}' [{index + 1}/{issues.Count}] {issue}";
if (issue.StartsWith("[Error]"))
Debug.LogError(message, this);
else
Debug.LogWarning(message, this);
}
}
}
/// <summary>
/// 检查单个 Block 的必要配置。该方法只报告明确会导致运行时入口失效的字段,
/// 不检查歌曲或谱面导入结果,以免把内容制作流程变成阻断式校验。
/// </summary>
private static void ValidateBlock(StoryBlockDefinition block, HashSet<string> blockIds, List<string> issues)
{
HashSet<string> nextIds = new HashSet<string>();
foreach (string nextId in block.nextBlockIds)
{
if (string.IsNullOrWhiteSpace(nextId))
{
issues.Add($"[Warning] Block '{block.blockId}' 的 Next Blocks 中存在空 ID。");
}
else if (!blockIds.Contains(nextId))
{
issues.Add($"[Error] Block '{block.blockId}' 指向不存在的 Next Block '{nextId}'。");
}
else if (!nextIds.Add(nextId))
{
issues.Add($"[Warning] Block '{block.blockId}' 重复指向 Next Block '{nextId}'。");
}
}
switch (block.blockType)
{
case StoryBlockType.Text when string.IsNullOrWhiteSpace(block.yarnNodeName):
issues.Add($"[Warning] TextBlock '{block.blockId}' 未配置 Yarn Node。");
break;
case StoryBlockType.Song when string.IsNullOrWhiteSpace(block.songName):
issues.Add($"[Warning] SongBlock '{block.blockId}' 未配置 Song ID。");
break;
case StoryBlockType.Tutorial:
if (string.IsNullOrWhiteSpace(block.tutorialKey))
issues.Add($"[Warning] TutorialBlock '{block.blockId}' 未配置 Tutorial Key。");
if (string.IsNullOrWhiteSpace(block.tutorialProgressVariable))
issues.Add($"[Warning] TutorialBlock '{block.blockId}' 未配置 Progress Variable。");
if (block.tutorialDifficultySaveId < 0)
issues.Add($"[Warning] TutorialBlock '{block.blockId}' 未配置有效的 Difficulty Save ID。");
break;
}
}
/// <summary>
/// 校验 TextBlock 的 Yarn 节点是否真实存在于已编译的 YarnProject及是否被多个 Block 共用。
/// 后者并非必然错误,但通常意味着复制 Block 后忘记替换 Node因此仅报告 Warning。
/// </summary>
private void ValidateTextBlockYarnNodes(List<string> issues)
{
if (yarnProject?.Program == null)
return;
Dictionary<string, List<string>> blockIdsByNode = new Dictionary<string, List<string>>();
foreach (StoryBlockDefinition block in blocks)
{
if (block == null || block.blockType != StoryBlockType.Text || string.IsNullOrWhiteSpace(block.yarnNodeName))
continue;
if (!yarnProject.Program.Nodes.ContainsKey(block.yarnNodeName))
issues.Add($"[Error] TextBlock '{block.blockId}' 指向的 Yarn Node '{block.yarnNodeName}' 不存在于当前 Yarn Project。" );
if (!blockIdsByNode.TryGetValue(block.yarnNodeName, out List<string> referencedBlocks))
{
referencedBlocks = new List<string>();
blockIdsByNode[block.yarnNodeName] = referencedBlocks;
}
referencedBlocks.Add(block.blockId);
}
foreach (KeyValuePair<string, List<string>> pair in blockIdsByNode)
{
if (pair.Value.Count > 1)
issues.Add($"[Warning] Yarn Node '{pair.Key}' 被多个 TextBlock 共用:{string.Join(", ", pair.Value)}。确认这是有意复用,而非遗漏修改 Node。" );
}
}
/// <summary>
/// 对 StoryData 的连接图做只读的结构检查:入口、不可达 Block、环路、非向前连接与终点。
/// 这些检查不评价具体剧情质量,只定位最容易造成“永远无法游玩”或“无法结束章节”的配置问题。
/// </summary>
private HashSet<string> ValidateStoryGraph(HashSet<string> blockIds, List<string> issues)
{
Dictionary<string, List<string>> adjacency = new Dictionary<string, List<string>>();
Dictionary<string, int> indegree = new Dictionary<string, int>();
foreach (string id in blockIds)
{
adjacency[id] = new List<string>();
indegree[id] = 0;
}
foreach (StoryBlockDefinition block in blocks)
{
if (block == null || !blockIds.Contains(block.blockId))
continue;
foreach (string nextId in block.nextBlockIds.Where(id => !string.IsNullOrWhiteSpace(id) && blockIds.Contains(id)).Distinct())
{
adjacency[block.blockId].Add(nextId);
indegree[nextId]++;
StoryBlockDefinition next = GetBlock(nextId);
if (next != null && next.gridColumn <= block.gridColumn)
issues.Add($"[Warning] Block '{block.blockId}' -> '{nextId}' 没有指向更靠后的 Column请确认这不是意外的回退连接或循环。" );
}
}
List<string> entries = indegree.Where(pair => pair.Value == 0).Select(pair => pair.Key).ToList();
if (entries.Count == 0 && blockIds.Count > 0)
issues.Add("[Error] 剧情连接图没有入口 Block所有 Block 都存在前置连接)。");
else if (entries.Count > 1)
issues.Add($"[Warning] 剧情连接图存在 {entries.Count} 个入口 Block{string.Join(", ", entries)}。确认它们均由条件或设计意图控制。" );
HashSet<string> reachable = new HashSet<string>();
Stack<string> pending = new Stack<string>(entries);
while (pending.Count > 0)
{
string current = pending.Pop();
if (!reachable.Add(current))
continue;
foreach (string next in adjacency[current])
pending.Push(next);
}
foreach (string id in blockIds.Where(id => !reachable.Contains(id)))
issues.Add($"[Warning] Block '{id}' 从任何入口都不可达;若依赖纯变量条件进入,请确认存在外部入口。" );
List<string> terminalBlocks = adjacency.Where(pair => pair.Value.Count == 0).Select(pair => pair.Key).ToList();
if (blockIds.Count > 0 && terminalBlocks.Count == 0)
issues.Add("[Warning] 剧情连接图不存在终点 Block请确认循环路线可以按预期结束。" );
HashSet<string> visiting = new HashSet<string>();
HashSet<string> visited = new HashSet<string>();
foreach (string entry in entries)
DetectCycle(entry, adjacency, visiting, visited, issues);
return reachable;
}
private static void DetectCycle(string blockId, Dictionary<string, List<string>> adjacency,
HashSet<string> visiting, HashSet<string> visited, List<string> issues)
{
if (visited.Contains(blockId))
return;
if (!visiting.Add(blockId))
{
issues.Add($"[Warning] 剧情连接图检测到包含 Block '{blockId}' 的循环。请确认该循环有明确的变量出口。" );
return;
}
foreach (string next in adjacency[blockId])
DetectCycle(next, adjacency, visiting, visited, issues);
visiting.Remove(blockId);
visited.Add(blockId);
}
/// <summary>检查多个 Marker 是否锚定到同一 Block通常表示重复配置不阻止特殊的多标签时间点。</summary>
private void ValidateTimelineMarkerAnchors(HashSet<string> reachableBlockIds, List<string> issues)
{
foreach (IGrouping<string, StoryTimelineMarkerDefinition> group in timelineMarkers
.Where(marker => marker != null && !string.IsNullOrWhiteSpace(marker.anchorBlockId))
.GroupBy(marker => marker.anchorBlockId))
{
if (group.Count() > 1)
issues.Add($"[Warning] 多个 Timeline Marker 锚定到 Block '{group.Key}'{string.Join(", ", group.Select(marker => marker.markerId))}。确认这是有意的多标签时间点。" );
if (!reachableBlockIds.Contains(group.Key))
issues.Add($"[Warning] Timeline Marker 锚定的 Block '{group.Key}' 从任何入口都不可达Marker 将永远不会按正常流程显示。" );
}
}
/// <summary>
/// 检查互斥结局的编辑期声明。由于 Forbidden Condition 可以是任意变量表达式,
/// 自检无法数学证明条件完全互斥;它只检查显式分组、引用、终点形态和最基本的禁用条件覆盖。
/// </summary>
private void ValidateExclusiveRouteGroups(HashSet<string> blockIds, List<string> issues)
{
HashSet<string> groupIds = new HashSet<string>();
foreach (StoryRouteValidationGroup group in exclusiveRouteGroups)
{
if (group == null)
{
issues.Add("[Warning] Exclusive Route Groups 中存在空项。");
continue;
}
if (!IsValidStableKey(group.groupId))
issues.Add($"[Error] Exclusive Route Group ID '{group.groupId}' 为空或不符合命名规则。" );
else if (!groupIds.Add(group.groupId))
issues.Add($"[Error] Exclusive Route Group ID '{group.groupId}' 重复。" );
if (group.terminalBlockIds == null || group.terminalBlockIds.Count < 2)
{
issues.Add($"[Warning] Exclusive Route Group '{group.groupId}' 至少应包含两个互斥终点 Block。" );
continue;
}
HashSet<string> seenTerminalIds = new HashSet<string>();
foreach (string terminalId in group.terminalBlockIds)
{
if (!seenTerminalIds.Add(terminalId))
{
issues.Add($"[Warning] Exclusive Route Group '{group.groupId}' 重复包含 Block '{terminalId}'。" );
continue;
}
StoryBlockDefinition terminal = GetBlock(terminalId);
if (terminal == null || !blockIds.Contains(terminalId))
{
issues.Add($"[Error] Exclusive Route Group '{group.groupId}' 引用了不存在的 Block '{terminalId}'。" );
continue;
}
if (terminal.nextBlockIds.Any(id => !string.IsNullOrWhiteSpace(id)))
issues.Add($"[Warning] Exclusive Route Group '{group.groupId}' 的终点 Block '{terminalId}' 仍配置了 Next Block。" );
if (terminal.forbiddenCondition == null || !terminal.forbiddenCondition.IsConfigured)
issues.Add($"[Warning] 互斥终点 Block '{terminalId}' 未配置 Forbidden Condition请人工确认其它路线完成后会禁用它。" );
}
}
}
/// <summary>
/// 侦测已废弃的 Yarn 临时变量语法。新台本应只使用项目提供的 set_* / get_* 永久变量命令;
/// C0 旧台本仍可能保留历史语法,故当前只输出 Warning不自动修改任何文本资产。
/// </summary>
private void ValidateLegacyYarnSyntax(List<string> issues)
{
if (yarnProject == null)
return;
string projectPath = AssetDatabase.GetAssetPath(yarnProject);
foreach (string path in AssetDatabase.GetDependencies(projectPath, true).Where(path => path.EndsWith(".yarn")))
{
TextAsset yarnSource = AssetDatabase.LoadAssetAtPath<TextAsset>(path);
if (yarnSource == null)
continue;
string[] lines = yarnSource.text.Split('\n');
for (int index = 0; index < lines.Length; index++)
{
string line = lines[index].TrimStart();
if (line.StartsWith("<<declare ") || line.StartsWith("<<set $"))
issues.Add($"[Warning] {path}:{index + 1} 使用了已废弃的 Yarn 临时变量语法 '{line}'。请改用 set_* / get_* 永久变量接口。" );
}
}
}
/// <summary>
/// 遍历通用条件树,检查会造成条件无法按预期求值的空引用或不存在 Block 引用。
/// VariableCondition 只检查变量名是否为空:未在 Initial Variables 声明的变量允许由 Yarn 在运行时首次写入。
/// </summary>
private static void ValidateCondition(StoryCondition condition, string owner, HashSet<string> blockIds, List<string> issues)
{
if (condition?.root != null)
{
ValidateConditionNode(condition.root, owner, blockIds, issues);
}
}
private static void ValidateConditionNode(StoryConditionNode node, string owner, HashSet<string> blockIds, List<string> issues)
{
switch (node)
{
case AllOfCondition allOf:
if (allOf.conditions == null || allOf.conditions.Count == 0)
issues.Add($"[Warning] {owner} 的 All Of 没有子条件,会恒为 true。");
else
ValidateConditionChildren(allOf.conditions, owner, blockIds, issues);
break;
case AnyOfCondition anyOf:
if (anyOf.conditions == null || anyOf.conditions.Count == 0)
issues.Add($"[Warning] {owner} 的 Any Of 没有子条件,会恒为 false。");
else
ValidateConditionChildren(anyOf.conditions, owner, blockIds, issues);
break;
case NotCondition not:
if (not.condition == null)
issues.Add($"[Warning] {owner} 的 Not 没有子条件,会恒为 true。");
else
ValidateConditionNode(not.condition, owner, blockIds, issues);
break;
case BlockCompletedCondition blockCompleted:
if (string.IsNullOrWhiteSpace(blockCompleted.blockId) || !blockIds.Contains(blockCompleted.blockId))
issues.Add($"[Error] {owner} 引用了不存在的 Block Completed ID '{blockCompleted.blockId}'。");
break;
case VariableCondition variable when string.IsNullOrWhiteSpace(variable.variableName):
issues.Add($"[Error] {owner} 存在未填写 Variable Name 的 Variable Compare。");
break;
case null:
issues.Add($"[Warning] {owner} 包含空条件节点。");
break;
}
}
private static void ValidateConditionChildren(List<StoryConditionNode> children, string owner, HashSet<string> blockIds, List<string> issues)
{
foreach (StoryConditionNode child in children)
{
ValidateConditionNode(child, owner, blockIds, issues);
}
}
private static bool IsValidStableKey(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
foreach (char character in value)
{
if (!(character is >= 'a' and <= 'z') &&
!(character is >= '0' and <= '9') &&
character != '_')
{
return false;
}
}
return true;
}
/// <summary>
/// 在 Inspector 顶部绘制只读的故事树结构预览(流程图样式,连线左右相连)。
/// 提供"详细 / 简略"两种可拖拽平移的视图;按 (gridColumn, gridRow) 摆放 block。
/// 每个 Block 使用其运行时 Prefab 尺寸按统一比例缩放后的矩形,且列坐标始终代表 Block 的中心点,
/// 从而让 Important / Secondary / Song / Tutorial 的体积与连线端点关系尽量接近游戏内画面。
/// </summary>
[PropertyOrder(-10)]
[OnInspectorGUI]
private void DrawStoryPreview()
{
_isPreviewExpanded = EditorGUILayout.Foldout(
_isPreviewExpanded,
new GUIContent(
"Story Tree Preview",
"只读预览:按 StoryData 的 Column / Row、连接关系和当前 Block 尺寸约定绘制。展开后可拖动平移视图。"
),
true
);
if (!_isPreviewExpanded)
{
return;
}
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("Fit View", GUILayout.Width(80f), GUILayout.Height(20f)))
_requestPreviewFit = true;
if (GUILayout.Button("Reset", GUILayout.Width(60f), GUILayout.Height(20f)))
_previewPan = Vector2.zero;
EditorGUILayout.EndHorizontal();
bool simple = _previewMode == StoryPreviewMode.Simple;
float previewScale = simple ? SimplePreviewScale : DetailedPreviewScale;
float stepX = RuntimeColumnStep * previewScale;
float stepY = RuntimeRowStep * previewScale;
Rect area = GUILayoutUtility.GetRect(0f, PreviewViewportHeight, GUILayout.ExpandWidth(true));
// 工具栏按钮在取得最终 Preview 区域后才可执行精确 Fit用当前 Event 的热键标记延迟到这里处理。
if (_requestPreviewFit)
{
FitPreviewView(area, previewScale);
_requestPreviewFit = false;
}
EditorGUI.DrawRect(area, new Color(0.12f, 0.12f, 0.14f, 1f));
HandlePreviewPanning(area);
// 裁剪到预览区,超出部分(平移后)不外溢到其它 Inspector 元素
GUI.BeginClip(area);
{
// (0,0) 是 Block 的逻辑中心点。为避免第一列 Important Block 被左侧裁掉,
// 初始视图预留半个最大 Block 宽度;这不改变 StoryData 的坐标语义。
float originX = PreviewPaddingLeft + ImportantBlockWidth * previewScale * 0.5f + _previewPan.x;
float originY = area.height * 0.5f + _previewPan.y;
// 计算各 Block 的实际比例矩形。运行时同样以中心 Pivot 放置,因此 gridColumn / gridRow
// 在预览中也对应矩形中心,而不是左边缘。
Dictionary<string, Rect> cellRects = new Dictionary<string, Rect>();
foreach (StoryBlockDefinition block in blocks)
{
if (!HasPreviewableBlockId(block)) continue;
Vector2 blockSize = GetPreviewBlockSize(block, previewScale);
float px = originX + block.gridColumn * stepX; // 列向右为正(可负、可小数)
float py = originY + block.gridRow * stepY; // 行向下为正、向上为负
cellRects[block.blockId] = new Rect(
px - blockSize.x * 0.5f,
py - blockSize.y * 0.5f,
blockSize.x,
blockSize.y);
}
// 先画连线:与 BlockConnectorView 相同,起点右中 → 以两个 Block 中心的中点列折线 → 终点左中。
// 不使用端口中点作为转折列,避免不同宽度 Block 在同一列时出现不一致的转折位置。
Handles.BeginGUI();
foreach (StoryBlockDefinition block in blocks)
{
if (!HasPreviewableBlockId(block) || !cellRects.TryGetValue(block.blockId, out Rect fromRect)) continue;
foreach (string nextId in block.nextBlockIds)
{
if (string.IsNullOrEmpty(nextId) || !cellRects.TryGetValue(nextId, out Rect toRect)) continue;
Vector3 start = new Vector3(fromRect.xMax, fromRect.center.y, 0f);
Vector3 end = new Vector3(toRect.xMin, toRect.center.y, 0f);
float midX = (fromRect.center.x + toRect.center.x) * 0.5f;
Vector3 mid1 = new Vector3(midX, start.y, 0f);
Vector3 mid2 = new Vector3(midX, end.y, 0f);
Handles.color = new Color(0.82f, 0.82f, 0.88f, 1f);
Handles.DrawAAPolyLine(simple ? 2f : 3f, start, mid1, mid2, end);
}
}
Handles.EndGUI();
// 再画 block 方块
GUIStyle labelStyle = new GUIStyle(EditorStyles.boldLabel)
{
alignment = TextAnchor.MiddleCenter,
wordWrap = true,
richText = false,
fontSize = simple ? 9 : 11,
normal = { textColor = Color.white }
};
foreach (StoryBlockDefinition block in blocks)
{
if (!HasPreviewableBlockId(block) || !cellRects.TryGetValue(block.blockId, out Rect rect)) continue;
EditorGUI.DrawRect(rect, GetTypeColor(block.blockType));
string label = GetPreviewLabel(block, simple, rect.height);
GUI.Label(rect, label, labelStyle);
}
}
GUI.EndClip();
}
/// <summary>
/// 返回一个 Block 在 Inspector 预览中的尺寸。所有类型使用同一缩放比例,
/// 因而宽高比及不同 Block 之间的体积关系与运行时 Prefab 保持一致。
/// </summary>
private static Vector2 GetPreviewBlockSize(StoryBlockDefinition block, float previewScale)
{
Vector2 runtimeSize = block.blockType switch
{
StoryBlockType.Text when block.textImportance == TextBlockImportance.Important =>
new Vector2(ImportantBlockWidth, ImportantBlockHeight),
StoryBlockType.Text => new Vector2(SecondaryBlockWidth, SecondaryBlockHeight),
StoryBlockType.Song => new Vector2(SongBlockWidth, SongBlockHeight),
StoryBlockType.Tutorial => new Vector2(TutorialBlockWidth, TutorialBlockHeight),
_ => Vector2.zero
};
return runtimeSize * previewScale;
}
/// <summary>
/// 将当前 StoryData 的真实预览边界居中到可视区域。
/// 只修改 Inspector 的临时平移量,不会修改任何 Block 的 gridColumn / gridRow。
/// </summary>
private void FitPreviewView(Rect area, float previewScale)
{
if (blocks == null || blocks.Count == 0)
{
_previewPan = Vector2.zero;
return;
}
float stepX = RuntimeColumnStep * previewScale;
float stepY = RuntimeRowStep * previewScale;
float minX = float.PositiveInfinity;
float maxX = float.NegativeInfinity;
float minY = float.PositiveInfinity;
float maxY = float.NegativeInfinity;
foreach (StoryBlockDefinition block in blocks)
{
if (!HasPreviewableBlockId(block))
{
continue;
}
Vector2 size = GetPreviewBlockSize(block, previewScale);
float centerX = block.gridColumn * stepX;
float centerY = block.gridRow * stepY;
minX = Mathf.Min(minX, centerX - size.x * 0.5f);
maxX = Mathf.Max(maxX, centerX + size.x * 0.5f);
minY = Mathf.Min(minY, centerY - size.y * 0.5f);
maxY = Mathf.Max(maxY, centerY + size.y * 0.5f);
}
if (float.IsPositiveInfinity(minX))
{
_previewPan = Vector2.zero;
return;
}
// DrawStoryPreview 的初始原点位于左侧 Padding + 半个最大 Block 宽度;
// 此处只补偿该基础原点与内容包围盒中心之间的差值。
float baseOriginX = PreviewPaddingLeft + ImportantBlockWidth * previewScale * 0.5f;
_previewPan = new Vector2(
area.width * 0.5f - baseOriginX - (minX + maxX) * 0.5f,
-(minY + maxY) * 0.5f
);
}
/// <summary>
/// 根据可用高度选择预览标签密度。Song / Tutorial 的真实比例高度较小,
/// 因此只显示 Block ID避免文字撑破矩形而掩盖实际体积关系。
/// </summary>
private static string GetPreviewLabel(StoryBlockDefinition block, bool simple, float blockHeight)
{
if (simple || blockHeight < 32f)
{
return block.blockId;
}
if (blockHeight < 60f)
{
return $"{block.blockId}\n[{block.blockType}]";
}
return $"{block.blockId}\n[{block.blockType}]\n{block.GetDisplayTitle()}";
}
/// <summary>
/// 处理预览区内的鼠标拖拽平移。
/// </summary>
private void HandlePreviewPanning(Rect area)
{
Event e = Event.current;
switch (e.type)
{
case EventType.MouseDown:
if (area.Contains(e.mousePosition))
{
_isPanningPreview = true;
e.Use();
}
break;
case EventType.MouseDrag:
if (_isPanningPreview)
{
_previewPan += e.delta;
GUI.changed = true;
e.Use();
}
break;
case EventType.MouseUp:
if (_isPanningPreview)
{
_isPanningPreview = false;
e.Use();
}
break;
}
}
/// <summary>
/// 判断 Block 能否参与 Inspector 中的故事树预览。
/// 新增 Block 时Odin 会先创建一个尚未填写 blockId 的临时数据项;它还不能作为
/// Dictionary 的 Key也不应在预览中显示或连接。统一在三个预览阶段过滤避免后续
/// Draw / TryGetValue 阶段再次把空引用传入 Dictionary。
/// </summary>
private static bool HasPreviewableBlockId(StoryBlockDefinition block)
{
return block != null && !string.IsNullOrWhiteSpace(block.blockId);
}
/// <summary>
/// 预览中不同 block 类型的填充色。
/// </summary>
private static Color GetTypeColor(StoryBlockType type)
{
return type switch
{
StoryBlockType.Text => new Color(0.20f, 0.42f, 0.72f, 1f), // 蓝
StoryBlockType.Song => new Color(0.62f, 0.30f, 0.66f, 1f), // 紫
StoryBlockType.Tutorial => new Color(0.28f, 0.56f, 0.36f, 1f), // 绿
_ => new Color(0.4f, 0.4f, 0.4f, 1f)
};
}
}
}
#endif

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 93a8de9ae74f4e8784af656377c443be

View File

@@ -2,10 +2,6 @@ using System.Collections.Generic;
using Sirenix.OdinInspector;
using UnityEngine;
using Yarn.Unity;
#if UNITY_EDITOR
using UnityEditor;
using Sirenix.Utilities.Editor;
#endif
namespace Ichni.Story
{
@@ -14,17 +10,30 @@ namespace Ichni.Story
/// 布局完全由每个 block 的 (gridColumn, gridRow) 静态决定,运行时全量展示。
/// </summary>
[CreateAssetMenu(fileName = "StoryData", menuName = "Ichni/Story/New/StoryData")]
public class StoryData : SerializedScriptableObject
public partial class StoryData : SerializedScriptableObject
{
[TitleGroup("Chapter Settings", Alignment = TitleAlignments.Centered)]
[LabelText("Chapter Index")]
[Tooltip("章节的稳定索引。StorySave、StoryManager 与 ChapterSelection 都以该值定位当前章节。")]
public string chapterIndex;
[TitleGroup("Chapter Settings")]
[LabelText("Yarn Project")]
[InfoBox("该章节对应的 YarnProject 编译资产,阶段 2 接线。")]
[Tooltip("该章节对应的 YarnProject 编译资产。TextBlock 会在运行时从此项目中执行配置的 Yarn Node。")]
public YarnProject yarnProject;
/// <summary>
/// 本章节永久剧情变量的默认值。它们只属于本章节,且不会直接写入存档;
/// 玩家第一次通过 Yarn 或其它剧情入口写入同名变量后,存档值才会覆盖这里的默认值。
/// </summary>
[LabelText("Initial Variables")]
[ListDrawerSettings(ShowFoldout = true, DefaultExpandedState = false, DraggableItems = true, ShowItemCount = true)]
[Tooltip("变量 Key 使用小写英文、数字和下划线。Yarn 请通过 <<set_*>> 与 get_*() 访问,不使用 <<declare>>、<<set>> 或 $variable。")]
public List<StoryVariableDefinition> initialVariables = new List<StoryVariableDefinition>();
[LabelText("Blocks")]
[ListDrawerSettings(ShowFoldout = true, DefaultExpandedState = false, DraggableItems = true, ShowItemCount = true)]
[Tooltip("章节的完整剧情树定义。列表排序不决定运行时流程;连接关系、条件与网格坐标才是唯一依据。")]
public List<StoryBlockDefinition> blocks = new List<StoryBlockDefinition>();
/// <summary>
@@ -34,9 +43,18 @@ namespace Ichni.Story
/// </summary>
[LabelText("Timeline Markers")]
[ListDrawerSettings(ShowFoldout = true, DefaultExpandedState = false, DraggableItems = true, ShowItemCount = true)]
[InfoBox("每个标记锚定到一个 TextBlock运行时使用该 Block 的 Grid Column 对齐 Timeline。TextBlock 为 Current / Completed 时显示Locked / Forbidden 时隐藏。未配置标记时,现有章节行为不变。")]
[Tooltip("每个标记锚定到一个 TextBlock运行时使用该 Block 的 Grid Column 对齐 Timeline。TextBlock 为 Current / Completed 时显示Locked / Forbidden 时隐藏。")]
public List<StoryTimelineMarkerDefinition> timelineMarkers = new List<StoryTimelineMarkerDefinition>();
/// <summary>
/// 仅供编辑器自检使用的互斥结局分组,不参与运行时的解锁、禁用或存档逻辑。
/// 实际路线禁用仍完全通过各 Block 的 Forbidden Condition 表达,以保留剧情变量组合的灵活性。
/// </summary>
[LabelText("Exclusive Route Groups")]
[ListDrawerSettings(ShowFoldout = true, DefaultExpandedState = false, DraggableItems = true, ShowItemCount = true)]
[Tooltip("把同一章节中只能同时达成一个的结局 Block 放入同一组。Validate Story Data 会检查引用、终点形态与 Forbidden Condition 配置;不会改变运行时逻辑。")]
public List<StoryRouteValidationGroup> exclusiveRouteGroups = new List<StoryRouteValidationGroup>();
// ── Queries ─────────────────────────────────────────────────────────────
/// <summary>
@@ -46,13 +64,35 @@ namespace Ichni.Story
{
foreach (StoryBlockDefinition block in blocks)
{
if (block.blockId == blockId)
if (block != null && block.blockId == blockId)
return block;
}
return null;
}
/// <summary>
/// 尝试获取一个章节变量的静态默认定义。重复 Key 取列表中第一项,
/// 因此配置时应保持 Key 唯一,以免在 Inspector 中产生歧义。
/// </summary>
public bool TryGetInitialVariable(string key, out StoryVariableDefinition definition)
{
if (!string.IsNullOrWhiteSpace(key) && initialVariables != null)
{
foreach (StoryVariableDefinition item in initialVariables)
{
if (item != null && item.key == key)
{
definition = item;
return true;
}
}
}
definition = null;
return false;
}
/// <summary>
/// 获取指定 blockId 的所有直接后继 block 定义。
/// </summary>
@@ -61,6 +101,9 @@ namespace Ichni.Story
StoryBlockDefinition source = GetBlock(blockId);
if (source == null) yield break;
if (source.nextBlockIds == null)
yield break;
foreach (string nextId in source.nextBlockIds)
{
StoryBlockDefinition next = GetBlock(nextId);
@@ -69,241 +112,5 @@ namespace Ichni.Story
}
}
// ── Editor Preview ────────────────────────────────────────────────────────
#if UNITY_EDITOR
private enum StoryPreviewMode { Detailed, Simple }
// 编辑器 UI 临时状态(不参与序列化)
[System.NonSerialized] private StoryPreviewMode _previewMode = StoryPreviewMode.Detailed;
[System.NonSerialized] private Vector2 _previewPan;
[System.NonSerialized] private bool _isPanningPreview;
private const float PreviewViewportHeight = 380f;
private const float PreviewPaddingLeft = 24f;
// 预览与运行时共用同一套“列 / 行代表 Block 中心点”的布局语义。
// 这里的尺寸直接对应当前四种 Block Prefab 的设计尺寸;若未来修改 Prefab 尺寸,必须同步更新本组常量。
private const float ImportantBlockWidth = 600f;
private const float ImportantBlockHeight = 375f;
private const float SecondaryBlockWidth = 400f;
private const float SecondaryBlockHeight = 200f;
private const float SongBlockWidth = 400f;
private const float SongBlockHeight = 100f;
private const float TutorialBlockWidth = 400f;
private const float TutorialBlockHeight = 100f;
// 与 StoryTreeController 默认 columnStep / rowStep 保持比例一致,
// 因而预览中的相邻列、行间距也能反映运行时的实际布局关系。
private const float RuntimeColumnStep = 720f;
private const float RuntimeRowStep = 525f;
private const float DetailedPreviewScale = 0.25f;
private const float SimplePreviewScale = 0.10f;
/// <summary>
/// 在 Inspector 顶部绘制只读的故事树结构预览(流程图样式,连线左右相连)。
/// 提供"详细 / 简略"两种可拖拽平移的视图;按 (gridColumn, gridRow) 摆放 block。
/// 每个 Block 使用其运行时 Prefab 尺寸按统一比例缩放后的矩形,且列坐标始终代表 Block 的中心点,
/// 从而让 Important / Secondary / Song / Tutorial 的体积与连线端点关系尽量接近游戏内画面。
/// </summary>
[PropertyOrder(-10)]
[OnInspectorGUI]
private void DrawStoryPreview()
{
SirenixEditorGUI.Title("Story Tree Preview", null, TextAlignment.Left, true);
if (blocks == null || blocks.Count == 0)
{
EditorGUILayout.HelpBox("尚未定义任何 block。", MessageType.Info);
return;
}
// 顶部工具条:模式切换 + 复位视图
EditorGUILayout.BeginHorizontal();
_previewMode = (StoryPreviewMode)GUILayout.Toolbar(
(int)_previewMode, new[] { "Detailed", "Simple" }, GUILayout.Height(20f));
if (GUILayout.Button("Reset View", GUILayout.Width(90f), GUILayout.Height(20f)))
_previewPan = Vector2.zero;
EditorGUILayout.EndHorizontal();
bool simple = _previewMode == StoryPreviewMode.Simple;
float previewScale = simple ? SimplePreviewScale : DetailedPreviewScale;
float stepX = RuntimeColumnStep * previewScale;
float stepY = RuntimeRowStep * previewScale;
Rect area = GUILayoutUtility.GetRect(0f, PreviewViewportHeight, GUILayout.ExpandWidth(true));
EditorGUI.DrawRect(area, new Color(0.12f, 0.12f, 0.14f, 1f));
HandlePreviewPanning(area);
// 裁剪到预览区,超出部分(平移后)不外溢到其它 Inspector 元素
GUI.BeginClip(area);
{
// (0,0) 是 Block 的逻辑中心点。为避免第一列 Important Block 被左侧裁掉,
// 初始视图预留半个最大 Block 宽度;这不改变 StoryData 的坐标语义。
float originX = PreviewPaddingLeft + ImportantBlockWidth * previewScale * 0.5f + _previewPan.x;
float originY = area.height * 0.5f + _previewPan.y;
// 计算各 Block 的实际比例矩形。运行时同样以中心 Pivot 放置,因此 gridColumn / gridRow
// 在预览中也对应矩形中心,而不是左边缘。
Dictionary<string, Rect> cellRects = new Dictionary<string, Rect>();
foreach (StoryBlockDefinition block in blocks)
{
if (!HasPreviewableBlockId(block)) continue;
Vector2 blockSize = GetPreviewBlockSize(block, previewScale);
float px = originX + block.gridColumn * stepX; // 列向右为正(可负、可小数)
float py = originY + block.gridRow * stepY; // 行向下为正、向上为负
cellRects[block.blockId] = new Rect(
px - blockSize.x * 0.5f,
py - blockSize.y * 0.5f,
blockSize.x,
blockSize.y);
}
// 先画连线:与 BlockConnectorView 相同,起点右中 → 以两个 Block 中心的中点列折线 → 终点左中。
// 不使用端口中点作为转折列,避免不同宽度 Block 在同一列时出现不一致的转折位置。
Handles.BeginGUI();
foreach (StoryBlockDefinition block in blocks)
{
if (!HasPreviewableBlockId(block) || !cellRects.TryGetValue(block.blockId, out Rect fromRect)) continue;
foreach (string nextId in block.nextBlockIds)
{
if (string.IsNullOrEmpty(nextId) || !cellRects.TryGetValue(nextId, out Rect toRect)) continue;
Vector3 start = new Vector3(fromRect.xMax, fromRect.center.y, 0f);
Vector3 end = new Vector3(toRect.xMin, toRect.center.y, 0f);
float midX = (fromRect.center.x + toRect.center.x) * 0.5f;
Vector3 mid1 = new Vector3(midX, start.y, 0f);
Vector3 mid2 = new Vector3(midX, end.y, 0f);
Handles.color = new Color(0.82f, 0.82f, 0.88f, 1f);
Handles.DrawAAPolyLine(simple ? 2f : 3f, start, mid1, mid2, end);
}
}
Handles.EndGUI();
// 再画 block 方块
GUIStyle labelStyle = new GUIStyle(EditorStyles.boldLabel)
{
alignment = TextAnchor.MiddleCenter,
wordWrap = true,
richText = false,
fontSize = simple ? 9 : 11,
normal = { textColor = Color.white }
};
foreach (StoryBlockDefinition block in blocks)
{
if (!HasPreviewableBlockId(block) || !cellRects.TryGetValue(block.blockId, out Rect rect)) continue;
EditorGUI.DrawRect(rect, GetTypeColor(block.blockType));
string label = GetPreviewLabel(block, simple, rect.height);
GUI.Label(rect, label, labelStyle);
}
}
GUI.EndClip();
EditorGUILayout.HelpBox(
"在预览区内拖拽可平移视图。列 = 水平(右为正),行 = 垂直(下为正、上为负);坐标代表 Block 中心点。预览按当前 Prefab 尺寸比例绘制Important 600×375、Secondary 400×200、Song / Tutorial 400×100。",
MessageType.None);
}
/// <summary>
/// 返回一个 Block 在 Inspector 预览中的尺寸。所有类型使用同一缩放比例,
/// 因而宽高比及不同 Block 之间的体积关系与运行时 Prefab 保持一致。
/// </summary>
private static Vector2 GetPreviewBlockSize(StoryBlockDefinition block, float previewScale)
{
Vector2 runtimeSize = block.blockType switch
{
StoryBlockType.Text when block.textImportance == TextBlockImportance.Important =>
new Vector2(ImportantBlockWidth, ImportantBlockHeight),
StoryBlockType.Text => new Vector2(SecondaryBlockWidth, SecondaryBlockHeight),
StoryBlockType.Song => new Vector2(SongBlockWidth, SongBlockHeight),
StoryBlockType.Tutorial => new Vector2(TutorialBlockWidth, TutorialBlockHeight),
_ => Vector2.zero
};
return runtimeSize * previewScale;
}
/// <summary>
/// 根据可用高度选择预览标签密度。Song / Tutorial 的真实比例高度较小,
/// 因此只显示 Block ID避免文字撑破矩形而掩盖实际体积关系。
/// </summary>
private static string GetPreviewLabel(StoryBlockDefinition block, bool simple, float blockHeight)
{
if (simple || blockHeight < 32f)
{
return block.blockId;
}
if (blockHeight < 60f)
{
return $"{block.blockId}\n[{block.blockType}]";
}
return $"{block.blockId}\n[{block.blockType}]\n{block.GetDisplayTitle()}";
}
/// <summary>
/// 处理预览区内的鼠标拖拽平移。
/// </summary>
private void HandlePreviewPanning(Rect area)
{
Event e = Event.current;
switch (e.type)
{
case EventType.MouseDown:
if (area.Contains(e.mousePosition))
{
_isPanningPreview = true;
e.Use();
}
break;
case EventType.MouseDrag:
if (_isPanningPreview)
{
_previewPan += e.delta;
GUI.changed = true;
e.Use();
}
break;
case EventType.MouseUp:
if (_isPanningPreview)
{
_isPanningPreview = false;
e.Use();
}
break;
}
}
/// <summary>
/// 判断 Block 能否参与 Inspector 中的故事树预览。
/// 新增 Block 时Odin 会先创建一个尚未填写 blockId 的临时数据项;它还不能作为
/// Dictionary 的 Key也不应在预览中显示或连接。统一在三个预览阶段过滤避免后续
/// Draw / TryGetValue 阶段再次把空引用传入 Dictionary。
/// </summary>
private static bool HasPreviewableBlockId(StoryBlockDefinition block)
{
return block != null && !string.IsNullOrWhiteSpace(block.blockId);
}
/// <summary>
/// 预览中不同 block 类型的填充色。
/// </summary>
private static Color GetTypeColor(StoryBlockType type)
{
return type switch
{
StoryBlockType.Text => new Color(0.20f, 0.42f, 0.72f, 1f), // 蓝
StoryBlockType.Song => new Color(0.62f, 0.30f, 0.66f, 1f), // 紫
StoryBlockType.Tutorial => new Color(0.28f, 0.56f, 0.36f, 1f), // 绿
_ => new Color(0.4f, 0.4f, 0.4f, 1f)
};
}
#endif
}
}

View File

@@ -2,6 +2,8 @@ using System;
using System.Collections.Generic;
using Sirenix.OdinInspector;
using UnityEngine;
using UnityEngine.Localization;
using UnityEngine.Localization.Tables;
namespace Ichni.Story
{
@@ -10,21 +12,47 @@ namespace Ichni.Story
/// <para>该资产不隶属于任何 StoryData由 <see cref="StoryManager.helperData"/> 统一引用,
/// 因而所有章节都共用同一个 Helper 角色、名称和候选对话池。</para>
/// <para>运行时会在满足条件的候选中按权重抽取,并在候选数量允许时避免与上次对话重复;
/// 具体抽取和 UI 展示逻辑将在 Helper 功能实现阶段接入。</para>
/// 具体抽取和 UI 展示由 <see cref="StoryHelperController"/> 负责。</para>
/// </summary>
[CreateAssetMenu(fileName = "StoryHelperData", menuName = "Ichni/Story/New/Story Helper Data")]
public class StoryHelperData : SerializedScriptableObject
{
/// <summary>
/// Helper 的稳定身份标识。当前阶段只有一个 Helper但后续的更换界面、存档和统计
/// 都应使用该 ID而非依赖可变的资源名称或显示名称。
/// </summary>
[LabelText("Helper ID")]
[Tooltip("使用小写英文、数字和下划线,例如 chapter0_helper。未来切换与持久化 Helper 时使用此稳定 ID。")]
public string helperId;
/// <summary>
/// 当前静态版本使用的立绘。留空时,静态 Presenter 会保留场景中原本配置的 Image
/// 因而可以在不立即迁移全部美术引用的情况下逐步接入本数据资产。
/// </summary>
[PreviewField(75, ObjectFieldAlignment.Left)]
[LabelText("Portrait Sprite")]
[Tooltip("当前静态 Helper 立绘。未来改用 Spine 或 Live2D 时,可由对应 Presenter 忽略该字段。")]
public Sprite portraitSprite;
/// <summary>
/// Helper 名称与对话文本所使用的 Unity Localization String Table。
/// </summary>
[LabelText("Localization Table")]
[Tooltip("Display Name Key、Fallback Dialogue Key 与 Dialogue Pool 的 Text Key 均在此 String Table 中查找。")]
public TableReference localizationTable;
/// <summary>
/// Helper 显示名称的 Unity Localization Entry Key。
/// </summary>
[LabelText("Display Name Key (Localization)")]
[Tooltip("Helper 显示名称使用的 Unity Localization Entry Key。")]
public string displayNameKey;
/// <summary>
/// 当所有候选对话均不满足条件时显示的兜底对话 Entry Key。
/// </summary>
[LabelText("Fallback Dialogue Key (Localization)")]
[Tooltip("没有候选对话满足条件时显示的 Unity Localization Entry Key。")]
public string fallbackDialogueKey;
/// <summary>
@@ -32,6 +60,7 @@ namespace Ichni.Story
/// </summary>
[LabelText("Dialogue Pool")]
[ListDrawerSettings(ShowFoldout = true, DefaultExpandedState = false, DraggableItems = true, ShowItemCount = true)]
[Tooltip("全章节共用的候选 Helper 对话。条件留空时该项始终可参与按权重抽取。")]
public List<StoryHelperDialogueDefinition> dialoguePool = new List<StoryHelperDialogueDefinition>();
}
@@ -42,25 +71,33 @@ namespace Ichni.Story
/// </summary>
[Serializable]
[InlineProperty]
[HideReferenceObjectPicker]
public class StoryHelperDialogueDefinition
{
/// <summary>
/// 候选对话的稳定 ID仅用于调试、去重和未来数据追踪。
/// 命名使用小写英文、数字和下划线,例如 <c>general_greeting_01</c>。
/// </summary>
[LabelText("Dialogue ID")]
[HorizontalGroup("Identity", 0.38f)]
[LabelText("ID")]
[LabelWidth(20)]
[Tooltip("候选对话的稳定 ID仅用于调试、去重和未来数据追踪。")]
public string dialogueId;
/// <summary>
/// 实际显示内容的 Unity Localization Entry Key。
/// </summary>
[LabelText("Dialogue Key (Localization)")]
[HorizontalGroup("Identity")]
[LabelText("Text Key")]
[LabelWidth(52)]
[Tooltip("实际显示文本的 Unity Localization Entry Key。")]
public string dialogueKey;
/// <summary>
/// 此对话可被抽取的条件;留空表示始终可用。
/// </summary>
[LabelText("Availability Condition")]
[LabelText("Availability")]
[Tooltip("此候选对话可参与抽取的条件;留空表示始终可用。")]
public StoryCondition availabilityCondition = new StoryCondition();
/// <summary>
@@ -69,6 +106,7 @@ namespace Ichni.Story
/// </summary>
[LabelText("Weight")]
[MinValue(1)]
[Tooltip("在满足条件的候选中使用的相对抽取权重,最小为 1。")]
public int weight = 1;
}
}

View File

@@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
using Sirenix.OdinInspector;
using UnityEngine;
namespace Ichni.Story
{
/// <summary>
/// StoryData 编辑期的互斥路线声明。
/// <para>本类不会参与运行时条件求值:路线的开放与禁用仍只由 StoryBlockDefinition 中的
/// Unlock Condition / Forbidden Condition 控制。它的唯一职责是让 Validate Story Data 能够
/// 对“多个结局只能完成一个”的人工配置意图进行可定位检查。</para>
/// </summary>
[Serializable]
[InlineProperty]
[HideReferenceObjectPicker]
public class StoryRouteValidationGroup
{
[HorizontalGroup("Identity", 0.34f)]
[LabelText("Group ID")]
[LabelWidth(58)]
[Tooltip("仅用于编辑器验证与日志定位的稳定 ID。使用小写英文、数字和下划线例如 chapter0_ending。")]
public string groupId;
[HorizontalGroup("Identity")]
[LabelText("Terminal Blocks")]
[Tooltip("同一互斥结局组中的终点 Block ID。Validate Story Data 会检查它们是否存在、是否重复、是否为图终点,以及是否配置 Forbidden Condition。")]
public List<string> terminalBlockIds = new List<string>();
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4c6fe8ac7f9f4efba5df1c7a7c52b3e1

View File

@@ -13,40 +13,56 @@ namespace Ichni.Story
/// </summary>
[Serializable]
[InlineProperty]
[HideReferenceObjectPicker]
public class StoryTimelineMarkerDefinition
{
/// <summary>
/// 标记的稳定唯一 ID仅用于配置、调试和未来持久化引用。
/// 命名使用小写英文、数字和下划线,例如 <c>chapter0_day_01</c>。
/// </summary>
[HorizontalGroup("Identity", 0.34f)]
[LabelText("Marker ID")]
[LabelWidth(62)]
[Tooltip("Marker 的稳定唯一 ID。回滚快照会使用它作为索引建议使用小写英文、数字和下划线。")]
public string markerId;
/// <summary>
/// 被此标记锚定的 TextBlock ID。该 Block 的 gridColumn 是标记的唯一位置来源;
/// 当它处于 Current 或 Completed 时标记显示Locked 或 Forbidden 时,标记隐藏。
/// </summary>
[LabelText("Anchor Block ID")]
[HorizontalGroup("Identity", 0.33f)]
[LabelText("Anchor")]
[LabelWidth(42)]
[Tooltip("被此 Marker 锚定的 TextBlock ID。该 Block 的列位置决定 Marker 的横坐标。")]
public string anchorBlockId;
/// <summary>
/// Timeline 上显示的本地化文本 Key。通常用于时间描述也可用于特殊叙事文本。
/// </summary>
[LabelText("Label Key (Localization)")]
[HorizontalGroup("Identity")]
[LabelText("Label Key")]
[LabelWidth(54)]
[Tooltip("Timeline 上显示的 Unity Localization Entry Key通常用于时间或特殊叙事文本。")]
public string labelKey;
/// <summary>
/// 是否参与章节主线进度百分比计算。支线或纯装饰性时间标记应关闭此项。
/// </summary>
[LabelText("Contributes To Main Progress")]
[HorizontalGroup("Progress", 0.42f)]
[LabelText("Main Progress")]
[Tooltip("开启后,此 Marker 完成时会参与章节主线进度百分比的最大值计算。")]
public bool contributesToMainProgress = true;
/// <summary>
/// 锚定 Block 完成后可贡献的主线进度,范围为 0 到 1。
/// 同一章节的不同结局都应配置为 1运行时取所有有效完成标记中的最大值。
/// </summary>
[LabelText("Progress Value")]
[HorizontalGroup("Progress")]
[LabelText("Value")]
[LabelWidth(35)]
[ShowIf(nameof(contributesToMainProgress))]
[Range(0f, 1f)]
[Tooltip("完成该锚定 Block 后贡献的主线进度,范围为 0 到 1。互斥结局通常都设为 1。")]
public float progressValue;
}
}

View File

@@ -0,0 +1,74 @@
using System;
using Sirenix.OdinInspector;
using UnityEngine;
namespace Ichni.Story
{
/// <summary>
/// StoryData 中声明的章节变量默认值类型。
/// 数值变量会保存为 floatInt 仅决定 Inspector 和读取接口的语义。
/// </summary>
public enum StoryVariableType
{
Bool,
Int,
Float,
String
}
/// <summary>
/// 一个章节变量的静态默认定义。
/// 默认值不写入玩家存档当存档中没有同名覆盖值时StoryVariables 才返回此处的配置。
/// 因此“重开章节”只要清空章节变量容器,便会自然回到该定义的初始状态。
/// </summary>
[Serializable]
[InlineProperty]
[HideReferenceObjectPicker]
public class StoryVariableDefinition
{
[HorizontalGroup("Variable", 0.55f)]
[LabelText("Key")]
[LabelWidth(28)]
[Tooltip("变量 Key使用小写英文、数字和下划线例如 c0_route_choice。不要使用 Yarn 的 $variable 语法。")]
public string key;
[HorizontalGroup("Variable", 0.18f)]
[LabelText("Type")]
[LabelWidth(32)]
[Tooltip("变量的默认值类型。运行时可由 Yarn 的 set_* 指令写入同名永久章节变量。")]
public StoryVariableType type;
[HorizontalGroup("Variable")]
[LabelText("Default")]
[LabelWidth(48)]
[ShowIf(nameof(IsBool))]
[Tooltip("变量尚未被玩家存档覆盖时使用的默认 Bool 值。")]
public bool boolValue;
[HorizontalGroup("Variable")]
[LabelText("Default")]
[LabelWidth(48)]
[ShowIf(nameof(IsInt))]
[Tooltip("变量尚未被玩家存档覆盖时使用的默认 Int 值。")]
public int intValue;
[HorizontalGroup("Variable")]
[LabelText("Default")]
[LabelWidth(48)]
[ShowIf(nameof(IsFloat))]
[Tooltip("变量尚未被玩家存档覆盖时使用的默认 Float 值。")]
public float floatValue;
[HorizontalGroup("Variable")]
[LabelText("Default")]
[LabelWidth(48)]
[ShowIf(nameof(IsString))]
[Tooltip("变量尚未被玩家存档覆盖时使用的默认 String 值。")]
public string stringValue;
private bool IsBool() => type == StoryVariableType.Bool;
private bool IsInt() => type == StoryVariableType.Int;
private bool IsFloat() => type == StoryVariableType.Float;
private bool IsString() => type == StoryVariableType.String;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8d61a59c6d4a423aae2e04ae3dac5b1a

View File

@@ -5,9 +5,9 @@ using UnityEngine;
namespace Ichni.Story
{
/// <summary>
/// 全局剧情变量的统一数据枢纽。
/// 当前章节剧情变量的统一数据枢纽。
/// 所有外部系统(包括 Yarn、UI、小游戏读写剧情变量的唯一官方入口。
/// 自动桥接至 GameSaveManager 的 StorySaveModule 中持久化。
/// 自动桥接至 GameSaveManager 的 StorySaveModule 中当前章节的持久化容器
/// </summary>
public static class StoryVariables
{
@@ -19,7 +19,7 @@ namespace Ichni.Story
get
{
if (GameSaveManager.instance != null && GameSaveManager.instance.StorySaveModule != null)
return GameSaveManager.instance.StorySaveModule.variables;
return GameSaveManager.instance.StorySaveModule.GetActiveChapterVariables();
return _fallback;
}
}
@@ -32,6 +32,17 @@ namespace Ichni.Story
if (vars.floatVariables.TryGetValue(key, out float f)) return f;
if (vars.boolVariables.TryGetValue(key, out bool b)) return b ? 1f : 0f;
if (vars.stringVariables.TryGetValue(key, out string s) && float.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out float p)) return p;
if (TryGetInitialVariable(key, out StoryVariableDefinition definition))
{
return definition.type switch
{
StoryVariableType.Bool => definition.boolValue ? 1f : 0f,
StoryVariableType.Int => definition.intValue,
StoryVariableType.Float => definition.floatValue,
StoryVariableType.String when float.TryParse(definition.stringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out float parsed) => parsed,
_ => defaultValue
};
}
return defaultValue;
}
@@ -41,6 +52,17 @@ namespace Ichni.Story
if (vars.floatVariables.TryGetValue(key, out float f)) return Mathf.RoundToInt(f);
if (vars.boolVariables.TryGetValue(key, out bool b)) return b ? 1 : 0;
if (vars.stringVariables.TryGetValue(key, out string s) && float.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out float p)) return Mathf.RoundToInt(p);
if (TryGetInitialVariable(key, out StoryVariableDefinition definition))
{
return definition.type switch
{
StoryVariableType.Bool => definition.boolValue ? 1 : 0,
StoryVariableType.Int => definition.intValue,
StoryVariableType.Float => Mathf.RoundToInt(definition.floatValue),
StoryVariableType.String when float.TryParse(definition.stringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out float parsed) => Mathf.RoundToInt(parsed),
_ => defaultValue
};
}
return defaultValue;
}
@@ -50,6 +72,17 @@ namespace Ichni.Story
if (vars.boolVariables.TryGetValue(key, out bool b)) return b;
if (vars.floatVariables.TryGetValue(key, out float f)) return f != 0f;
if (vars.stringVariables.TryGetValue(key, out string s) && bool.TryParse(s, out bool p)) return p;
if (TryGetInitialVariable(key, out StoryVariableDefinition definition))
{
return definition.type switch
{
StoryVariableType.Bool => definition.boolValue,
StoryVariableType.Int => definition.intValue != 0,
StoryVariableType.Float => !Mathf.Approximately(definition.floatValue, 0f),
StoryVariableType.String when bool.TryParse(definition.stringValue, out bool parsed) => parsed,
_ => defaultValue
};
}
return defaultValue;
}
@@ -59,13 +92,25 @@ namespace Ichni.Story
if (vars.stringVariables.TryGetValue(key, out string s)) return s;
if (vars.floatVariables.TryGetValue(key, out float f)) return f.ToString(CultureInfo.InvariantCulture);
if (vars.boolVariables.TryGetValue(key, out bool b)) return b.ToString();
if (TryGetInitialVariable(key, out StoryVariableDefinition definition))
{
return definition.type switch
{
StoryVariableType.Bool => definition.boolValue.ToString(),
StoryVariableType.Int => definition.intValue.ToString(CultureInfo.InvariantCulture),
StoryVariableType.Float => definition.floatValue.ToString(CultureInfo.InvariantCulture),
StoryVariableType.String => definition.stringValue ?? string.Empty,
_ => defaultValue
};
}
return defaultValue;
}
public static bool HasVariable(string key)
{
StoryVariablesSave vars = Variables;
return vars.floatVariables.ContainsKey(key) || vars.boolVariables.ContainsKey(key) || vars.stringVariables.ContainsKey(key);
return vars.floatVariables.ContainsKey(key) || vars.boolVariables.ContainsKey(key) || vars.stringVariables.ContainsKey(key) ||
TryGetInitialVariable(key, out _);
}
// ── 写入 ────────────────────────────────────────────────────────────────
@@ -124,6 +169,20 @@ namespace Ichni.Story
if (!keepBool) vars.boolVariables.Remove(name);
}
/// <summary>
/// 从当前 StoryTree 的 StoryData 查询变量默认值。没有章节上下文时不虚构默认值,
/// 由调用方传入的 defaultValue 作为独立测试场景的回退。
/// </summary>
private static bool TryGetInitialVariable(string key, out StoryVariableDefinition definition)
{
StoryData storyData = StoryManager.instance?.treeController?.ActiveStoryData;
if (storyData != null)
return storyData.TryGetInitialVariable(key, out definition);
definition = null;
return false;
}
// ── 字典批量操作(内部供 Yarn Adapter 使用) ─────────────────────────────
internal static void SetAllVariables(Dictionary<string, float> floats, Dictionary<string, string> strings, Dictionary<string, bool> bools, bool clear = true)

View File

@@ -7,7 +7,7 @@ namespace Ichni.Story.Dialogue
/// 【临时 / 调试用】将 Yarn 对话行以"本地化后的文本"输出到 Console 的呈现器。
/// <para>用途:在没有 VN 对话 UI 的情况下,验证整条 Yarn 管线是否跑通,以及
/// Unity Localization 是否正确返回当前语言的文本(点击文本块即可在 Console 看到结果)。</para>
/// <para>阶段 3 接入 <c>VNDialoguePresenter</c> 后,可从 DialogueRunner 的呈现器列表移除本组件并删除此脚本。</para>
/// <para>正式运行使用 <c>VNDialoguePresenter</c>;本组件只应在需要观察本地化解析结果时临时启用。</para>
/// </summary>
public class ConsoleLinePresenter : DialoguePresenterBase
{

View File

@@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
namespace Ichni.Story.Dialogue
{
/// <summary>对话记录中一条已经实际显示或采用的内容。</summary>
[Serializable]
public sealed class DialogueHistoryEntry
{
public string speakerName;
public string content;
public bool isChoice;
}
/// <summary>
/// 当前一次 TextBlock 对话专用的临时记录。
/// <para>记录不会写入 ES3开始下一次对话、正常结束或点击 Back 取消时都会清空,
/// 因而它只承担 DialogUIPage 内的“本次对话回顾”职责。</para>
/// </summary>
public static class DialogueHistory
{
private static readonly List<DialogueHistoryEntry> EntriesInternal = new();
public static IReadOnlyList<DialogueHistoryEntry> Entries => EntriesInternal;
public static void BeginSession() => EntriesInternal.Clear();
public static void AddLine(string speakerName, string content)
{
if (string.IsNullOrWhiteSpace(content))
return;
EntriesInternal.Add(new DialogueHistoryEntry
{
speakerName = speakerName ?? string.Empty,
content = content,
isChoice = false
});
}
public static void AddChoice(string content)
{
if (string.IsNullOrWhiteSpace(content))
return;
EntriesInternal.Add(new DialogueHistoryEntry
{
speakerName = string.Empty,
content = content,
isChoice = true
});
}
public static void Clear() => EntriesInternal.Clear();
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fd7c83e0353e4fb7b912fb9996f64738

View File

@@ -0,0 +1,86 @@
using System;
using System.Linq;
using Yarn.Unity;
namespace Ichni.Story.Dialogue
{
/// <summary>
/// Yarn 选项的章节内记忆服务。
/// <para>玩家第一次完成某组选项后,保存其 <see cref="DialogueOption.TextID"/>;再次进入同一 TextBlock 时,
/// 若该选项仍存在且可用,则不显示选择 UI直接返回该 Option 给 Yarn。</para>
/// <para>记录随 Timeline 回滚快照一起恢复,因此回滚到选项之前会让玩家重新选择。</para>
/// </summary>
public static class StoryChoiceMemory
{
/// <summary>
/// 根据来源 Block 与当前全部 Option Text ID 生成选项组 Key。
/// Text ID 已由 Yarn 编译结果稳定生成;排序使纯显示顺序调整不会把同一组选项误认为新选项组。
/// </summary>
public static string CreateChoiceKey(string sourceBlockId, DialogueOption[] options)
{
if (string.IsNullOrWhiteSpace(sourceBlockId) || options == null || options.Length == 0 ||
options.Any(option => option == null || string.IsNullOrWhiteSpace(option.TextID)))
{
return null;
}
string optionSignature = string.Join("|", options
.Select(option => option.TextID)
.OrderBy(textId => textId, StringComparer.Ordinal));
return sourceBlockId + "::" + optionSignature;
}
/// <summary>
/// 查找已保存的选项。若 Yarn 改稿后旧 Text ID 已不存在或暂时不可用,
/// 会删除陈旧记录并回退为普通选择界面,避免自动选择错误路线。
/// </summary>
public static bool TryGetRememberedOption(string sourceBlockId, DialogueOption[] options,
out DialogueOption rememberedOption)
{
rememberedOption = null;
StoryTreeController treeController = StoryManager.instance?.treeController;
StorySaveModule saveModule = GameSaveManager.instance?.StorySaveModule;
string choiceKey = CreateChoiceKey(sourceBlockId, options);
if (treeController == null || saveModule == null || string.IsNullOrEmpty(treeController.ActiveChapterIndex) ||
string.IsNullOrEmpty(choiceKey))
{
return false;
}
if (!saveModule.TryGetChoice(treeController.ActiveChapterIndex, choiceKey, out StoryChoiceRecord record))
return false;
rememberedOption = options.FirstOrDefault(option => option != null && option.IsAvailable &&
option.TextID == record.selectedOptionTextId);
if (rememberedOption != null)
return true;
saveModule.RemoveChoice(treeController.ActiveChapterIndex, choiceKey);
return false;
}
/// <summary>
/// 保存玩家首次确认的选项。调用时先捕获所属 Block 的 Marker 快照,
/// 保证该选项及其随后写入的路线变量都能被重开流程一起回滚。
/// </summary>
public static void RememberChoice(string sourceBlockId, DialogueOption[] options, DialogueOption selectedOption)
{
StoryTreeController treeController = StoryManager.instance?.treeController;
StorySaveModule saveModule = GameSaveManager.instance?.StorySaveModule;
string choiceKey = CreateChoiceKey(sourceBlockId, options);
if (treeController == null || saveModule == null || selectedOption == null ||
string.IsNullOrEmpty(treeController.ActiveChapterIndex) || string.IsNullOrEmpty(choiceKey))
{
return;
}
StoryProgress.TryCaptureActiveBlockSnapshot();
saveModule.SetChoice(treeController.ActiveChapterIndex, new StoryChoiceRecord
{
sourceBlockId = sourceBlockId,
choiceKey = choiceKey,
selectedOptionTextId = selectedOption.TextID
});
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d73ac4c0c2034a04a7e701a64a3f8dd9

View File

@@ -3,6 +3,7 @@ using System.Reflection;
using Ichni.Story.UI;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.Localization;
using Yarn.Unity;
using Yarn.Unity.UnityLocalization;
@@ -11,7 +12,7 @@ namespace Ichni.Story.Dialogue
/// <summary>
/// 故事树 block 与 Yarn <see cref="DialogueRunner"/> 之间的桥接控制器。
/// <para>点击文本块 → 启动对应 Yarn 节点;对话结束 → 持久化剧情变量、标记 block 完成、
/// 重算解锁并存档、依次执行结束回调(如歌曲解锁提示,阶段 4 使用)。</para>
/// 重算解锁并存档、依次执行结束回调(如歌曲解锁提示)。</para>
/// </summary>
public class StoryDialogueController : MonoBehaviour
{
@@ -24,21 +25,66 @@ namespace Ichni.Story.Dialogue
[Tooltip("故事树页面:对话进行时淡出、结束后淡入(可选)。")]
public StoryUIPage storyUIPage;
[Header("Localization")]
[Tooltip("所有章节 Yarn 台词共用的 Unity Localization String Table建议命名 YarnLines。留空时保留当前 UnityLocalisedLineProvider 的既有表配置。")]
public LocalizedStringTable yarnLinesTable;
/// <summary>
/// 对话结束后依次执行并清空的回调队列。供 <c>unlock_song</c> 等 Yarn 命令(阶段 4
/// 对话结束后依次执行并清空的回调队列。供 <c>unlock_song</c> 等 Yarn 命令
/// 把"解锁提示"排队到对话结束后弹出。
/// </summary>
[System.NonSerialized] public List<UnityAction> dialogueEndActions = new List<UnityAction>();
private readonly List<UnityAction> _dialogueEndActions = new List<UnityAction>();
// 当前正在播放对话的 block id对话结束时据此标记完成
private string _activeBlockId;
// 当前 TextBlock 开始前的章节快照。它只服务于本次“中途退出对话”的恢复,
// 不会被写入 ES3也不会影响歌曲成绩、解锁 Key、Settings 或其它章节。
private ChapterStorySave _activeDialogueSnapshot;
private string _activeDialogueChapterIndex;
private bool _isCancellingDialogue;
/// <summary>
/// 当前对话对应的 TextBlock ID。Yarn 选项记忆与变量指令使用它定位所属章节 Block
/// 不应由其它系统直接赋值。
/// </summary>
public string ActiveBlockId => _activeBlockId;
/// <summary>
/// 当前是否存在尚未提交的 TextBlock 对话事务。
/// Yarn 的全局副作用(例如 grant_unlock、unlock_song、show_message必须据此决定延迟到正常结束后执行
/// 从而避免玩家按 Back 时留下已解锁内容或孤立弹窗。
/// </summary>
public bool IsDialogueTransactionActive =>
!string.IsNullOrEmpty(_activeBlockId) &&
GameSaveManager.instance?.StorySaveModule?.IsChapterTransactionActive(_activeDialogueChapterIndex) == true;
private void Awake()
{
instance = this;
EnsureUnityLocalisedLineProvider();
}
private void Start()
{
// 这些引用不会由 StoryData 自检覆盖,因为它们属于场景运行时装配而非单个章节资产。
// 仅输出可定位 Warning不阻断编辑器下的部分独立测试流程。
if (dialogueRunner == null)
{
Debug.LogWarning("[StoryDialogueController] 未配置 DialogueRunnerTextBlock 无法启动对话。", this);
return;
}
if (dialogueRunner.GetComponent<VNDialoguePresenter>() == null)
Debug.LogWarning("[StoryDialogueController] DialogueRunner 缺少 VNDialoguePresenter台词和选项不会显示。", dialogueRunner);
if (dialogueRunner.GetComponent<UnityLocalisedLineProvider>() == null)
Debug.LogWarning("[StoryDialogueController] DialogueRunner 缺少 UnityLocalisedLineProvider本地化台词无法解析。", dialogueRunner);
if (storyUIPage == null)
Debug.LogWarning("[StoryDialogueController] 未配置 StoryUIPage对话开始/结束时不会切换剧情树页面。", this);
}
private void OnEnable()
{
if (dialogueRunner != null && dialogueRunner.onDialogueComplete != null)
@@ -51,6 +97,12 @@ namespace Ichni.Story.Dialogue
dialogueRunner.onDialogueComplete.RemoveListener(HandleDialogueComplete);
}
private void OnDestroy()
{
if (instance == this)
instance = null;
}
/// <summary>
/// 播放某文本块对应的 Yarn 节点。若对话已在进行、引用缺失或节点名为空则忽略。
/// </summary>
@@ -79,13 +131,106 @@ namespace Ichni.Story.Dialogue
// 将 DialogueRunner 的 YarnProject 对齐到当前章节(支持多章节共用一个 Runner
EnsureProjectForCurrentChapter();
// StartDialogue 对缺失 Node 只会在内部抛出/记录错误;必须在开启事务前预检,
// 否则会留下一个没有机会提交或回滚的半开事务。
if (dialogueRunner.YarnProject?.Program == null ||
!dialogueRunner.YarnProject.Program.Nodes.ContainsKey(block.YarnNodeName))
{
Debug.LogError($"[StoryDialogueController] Yarn Project 中不存在节点 '{block.YarnNodeName}',已取消启动 Block '{block.blockId}'。", dialogueRunner);
return;
}
StoryTreeController treeController = StoryManager.instance?.treeController;
StorySaveModule saveModule = GameSaveManager.instance?.StorySaveModule;
string chapterIndex = treeController?.ActiveChapterIndex;
if (treeController == null || saveModule == null || string.IsNullOrEmpty(chapterIndex))
{
Debug.LogError("[StoryDialogueController] 当前章节存档上下文未就绪,拒绝开始可回滚的 TextBlock 对话。");
return;
}
// 必须在任何 Yarn 命令、选项或 Marker 快照写入前完成复制并开启事务。
// 事务期间所有剧情变化仅停留在内存;正常结束才提交,中途 Back 则完整恢复本副本。
_activeDialogueSnapshot = StorySaveCloneUtility.CloneChapter(saveModule.GetChapter(chapterIndex));
_activeDialogueChapterIndex = chapterIndex;
if (!saveModule.BeginChapterTransaction(chapterIndex))
{
_activeDialogueSnapshot = null;
_activeDialogueChapterIndex = null;
return;
}
_activeBlockId = block.blockId;
// 在 Yarn 命令或选项改变章节变量前先登记来源,并为 Marker 所在列创建快照。
StoryProgress.BeginBlockMutation(_activeBlockId);
if (storyUIPage != null)
storyUIPage.FadeOut();
// StartDialogue 返回 YarnTask此处即发即忘完成由 onDialogueComplete 处理
dialogueRunner.StartDialogue(block.YarnNodeName).Forget();
// 启动异常会回滚本次章节事务;正常结束仍由 onDialogueComplete 统一提交。
StartDialogueSafely(block.YarnNodeName).Forget();
}
/// <summary>
/// 以受保护方式启动 Yarn。缺失 Provider、Presenter 等启动级异常不应让 StorySave 的事务永久处于活动状态,
/// 因而会被转换为 Failed 流程并完整恢复进入对话前的章节快照。
/// </summary>
private async YarnTask StartDialogueSafely(string yarnNodeName)
{
try
{
await dialogueRunner.StartDialogue(yarnNodeName);
}
catch (System.Exception exception)
{
Debug.LogException(exception, dialogueRunner);
// 启动阶段可能已经让部分 Presenter 进入展示状态;先请求停止,再立即清理事务。
// 后续若收到 Runner 的完成事件,会因 activeBlockId 已被清空而被安全忽略。
if (dialogueRunner != null && dialogueRunner.IsDialogueRunning)
{
dialogueRunner.GetComponent<VNDialoguePresenter>()?.CancelCurrentPresentation();
dialogueRunner.Stop().Forget();
}
RestoreInterruptedDialogue("启动 Yarn 对话失败");
}
}
/// <summary>
/// 取消当前 TextBlock 对话并恢复进入前的章节剧情快照。
/// <para>Yarn 的 <see cref="DialogueRunner.Stop"/> 仍会触发 onDialogueComplete
/// 因此此处先标记取消状态,完成回调会走恢复流程而不是误将 Block 标记为 Completed。</para>
/// </summary>
public bool CancelActiveDialogue()
{
if (_isCancellingDialogue || string.IsNullOrEmpty(_activeBlockId) ||
dialogueRunner == null || !dialogueRunner.IsDialogueRunning)
{
return false;
}
_isCancellingDialogue = true;
_dialogueEndActions.Clear();
dialogueRunner.GetComponent<VNDialoguePresenter>()?.CancelCurrentPresentation();
dialogueRunner.Stop().Forget();
return true;
}
/// <summary>
/// 将一个全局副作用排入“本次 TextBlock 成功提交后”执行的队列。
/// <para>没有活动对话事务时会立即执行,便于未来在非 TextBlock 环境复用同一批 Yarn 命令。</para>
/// </summary>
public void ExecuteAfterDialogueCommit(UnityAction action)
{
if (action == null)
return;
if (IsDialogueTransactionActive)
{
_dialogueEndActions.Add(action);
return;
}
action.Invoke();
}
// 将 DialogueRunner 的 YarnProject 切换为当前已构建章节的 StoryData.yarnProject若不同且未在运行
@@ -115,16 +260,25 @@ namespace Ichni.Story.Dialogue
if (field == null) return;
// 已经是 UnityLocalisedLineProvider 则无需处理
if (field.GetValue(dialogueRunner) is UnityLocalisedLineProvider) return;
UnityLocalisedLineProvider provider =
dialogueRunner.GetComponent<UnityLocalisedLineProvider>();
UnityLocalisedLineProvider provider = field.GetValue(dialogueRunner) as UnityLocalisedLineProvider;
provider ??= dialogueRunner.GetComponent<UnityLocalisedLineProvider>();
if (provider != null)
{
field.SetValue(dialogueRunner, provider);
Debug.Log("[StoryDialogueController] Assigned UnityLocalisedLineProvider to DialogueRunner.lineProvider.");
if (field.GetValue(dialogueRunner) != provider)
{
field.SetValue(dialogueRunner, provider);
Debug.Log("[StoryDialogueController] Assigned UnityLocalisedLineProvider to DialogueRunner.lineProvider.");
}
// Yarn 的 Localized Line Provider 本身只持有一个 String Table 引用;统一使用 YarnLines
// 可避免在切换章节时依赖反射替换不同表。未配置时不覆盖旧场景,便于分步迁移现有 Chapter0_Lines。
if (yarnLinesTable != null && !yarnLinesTable.IsEmpty)
{
FieldInfo tableField = typeof(UnityLocalisedLineProvider).GetField(
"stringsTable", BindingFlags.NonPublic | BindingFlags.Instance);
tableField?.SetValue(provider, yarnLinesTable);
}
}
else
{
@@ -134,24 +288,35 @@ namespace Ichni.Story.Dialogue
private void HandleDialogueComplete()
{
// 持久化对话过程中写入的全局剧情变量
if (GameSaveManager.instance != null && GameSaveManager.instance.StorySaveModule != null)
GameSaveManager.instance.StorySaveModule.SaveVariables();
// Failed 流程已经清理 activeBlockId 后,底层 Runner 仍可能随后送达完成事件;此时绝不能误提交。
if (string.IsNullOrEmpty(_activeBlockId))
return;
// 标记 block 完成 → 重算解锁 → 存档OnBlockCompleted 内部已去重并保存章节进度)
if (_isCancellingDialogue)
{
RestoreCancelledDialogue();
return;
}
// 标记 Block 完成 → 重算解锁 → 保存完整章节容器(含变量、选项与回滚快照)。
if (!string.IsNullOrEmpty(_activeBlockId) &&
StoryManager.instance != null && StoryManager.instance.treeController != null)
{
StoryManager.instance.treeController.OnBlockCompleted(_activeBlockId);
}
StoryProgress.EndBlockMutation(_activeBlockId);
_activeBlockId = null;
// 所有本次 TextBlock 的变量、选项和完成状态至此才一次性写入 ES3。
GameSaveManager.instance?.StorySaveModule?.CommitChapterTransaction(_activeDialogueChapterIndex);
ClearDialogueSessionState();
// 依次执行并清空结束回调(如歌曲解锁提示)
if (dialogueEndActions.Count > 0)
if (_dialogueEndActions.Count > 0)
{
List<UnityAction> pending = new List<UnityAction>(dialogueEndActions);
dialogueEndActions.Clear();
List<UnityAction> pending = new List<UnityAction>(_dialogueEndActions);
_dialogueEndActions.Clear();
foreach (UnityAction action in pending)
{
action?.Invoke();
@@ -164,6 +329,50 @@ namespace Ichni.Story.Dialogue
{
storyUIPage.FadeIn();
}
DialogueHistory.Clear();
}
/// <summary>
/// 处理 Back 触发的 Yarn 停止回调:放弃运行期间的全部章节剧情改动,
/// 并只刷新当前已实例化的 StoryTree保留玩家拖动剧情树后的视口位置。
/// </summary>
private void RestoreCancelledDialogue()
{
RestoreInterruptedDialogue("玩家中途退出对话");
}
/// <summary>
/// 取消和失败共用的事务回滚出口。该方法具有幂等性:无活动 Block 时直接返回,
/// 因而底层 Yarn 在 Stop/异常后重复触发完成事件也不会把已回滚的数据再次提交。
/// </summary>
private void RestoreInterruptedDialogue(string reason)
{
if (string.IsNullOrEmpty(_activeBlockId))
return;
string interruptedBlockId = _activeBlockId;
string chapterIndex = _activeDialogueChapterIndex;
StoryTreeController treeController = StoryManager.instance?.treeController;
Debug.LogWarning($"[StoryDialogueController] {reason},已回滚 TextBlock '{interruptedBlockId}' 的章节剧情事务。");
StoryProgress.EndBlockMutation(interruptedBlockId);
GameSaveManager.instance?.StorySaveModule?.RollbackChapterTransaction(
chapterIndex, _activeDialogueSnapshot);
treeController?.ReloadActiveChapterProgress();
_dialogueEndActions.Clear();
DialogueHistory.Clear();
_activeBlockId = null;
ClearDialogueSessionState();
storyUIPage?.FadeIn();
}
private void ClearDialogueSessionState()
{
_activeDialogueSnapshot = null;
_activeDialogueChapterIndex = null;
_isCancellingDialogue = false;
}
}
}

View File

@@ -94,6 +94,13 @@ namespace Ichni.Story.Dialogue
public override async YarnTask OnDialogueStartedAsync()
{
// 快进是一次 Yarn 对话会话的临时操作,绝不能遗留到下一次进入 TextBlock。
_isFastForwarding = false;
_typewriterComplete = false;
// 对话记录只保留当前一次 TextBlock每次 Yarn 对话真正开始时清空上一段残留内容。
DialogueHistory.BeginSession();
if (Page != null)
{
var tcs = new YarnTaskCompletionSource<bool>();
@@ -119,6 +126,8 @@ namespace Ichni.Story.Dialogue
// ── 打字效果 ──
string fullText = line.TextWithoutCharacterName.Text;
// 记录的是本次实际展示时已经解析完成的本地化文本,而非 Yarn Key。
DialogueHistory.AddLine(line.CharacterName, fullText);
_typewriterComplete = false;
if (Page.dialogueText != null)
@@ -163,6 +172,16 @@ namespace Ichni.Story.Dialogue
{
if (Page == null) return null;
// 同一 TextBlock 的选项已被玩家选择过时,直接把原 Option 返回给 Yarn
// 让后续指令和分支仍由 Yarn 正常执行,而不是在 C# 中复制分支逻辑。
string sourceBlockId = StoryDialogueController.instance?.ActiveBlockId;
if (StoryChoiceMemory.TryGetRememberedOption(sourceBlockId, options, out DialogueOption rememberedOption))
{
// 已记忆选项在普通回顾与快进时都会自动采用;记录页仍应如实显示本次经过的分支。
DialogueHistory.AddChoice(rememberedOption.Line.TextWithoutCharacterName.Text);
return rememberedOption;
}
// 遇到选项强制打断快进
_isFastForwarding = false;
@@ -173,9 +192,12 @@ namespace Ichni.Story.Dialogue
// 显示选项容器
if (Page.choiceFrame != null) Page.choiceFrame.SetActive(true);
_optionTcs = new YarnTaskCompletionSource<DialogueOption?>();
YarnTaskCompletionSource<DialogueOption?> optionTcs = new YarnTaskCompletionSource<DialogueOption?>();
_optionTcs = optionTcs;
WaitForExternalCancelAsync(token.NextContentToken).Forget();
// 将本组选项自己的 TCS 传入取消监听,避免上一组选项的 Token 在稍后取消时
// 错误结束下一组选项。_optionTcs 只用于外部 Back 流程定位当前正在等待的选择。
WaitForExternalCancelAsync(token.NextContentToken, optionTcs).Forget();
// ── 配置每个选项按钮 ──
for (int i = 0; i < Page.choiceButtons.Length; i++)
@@ -183,13 +205,24 @@ namespace Ichni.Story.Dialogue
if (Page.choiceButtons[i] == null) continue;
if (i < options.Length)
Page.choiceButtons[i].Setup(options[i], opt => _optionTcs.TrySetResult(opt));
{
Page.choiceButtons[i].Setup(options[i], opt =>
{
// 先写入选项记忆,再结束异步等待;这样即使紧接着 Yarn 修改路线变量,
// Timeline 快照也仍然代表“本组选择之前”的状态。
StoryChoiceMemory.RememberChoice(sourceBlockId, options, opt);
DialogueHistory.AddChoice(opt.Line.TextWithoutCharacterName.Text);
optionTcs.TrySetResult(opt);
});
}
else
Page.choiceButtons[i].Cleanup();
}
// ── 等待玩家选择 ──
DialogueOption? selected = await _optionTcs.Task;
DialogueOption? selected = await optionTcs.Task;
if (_optionTcs == optionTcs)
_optionTcs = null;
// 清理所有按钮并隐藏选项容器
foreach (ChoiceButton btn in Page.choiceButtons)
@@ -202,6 +235,10 @@ namespace Ichni.Story.Dialogue
public override async YarnTask OnDialogueCompleteAsync()
{
// 无论正常完成还是被 StoryDialogueController 通过 Stop 取消,都要结束本次快进状态。
_isFastForwarding = false;
_typewriterComplete = true;
if (Page != null)
{
var tcs = new YarnTaskCompletionSource<bool>();
@@ -210,10 +247,48 @@ namespace Ichni.Story.Dialogue
}
}
/// <summary>
/// 打开对话记录前的 UI 收束操作。
/// 停止自动快进,并补全当前正在打字的一句,但绝不请求下一句,
/// 因而 History 覆盖层打开期间不会改变 Yarn 的剧情进度。
/// </summary>
public void PrepareForHistory()
{
_isFastForwarding = false;
if (!_typewriterComplete && _runner != null)
_runner.RequestHurryUpLine();
}
/// <summary>
/// 为 DialogUIPage 的 Back 取消流程收起所有临时交互。
/// 真正终止 Yarn 和恢复章节快照由 <see cref="StoryDialogueController"/> 负责;
/// 本方法不写入任何剧情变量或选项。
/// </summary>
public void CancelCurrentPresentation()
{
_isFastForwarding = false;
_typewriterComplete = true;
_optionTcs?.TrySetResult(null);
if (Page == null)
return;
foreach (ChoiceButton button in Page.choiceButtons)
button?.Cleanup();
if (Page.choiceFrame != null)
Page.choiceFrame.SetActive(false);
}
// ── 内部方法 ─────────────────────────────────────────────────────────────
private void OnAdvanceButtonClicked()
{
// 未记忆选项必须由 ChoiceButton 明确选择;推进按钮不能越过当前选择。
if (_optionTcs != null)
return;
// 如果玩家在快进时点击屏幕/推进键,则打断快进状态并停止当前动作
if (_isFastForwarding)
{
@@ -231,6 +306,10 @@ namespace Ichni.Story.Dialogue
private void OnFastForwardButtonClicked()
{
// 快进可以跨过已经记忆的选项,但不能跳过正在等待玩家首次决定的选项。
if (_optionTcs != null)
return;
_isFastForwarding = true;
if (_runner == null) return;
@@ -263,10 +342,12 @@ namespace Ichni.Story.Dialogue
}
}
private async YarnTask WaitForExternalCancelAsync(CancellationToken externalToken)
private static async YarnTask WaitForExternalCancelAsync(
CancellationToken externalToken,
YarnTaskCompletionSource<DialogueOption?> optionTcs)
{
await YarnTask.WaitUntilCanceled(externalToken).SuppressCancellationThrow();
_optionTcs?.TrySetResult(null);
optionTcs.TrySetResult(null);
}
}
}

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: cc2195a6a842d2044a7087f3e7fc63e1
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 212db2c9a00528745af80a8df649d4d7
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,25 @@
using UnityEngine;
using UnityEngine.UI;
namespace Ichni.Story
{
/// <summary>
/// 当前阶段使用 <see cref="Image"/> 显示静态 Helper 插画的 Presenter。
/// <para>若 StoryHelperData 未配置 portraitSprite则保留场景中已有的 Sprite 作为回退,
/// 方便在视觉资源尚未完全迁移时继续工作。</para>
/// </summary>
public class StaticStoryHelperVisualPresenter : StoryHelperVisualPresenter
{
[SerializeField, Tooltip("用于显示当前 Helper 静态立绘的 Image。")]
private Image portraitImage;
public override void ApplyHelper(StoryHelperData helperData)
{
if (portraitImage == null || helperData == null || helperData.portraitSprite == null)
return;
// 不调用 SetNativeSizeHelper 的实际 UI 尺寸由场景布局统一控制。
portraitImage.sprite = helperData.portraitSprite;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c31f8bd5e6ca44399645c4f7cb21e0ad
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,388 @@
using System;
using System.Collections.Generic;
using DG.Tweening;
using UnityEngine;
using UnityEngine.Localization.Settings;
using UnityEngine.UI;
namespace Ichni.Story
{
/// <summary>
/// 场景中常驻 Helper 的交互控制器。
/// <para>点击后会按剧情条件筛选并加权随机抽取一条台词,实例化 HelperTalk Prefab 显示;
/// 此过程不修改剧情变量、Block 完成状态或存档。</para>
/// </summary>
[RequireComponent(typeof(Button))]
public class StoryHelperController : MonoBehaviour
{
[Header("References")]
[SerializeField, Tooltip("承接 Helper 点击的 Button。建议挂在固定根节点而非会移动的视觉节点上。")]
private Button helperButton;
[SerializeField, Tooltip("当前 Helper 的视觉 Presenter。未来可替换为 Spine 或 Live2D 对应实现。")]
private StoryHelperVisualPresenter visualPresenter;
[SerializeField, Tooltip("点击后实例化的 HelperTalk Prefab。它独立于 MessageBox。")]
private StoryHelperTalk helperTalkPrefab;
[SerializeField, Tooltip("运行时 HelperTalk 的父节点。留空时使用当前 Helper 根节点。")]
private Transform helperTalkContainer;
[Header("Motion Roots")]
[SerializeField, Tooltip("只承担循环漂浮动画的节点。")]
private RectTransform idleRoot;
[SerializeField, Tooltip("只承担点击反馈的节点。它应位于 Idle Root 内部,避免与循环漂浮争夺同一属性。")]
private RectTransform clickRoot;
[Header("Idle Animation")]
[SerializeField, Min(0f), Tooltip("循环漂浮的最大纵向偏移Canvas 单位)。")]
private float idleFloatDistance = 8f;
[SerializeField, Min(0.05f), Tooltip("从基准位置移动到最高点或最低点的单程时间。")]
private float idleHalfDuration = 1.35f;
[Header("Click Animation")]
[SerializeField, Min(0.01f), Tooltip("点击缩放与位移反馈持续时间。")]
private float clickPunchDuration = 0.2f;
[SerializeField, Min(0f), Tooltip("点击时的缩放强度0.035 表示约 3.5% 的轻微放大。")]
private float clickPunchScale = 0.035f;
[SerializeField, Min(0f), Tooltip("点击时的纵向位移反馈Canvas 单位)。")]
private float clickPunchOffset = 5f;
[SerializeField, Min(0f), Tooltip("连续点击的最小间隔,防止重复实例化大量气泡。")]
private float clickCooldown = 0.3f;
private readonly List<StoryHelperDialogueDefinition> _eligibleDialogues = new();
private Sequence _idleSequence;
private Sequence _clickSequence;
private StoryHelperTalk _activeTalk;
private Vector2 _idleBasePosition;
private Vector2 _clickBasePosition;
private Vector3 _clickBaseScale;
private string _lastDialogueId;
private StoryHelperData _appliedHelperData;
private StoryManager _subscribedStoryManager;
private float _nextClickTime;
private void Awake()
{
if (helperButton == null)
helperButton = GetComponent<Button>();
if (idleRoot == null)
idleRoot = transform as RectTransform;
if (clickRoot == null && visualPresenter != null)
clickRoot = visualPresenter.transform as RectTransform;
if (helperTalkContainer == null)
helperTalkContainer = transform;
CacheBaseTransforms();
}
private void OnEnable()
{
if (helperButton != null)
helperButton.onClick.AddListener(HandleHelperClicked);
SubscribeToStoryManager();
PlayIdleAnimation();
}
private void Start()
{
// OnEnable 的调用顺序可能早于 StoryManager.AwakeStart 时再次同步,
// 确保首次进入 MenuScene 也能应用 Inspector 中配置的默认 Helper。
SubscribeToStoryManager();
}
private void OnDisable()
{
if (helperButton != null)
helperButton.onClick.RemoveListener(HandleHelperClicked);
UnsubscribeFromStoryManager();
KillAndRestoreMotion();
CloseActiveTalk();
}
private void OnDestroy()
{
_idleSequence?.Kill();
_clickSequence?.Kill();
}
/// <summary>点击 Helper 的统一入口:播放反馈后创建一条新的专属气泡。</summary>
private void HandleHelperClicked()
{
if (Time.unscaledTime >= _nextClickTime)
{
TryCreateRandomTalk();
_nextClickTime = Time.unscaledTime + clickCooldown;
}
PlayClickAnimation();
}
/// <summary>
/// 从当前 Helper 对话池按条件和权重抽取文本,并实例化一个新的 HelperTalk。
/// </summary>
private void TryCreateRandomTalk()
{
StoryHelperData helperData = StoryManager.instance != null
? StoryManager.instance.ActiveHelperData
: null;
if (helperData == null)
{
Debug.LogWarning("[StoryHelper] 未配置当前 StoryHelperData无法创建 HelperTalk。", this);
return;
}
if (helperTalkPrefab == null)
{
Debug.LogWarning("[StoryHelper] 未配置 HelperTalk Prefab无法创建 Helper 对话气泡。", this);
return;
}
StoryHelperDialogueDefinition dialogue = SelectDialogue(helperData);
string contentKey = dialogue != null ? dialogue.dialogueKey : helperData.fallbackDialogueKey;
if (string.IsNullOrEmpty(contentKey))
{
Debug.LogWarning($"[StoryHelper] Helper '{helperData.name}' 没有可用候选对话,也未配置 Fallback Dialogue Key。", this);
return;
}
if (dialogue != null)
_lastDialogueId = GetDialogueIdentity(dialogue);
CloseActiveTalk();
_activeTalk = Instantiate(helperTalkPrefab, helperTalkContainer);
_activeTalk.Closed += HandleTalkClosed;
_activeTalk.Show(
GetLocalizedText(helperData, helperData.displayNameKey, helperData.name),
GetLocalizedText(helperData, contentKey, contentKey));
}
/// <summary>
/// 构建当前可用候选池;当存在其它候选时,排除与上一条相同的台词,避免连续重复。
/// </summary>
private StoryHelperDialogueDefinition SelectDialogue(StoryHelperData helperData)
{
_eligibleDialogues.Clear();
if (helperData.dialoguePool == null)
return null;
foreach (StoryHelperDialogueDefinition dialogue in helperData.dialoguePool)
{
if (dialogue == null || string.IsNullOrEmpty(dialogue.dialogueKey) || !IsDialogueAvailable(dialogue))
continue;
_eligibleDialogues.Add(dialogue);
}
if (_eligibleDialogues.Count == 0)
return null;
bool hasAlternative = false;
foreach (StoryHelperDialogueDefinition dialogue in _eligibleDialogues)
{
if (GetDialogueIdentity(dialogue) != _lastDialogueId)
{
hasAlternative = true;
break;
}
}
int totalWeight = 0;
foreach (StoryHelperDialogueDefinition dialogue in _eligibleDialogues)
{
if (hasAlternative && GetDialogueIdentity(dialogue) == _lastDialogueId)
continue;
totalWeight += Mathf.Max(1, dialogue.weight);
}
int randomWeight = UnityEngine.Random.Range(0, totalWeight);
foreach (StoryHelperDialogueDefinition dialogue in _eligibleDialogues)
{
if (hasAlternative && GetDialogueIdentity(dialogue) == _lastDialogueId)
continue;
randomWeight -= Mathf.Max(1, dialogue.weight);
if (randomWeight < 0)
return dialogue;
}
return _eligibleDialogues[0];
}
/// <summary>
/// 复用 StoryCondition 的通用条件树。未配置条件代表始终可用;已配置条件必须在当前已构建章节中求值,
/// 以避免全局 Helper 在没有章节上下文时误读其它章节的 Block 状态。
/// </summary>
private static bool IsDialogueAvailable(StoryHelperDialogueDefinition dialogue)
{
StoryCondition condition = dialogue.availabilityCondition;
if (condition == null || !condition.IsConfigured)
return true;
StoryTreeController treeController = StoryManager.instance != null
? StoryManager.instance.treeController
: null;
if (treeController == null || treeController.ActiveStoryData == null)
return false;
return condition.IsSatisfied((str) => StoryVariables.GetInt(str), treeController.IsBlockCompleted);
}
private static string GetDialogueIdentity(StoryHelperDialogueDefinition dialogue)
{
return string.IsNullOrEmpty(dialogue.dialogueId) ? dialogue.dialogueKey : dialogue.dialogueId;
}
/// <summary>
/// 创建不影响点击节点的轻微循环漂浮。Idle 与 Click 使用不同节点,避免 DOTween 同时写入
/// 同一个位置或缩放属性造成跳动。
/// </summary>
private void PlayIdleAnimation()
{
if (idleRoot == null || idleFloatDistance <= 0f)
return;
_idleSequence?.Kill();
idleRoot.anchoredPosition = _idleBasePosition;
_idleSequence = DOTween.Sequence()
.Pause()
.SetUpdate(true)
.Append(idleRoot.DOAnchorPosY(_idleBasePosition.y + idleFloatDistance, idleHalfDuration).SetEase(Ease.InOutSine))
.Append(idleRoot.DOAnchorPosY(_idleBasePosition.y - idleFloatDistance, idleHalfDuration * 2f).SetEase(Ease.InOutSine))
.Append(idleRoot.DOAnchorPosY(_idleBasePosition.y, idleHalfDuration).SetEase(Ease.InOutSine))
.SetLoops(-1);
_idleSequence.Play();
}
/// <summary>播放一次克制的点击反馈,不会中断 Helper 的循环漂浮动画。</summary>
private void PlayClickAnimation()
{
if (clickRoot == null)
return;
_clickSequence?.Kill();
clickRoot.anchoredPosition = _clickBasePosition;
clickRoot.localScale = _clickBaseScale;
_clickSequence = DOTween.Sequence()
.Pause()
.SetUpdate(true)
.Join(clickRoot.DOPunchScale(Vector3.one * clickPunchScale, clickPunchDuration, 5, 0.7f))
.Join(clickRoot.DOPunchAnchorPos(Vector2.up * clickPunchOffset, clickPunchDuration, 5, 0.7f));
_clickSequence.Play();
}
private void HandleActiveHelperChanged(StoryHelperData helperData)
{
_lastDialogueId = null;
ApplyHelper(helperData);
CloseActiveTalk();
}
/// <summary>
/// Helper 与 StoryManager 的 Awake 顺序不固定OnEnable 与 Start 都调用本方法,
/// 确保首次进入场景时既能应用默认 Helper也能为未来的 Helper 切换保留可靠事件入口。
/// </summary>
private void SubscribeToStoryManager()
{
StoryManager manager = StoryManager.instance;
if (manager == null || _subscribedStoryManager == manager)
return;
UnsubscribeFromStoryManager();
_subscribedStoryManager = manager;
_subscribedStoryManager.ActiveHelperChanged += HandleActiveHelperChanged;
ApplyHelper(_subscribedStoryManager.ActiveHelperData);
}
private void UnsubscribeFromStoryManager()
{
if (_subscribedStoryManager == null)
return;
_subscribedStoryManager.ActiveHelperChanged -= HandleActiveHelperChanged;
_subscribedStoryManager = null;
}
private void ApplyHelper(StoryHelperData helperData)
{
if (_appliedHelperData == helperData)
return;
_appliedHelperData = helperData;
visualPresenter?.ApplyHelper(helperData);
}
/// <summary>本地化失败时显示 Key确保测试阶段仍能定位缺失条目而不是得到空白气泡。</summary>
private string GetLocalizedText(StoryHelperData helperData, string key, string fallback)
{
if (string.IsNullOrEmpty(key))
return fallback;
try
{
string localized = LocalizationSettings.StringDatabase.GetLocalizedString(helperData.localizationTable, key);
return string.IsNullOrEmpty(localized) ? fallback : localized;
}
catch (Exception exception)
{
Debug.LogWarning($"[StoryHelper] 无法本地化 Key '{key}',将回退显示 '{fallback}'。{exception.Message}", this);
return fallback;
}
}
private void CacheBaseTransforms()
{
if (idleRoot != null)
_idleBasePosition = idleRoot.anchoredPosition;
if (clickRoot != null)
{
_clickBasePosition = clickRoot.anchoredPosition;
_clickBaseScale = clickRoot.localScale;
}
}
private void KillAndRestoreMotion()
{
_idleSequence?.Kill();
_clickSequence?.Kill();
_idleSequence = null;
_clickSequence = null;
if (idleRoot != null)
idleRoot.anchoredPosition = _idleBasePosition;
if (clickRoot != null)
{
clickRoot.anchoredPosition = _clickBasePosition;
clickRoot.localScale = _clickBaseScale;
}
}
private void CloseActiveTalk()
{
if (_activeTalk == null)
return;
_activeTalk.Closed -= HandleTalkClosed;
_activeTalk.CloseImmediately();
_activeTalk = null;
}
private void HandleTalkClosed(StoryHelperTalk talk)
{
talk.Closed -= HandleTalkClosed;
if (_activeTalk == talk)
_activeTalk = null;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8e3e75d25eea4224a487c9de56d5a91c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,102 @@
using System;
using DG.Tweening;
using TMPro;
using UnityEngine;
namespace Ichni.Story
{
/// <summary>
/// 由 <see cref="StoryHelperController"/> 按需实例化的 Helper 专属对话气泡。
/// <para>每个实例只显示一条文本:淡入、停留、淡出后自动销毁,因此场景中不需要常驻气泡,
/// 也不会与通用 MessageBox 队列产生耦合。</para>
/// </summary>
[RequireComponent(typeof(CanvasGroup))]
public class StoryHelperTalk : MonoBehaviour
{
[SerializeField, Tooltip("显示 Helper 名称的文本。")]
private TMP_Text speakerText;
[SerializeField, Tooltip("显示随机 Helper 对话的文本。")]
private TMP_Text contentText;
[Header("Animation")]
[SerializeField, Min(0.01f), Tooltip("淡入与缩放入场时间。")]
private float showDuration = 0.18f;
[SerializeField, Min(0f), Tooltip("气泡完整显示后的停留时间。")]
private float visibleDuration = 4.5f;
[SerializeField, Min(0.01f), Tooltip("淡出与缩放退场时间。")]
private float hideDuration = 0.16f;
[SerializeField, Range(0.5f, 1f), Tooltip("入场与退场时相对原始大小的缩放比例。")]
private float initialScaleMultiplier = 0.92f;
/// <summary>气泡结束显示并准备销毁时触发,供创建者清理当前实例引用。</summary>
public event Action<StoryHelperTalk> Closed;
private CanvasGroup _canvasGroup;
private RectTransform _rectTransform;
private Sequence _sequence;
private Vector3 _baseScale;
private bool _isClosed;
private void Awake()
{
_canvasGroup = GetComponent<CanvasGroup>();
_rectTransform = transform as RectTransform;
_baseScale = _rectTransform != null ? _rectTransform.localScale : Vector3.one;
}
/// <summary>
/// 写入文本并播放完整的出现、停留与收回流程。
/// 所有 DOTween Sequence 均先 Pause再手动调用 Play符合项目约定。
/// </summary>
public void Show(string speaker, string content)
{
if (speakerText == null || contentText == null || _rectTransform == null)
{
Debug.LogWarning("[StoryHelperTalk] Prefab 未配置 Speaker Text、Content Text 或 RectTransform已取消显示。", this);
CloseImmediately();
return;
}
speakerText.text = speaker;
contentText.text = content;
_canvasGroup.alpha = 0f;
_rectTransform.localScale = _baseScale * initialScaleMultiplier;
_sequence?.Kill();
_sequence = DOTween.Sequence()
.Pause()
.SetUpdate(true)
.Append(_canvasGroup.DOFade(1f, showDuration))
.Join(_rectTransform.DOScale(_baseScale, showDuration).SetEase(Ease.OutBack))
.AppendInterval(visibleDuration)
.Append(_canvasGroup.DOFade(0f, hideDuration))
.Join(_rectTransform.DOScale(_baseScale * initialScaleMultiplier, hideDuration).SetEase(Ease.InQuad))
.OnComplete(CloseImmediately);
_sequence.Play();
}
/// <summary>
/// 立即关闭并销毁该运行时实例。再次点击 Helper 时会调用它,使旧文本不会与新文本叠加。
/// </summary>
public void CloseImmediately()
{
if (_isClosed)
return;
_isClosed = true;
_sequence?.Kill();
_sequence = null;
Closed?.Invoke(this);
Destroy(gameObject);
}
private void OnDestroy()
{
_sequence?.Kill();
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e8bca20cc0f14b97944b95bdcf47d506
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,15 @@
using UnityEngine;
namespace Ichni.Story
{
/// <summary>
/// Helper 视觉表现的抽象接口。
/// <para>StoryHelperController 不直接依赖 Image、Spine 或 Live2D。未来替换表现方式时
/// 只需实现本类并替换场景引用,点击、随机台词与气泡逻辑无需改变。</para>
/// </summary>
public abstract class StoryHelperVisualPresenter : MonoBehaviour
{
/// <summary>根据当前 Helper 数据刷新视觉资源。</summary>
public abstract void ApplyHelper(StoryHelperData helperData);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 62eb3d8ec1f74ca5a981b254ecbc5187
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -3,59 +3,80 @@ using UnityEngine;
namespace Ichni.Story
{
/// <summary>
/// 外部玩法系统修改剧情进度的统一入口。
///
/// StoryVariables 只负责读写内存中的变量字典,不会自动落盘或刷新故事树。
/// 本类将“写变量、保存变量、重算 Block 状态”收口,避免教程、小游戏等系统重复实现
/// 不完整的存档流程。
/// 修改章节剧情进度的唯一业务入口。
/// <para>StoryVariables 只管理内存字典;本类负责在写入前创建 Timeline 回滚快照、保存当前章节、
/// 并刷新剧情树状态。Yarn 指令、TutorialBlock 与未来小游戏都应通过这里写入。</para>
/// </summary>
public static class StoryProgress
{
/// <summary>
/// 写入布尔剧情变量、保存到 StorySaveModule并立即重新推导当前故事树
/// 可供教程以外的玩法系统使用。
/// </summary>
public static bool SetBoolAndRefresh(string variableName, bool value)
{
if (!TrySetBoolAndSave(variableName, value))
return false;
// 对话开始时由 StoryDialogueController 设定。它用于判断本次变量/选项写入属于哪个 Block
// 从而在 Marker 所在列首次被执行前截取正确快照
private static string _activeMutationBlockId;
StoryManager.instance?.treeController?.RefreshAllStates();
return true;
/// <summary>
/// 声明接下来的剧情变量或选项写入属于指定 Block。仅在对话运行期间有效。
/// </summary>
public static void BeginBlockMutation(string blockId)
{
_activeMutationBlockId = blockId;
TryCaptureActiveBlockSnapshot();
}
/// <summary>
/// 写入整型剧情变量、保存到 StorySaveModule并立即重新推导当前故事树
/// 路线选项可通过本方法写入 Forbidden Condition 所读取的变量,确保玩家确认选择后,
/// 同一帧内即可把其它路线的 Block 更新为 Forbidden。
/// 结束指定 Block 的写入上下文,避免后续非剧情系统错误沿用旧的来源 Block
/// </summary>
public static bool SetIntAndRefresh(string variableName, int value)
public static void EndBlockMutation(string blockId)
{
if (!TrySetIntAndSave(variableName, value))
return false;
StoryManager.instance?.treeController?.RefreshAllStates();
return true;
if (_activeMutationBlockId == blockId)
_activeMutationBlockId = null;
}
/// <summary>
/// 处理一次 TutorialBlock 的选择结果
/// 无论选择“游玩”还是“跳过”,都会将其进度变量设为 true并将教程节点标记为完成。
/// 后续 Block 的解锁应依赖 <see cref="StoryBlockDefinition.tutorialProgressVariable"/>
/// 而不是依赖该节点的 completedBlockIds以便和 Yarn 及其它剧情变量共用同一条件系统。
/// 为当前写入来源捕获 Marker 快照。无章节、无来源 Block 或无对应 Marker 时安全返回 false
/// </summary>
public static bool TryCaptureActiveBlockSnapshot()
{
StoryTreeController treeController = StoryManager.instance?.treeController;
string blockId = !string.IsNullOrEmpty(_activeMutationBlockId)
? _activeMutationBlockId
: treeController?.currentBlock?.blockId;
return treeController != null && !string.IsNullOrEmpty(blockId) &&
treeController.EnsureRollbackSnapshotsForBlock(blockId);
}
public static bool SetBoolAndRefresh(string variableName, bool value) =>
TryWrite(variableName, () => StoryVariables.SetBool(variableName, value));
public static bool SetIntAndRefresh(string variableName, int value) =>
TryWrite(variableName, () => StoryVariables.SetInt(variableName, value));
public static bool AddIntAndRefresh(string variableName, int delta) =>
SetIntAndRefresh(variableName, StoryVariables.GetInt(variableName) + delta);
public static bool SetFloatAndRefresh(string variableName, float value) =>
TryWrite(variableName, () => StoryVariables.SetFloat(variableName, value));
public static bool AddFloatAndRefresh(string variableName, float delta) =>
SetFloatAndRefresh(variableName, StoryVariables.GetFloat(variableName) + delta);
public static bool SetStringAndRefresh(string variableName, string value) =>
TryWrite(variableName, () => StoryVariables.SetString(variableName, value));
public static bool RemoveVariableAndRefresh(string variableName) =>
TryWrite(variableName, () => StoryVariables.Remove(variableName));
/// <summary>
/// 处理 TutorialBlock 的“游玩”或“跳过”确认。两种选择都会立即写入进度变量并完成 Block
/// 完成后的 TutorialBlock 仍可点击,是因为其 View 自身允许 Completed 状态交互。
/// </summary>
public static bool ResolveTutorial(StoryBlockDefinition tutorialBlock)
{
if (tutorialBlock == null || tutorialBlock.blockType != StoryBlockType.Tutorial)
{
Debug.LogWarning("[StoryProgress] 无法处理空对象或非 Tutorial 类型的 Block。");
return false;
}
if (string.IsNullOrWhiteSpace(tutorialBlock.blockId) ||
if (tutorialBlock == null || tutorialBlock.blockType != StoryBlockType.Tutorial ||
string.IsNullOrWhiteSpace(tutorialBlock.blockId) ||
string.IsNullOrWhiteSpace(tutorialBlock.tutorialProgressVariable))
{
Debug.LogWarning($"[StoryProgress] TutorialBlock '{tutorialBlock?.blockId}' 缺少 Block ID 或 Progress Variable,拒绝解锁。");
Debug.LogWarning("[StoryProgress] TutorialBlock 缺少必要的 Block ID 或 Progress Variable。");
return false;
}
@@ -66,49 +87,19 @@ namespace Ichni.Story
return false;
}
if (!TrySetBoolAndSave(tutorialBlock.tutorialProgressVariable, true))
// Tutorial 不经过 Yarn 对话上下文,显式由自己的 Block ID 触发同一套快照逻辑。
treeController.EnsureRollbackSnapshotsForBlock(tutorialBlock.blockId);
if (!SetBoolAndRefresh(tutorialBlock.tutorialProgressVariable, true))
return false;
// completedBlockIds 只负责教程节点自身的 Completed 外观;
// Completed TutorialBlock 仍可点击,以便玩家重复进入教程。
// 后续节点应在 StoryData 中以 VariableCondition 读取上面的剧情变量。
treeController.OnBlockCompleted(tutorialBlock.blockId);
return true;
}
/// <summary>
/// 清除一个章节中所有 TutorialBlock 声明的进度变量
/// 目前供 StoryTreeController 的章节进度调试重置使用,确保变量条件与 completedBlockIds 一起回到初始状态。
/// 统一完成“检查上下文 → 捕获回滚点 → 写变量 → 保存章节 → 刷新树”的事务性流程
/// </summary>
public static void ClearTutorialProgressForChapter(StoryData storyData)
{
if (storyData?.blocks == null ||
GameSaveManager.instance == null || GameSaveManager.instance.StorySaveModule == null)
{
return;
}
bool changed = false;
foreach (StoryBlockDefinition block in storyData.blocks)
{
if (block == null || block.blockType != StoryBlockType.Tutorial ||
string.IsNullOrWhiteSpace(block.tutorialProgressVariable))
{
continue;
}
if (!StoryVariables.HasVariable(block.tutorialProgressVariable))
continue;
StoryVariables.Remove(block.tutorialProgressVariable);
changed = true;
}
if (changed)
GameSaveManager.instance.StorySaveModule.SaveVariables();
}
private static bool TrySetBoolAndSave(string variableName, bool value)
private static bool TryWrite(string variableName, System.Action write)
{
if (string.IsNullOrWhiteSpace(variableName))
{
@@ -116,37 +107,17 @@ namespace Ichni.Story
return false;
}
if (GameSaveManager.instance == null || GameSaveManager.instance.StorySaveModule == null)
if (GameSaveManager.instance?.StorySaveModule == null || StoryManager.instance?.treeController == null)
{
Debug.LogWarning("[StoryProgress] StorySaveModule 未就绪,拒绝写入剧情变量。");
Debug.LogWarning("[StoryProgress] 剧情章节或 StorySaveModule 未就绪,拒绝写入变量。");
return false;
}
StoryVariables.SetBool(variableName, value);
GameSaveManager.instance.StorySaveModule.SaveVariables();
return true;
}
/// <summary>
/// 整型变量的保存实现。Yarn 底层会以 float 字典持久化数字,具体存储细节由
/// StoryVariables.SetInt 统一处理,调用方不应直接操作 StorySaveModule.variables。
/// </summary>
private static bool TrySetIntAndSave(string variableName, int value)
{
if (string.IsNullOrWhiteSpace(variableName))
{
Debug.LogWarning("[StoryProgress] 剧情变量名为空,拒绝写入。");
return false;
}
if (GameSaveManager.instance == null || GameSaveManager.instance.StorySaveModule == null)
{
Debug.LogWarning("[StoryProgress] StorySaveModule 未就绪,拒绝写入剧情变量。");
return false;
}
StoryVariables.SetInt(variableName, value);
GameSaveManager.instance.StorySaveModule.SaveVariables();
// 快照必须在写入前完成,才能保证 Marker 回滚真正恢复到该列之前。
TryCaptureActiveBlockSnapshot();
write();
GameSaveManager.instance.StorySaveModule.SaveActiveChapter();
StoryManager.instance.treeController.RefreshAllStates();
return true;
}
}

View File

@@ -0,0 +1,65 @@
using System;
using Ichni.Menu;
using UnityEngine;
namespace Ichni.Story
{
/// <summary>
/// 从剧情 SongBlock 进入选曲时携带的临时上下文。
/// 它只存在于 DontDestroyOnLoad 的 InformationTransistor 中,不写入存档:歌曲完成与
/// 剧情 Block 完成仍由各自正式存档负责;本类仅用于一次页面往返的目标校验与视口恢复。
/// </summary>
[Serializable]
public class StorySongEntryContext
{
/// <summary>是否正处于一次由剧情 SongBlock 发起、尚未结束的选曲流程。</summary>
public bool isActive;
/// <summary>发起入口的章节 ID用于防止跨章节同名歌曲被误判为目标歌曲。</summary>
public string chapterIndex;
/// <summary>发起本次选曲的 SongBlock ID目标歌曲确认后会完成此 Block。</summary>
public string sourceBlockId;
/// <summary>SongItemData.songName对应 StoryBlockDefinition.songName 的稳定歌曲 ID。</summary>
public string targetSongId;
/// <summary>离开剧情页时 StoryTree Content 的位置,用于返回后还原浏览位置。</summary>
public Vector2 storyTreeContentPosition;
/// <summary>仅在确认进入目标歌曲后设为 true决定 GameScene 返回剧情页而非普通选曲页。</summary>
public bool returnToStoryAfterGame;
/// <summary>开始一次新的剧情歌曲入口,覆盖任何未完成的旧运行时上下文。</summary>
public void Begin(string chapterId, string blockId, string songId, Vector2 contentPosition)
{
isActive = true;
chapterIndex = chapterId;
sourceBlockId = blockId;
targetSongId = songId;
storyTreeContentPosition = contentPosition;
returnToStoryAfterGame = false;
}
/// <summary>
/// 判断玩家当前确认进入的歌曲是否正是剧情入口指定的目标。
/// 必须同时比对章节与稳定歌曲 ID不能仅使用显示名或对象引用。
/// </summary>
public bool IsTargetSong(ChapterSelectionUnit chapter, SongItemData song)
{
return isActive && chapter != null && song != null &&
chapter.chapterIndex == chapterIndex && song.songName == targetSongId;
}
/// <summary>结束本次页面往返并清除所有瞬时数据,避免影响后续普通选曲。</summary>
public void Clear()
{
isActive = false;
chapterIndex = null;
sourceBlockId = null;
targetSongId = null;
storyTreeContentPosition = Vector2.zero;
returnToStoryAfterGame = false;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2e8fa775d6ca4c6aa9c5f701c0d52eab

View File

@@ -105,8 +105,13 @@ namespace Ichni.Story
string.IsNullOrWhiteSpace(tutorialBlock.tutorialProgressVariable) ||
tutorialBlock.tutorialDifficultySaveId < 0)
{
Debug.LogWarning($"[TutorialFlowController] TutorialBlock '{tutorialBlock?.blockId}' 的 Tutorial Key、Progress Variable 或 Difficulty Save ID 未配置完整。\n" +
$"Tutorial Key:{tutorialBlock.tutorialKey}, Progress Variable:{tutorialBlock.tutorialProgressVariable}, Difficulty Save ID:{tutorialBlock.tutorialDifficultySaveId}");
// tutorialBlock 本身也可能为空;错误日志不能因为读取空对象字段而掩盖真正的配置问题。
Debug.LogWarning(
$"[TutorialFlowController] TutorialBlock '{tutorialBlock?.blockId}' 的 Tutorial Key、" +
$"Progress Variable 或 Difficulty Save ID 未配置完整。" +
$" Tutorial Key:{tutorialBlock?.tutorialKey}," +
$" Progress Variable:{tutorialBlock?.tutorialProgressVariable}," +
$" Difficulty Save ID:{tutorialBlock?.tutorialDifficultySaveId}");
return false;
}

View File

@@ -15,17 +15,62 @@ namespace Ichni.Story
{
public string chapterIndex;
// 已完成 block 的 id 列表(顺序无关,去重由 StoryTreeController 保证)
// 已完成 Block 的 ID 列表(顺序无关,去重由 StoryTreeController 保证)
public List<string> completedBlockIds = new();
// 章节内的对话选项记录choiceKey -> 选中项索引)。选项不跨章节引用。
public Dictionary<string, int> selectedChoices = new();
/// <summary>
/// 本章节已经确认过的 Yarn 选项。
/// Key 由 <see cref="Dialogue.StoryChoiceMemory"/> 根据来源 Block 与可选项文本 ID 生成,
/// Value 保存稳定的文本 ID而非 UI 下标;因此本地化、隐藏不可用选项或调整显示顺序时不会误选。
/// </summary>
public Dictionary<string, StoryChoiceRecord> selectedChoices = new();
/// <summary>
/// 只属于本章节的永久剧情变量。章节之间完全隔离,重开本章节时也只回滚这里的数据。
/// </summary>
public StoryVariablesSave storyVariables = new();
/// <summary>
/// 已经到达过的 Timeline 回滚点快照markerId -> 快照)。
/// 快照创建在 Marker 所在列的第一个 Block 写入前,故恢复后该列及其后的内容都会被重置。
/// </summary>
public Dictionary<string, StoryRollbackSnapshot> markerRollbackSnapshots = new();
}
/// <summary>
/// 全局剧情变量存档。float / string / bool 三类型与 Yarn Spinner 的
/// <c>VariableStorageBehaviour.GetAllVariables/SetAllVariables</c> 契约一致,
/// 供阶段 2 的 StoryVariableStorage 直接读写。
/// 一次 Yarn 选项的持久化结果。
/// 不保存运行时 Option 下标,避免因翻译、选项可用性或显示顺序变化而恢复到错误选项。
/// </summary>
[Serializable]
public class StoryChoiceRecord
{
/// <summary>触发这组选项的 TextBlock ID用于诊断和后续内容审查。</summary>
public string sourceBlockId;
/// <summary>由来源 Block 与全部 Option Text ID 生成的稳定选项组 Key。</summary>
public string choiceKey;
/// <summary>玩家第一次选择的 Yarn DialogueOption.TextID。</summary>
public string selectedOptionTextId;
}
/// <summary>
/// 一个 TimelineMarker 的章节内回滚快照。
/// 它刻意不包含歌曲成绩、解锁 Key、Settings 或其它章节的数据,保证“重开剧情”只影响当前章节故事。
/// </summary>
[Serializable]
public class StoryRollbackSnapshot
{
public string markerId;
public float markerColumn;
public List<string> completedBlockIds = new();
public Dictionary<string, StoryChoiceRecord> selectedChoices = new();
public StoryVariablesSave storyVariables = new();
}
/// <summary>
/// 章节剧情变量存档。float / string / bool 三种底层类型由 <see cref="StoryVariables"/>
/// 统一读写Int 以 float 保存,并在读取时恢复为整数语义。
/// </summary>
[Serializable]
public class StoryVariablesSave
@@ -34,4 +79,102 @@ namespace Ichni.Story
public Dictionary<string, string> stringVariables = new();
public Dictionary<string, bool> boolVariables = new();
}
/// <summary>
/// 剧情存档对象的深拷贝工具。
/// ES3 保存前和 Timeline 回滚前都必须使用副本,避免后续字典写入意外修改已记录的快照。
/// </summary>
public static class StorySaveCloneUtility
{
/// <summary>
/// 深拷贝完整章节剧情存档。
/// <para>对话中途退出与 Timeline 回滚都需要保留“修改前”的独立副本;
/// 不能直接引用运行中的 <see cref="ChapterStorySave"/>,否则后续变量或选项写入会污染快照。</para>
/// </summary>
public static ChapterStorySave CloneChapter(ChapterStorySave source)
{
source ??= new ChapterStorySave();
return new ChapterStorySave
{
chapterIndex = source.chapterIndex,
completedBlockIds = source.completedBlockIds != null
? new List<string>(source.completedBlockIds)
: new List<string>(),
selectedChoices = CloneChoices(source.selectedChoices),
storyVariables = CloneVariables(source.storyVariables),
markerRollbackSnapshots = CloneRollbackSnapshots(source.markerRollbackSnapshots)
};
}
public static StoryVariablesSave CloneVariables(StoryVariablesSave source)
{
source ??= new StoryVariablesSave();
return new StoryVariablesSave
{
floatVariables = source.floatVariables != null
? new Dictionary<string, float>(source.floatVariables)
: new Dictionary<string, float>(),
stringVariables = source.stringVariables != null
? new Dictionary<string, string>(source.stringVariables)
: new Dictionary<string, string>(),
boolVariables = source.boolVariables != null
? new Dictionary<string, bool>(source.boolVariables)
: new Dictionary<string, bool>()
};
}
public static Dictionary<string, StoryChoiceRecord> CloneChoices(
Dictionary<string, StoryChoiceRecord> source)
{
Dictionary<string, StoryChoiceRecord> copy = new();
if (source == null)
return copy;
foreach ((string key, StoryChoiceRecord value) in source)
{
if (value == null)
continue;
copy[key] = new StoryChoiceRecord
{
sourceBlockId = value.sourceBlockId,
choiceKey = value.choiceKey,
selectedOptionTextId = value.selectedOptionTextId
};
}
return copy;
}
/// <summary>
/// 深拷贝 Timeline Marker 快照及其内部的完成列表、选项和变量。
/// </summary>
public static Dictionary<string, StoryRollbackSnapshot> CloneRollbackSnapshots(
Dictionary<string, StoryRollbackSnapshot> source)
{
Dictionary<string, StoryRollbackSnapshot> copy = new();
if (source == null)
return copy;
foreach ((string key, StoryRollbackSnapshot value) in source)
{
if (value == null)
continue;
copy[key] = new StoryRollbackSnapshot
{
markerId = value.markerId,
markerColumn = value.markerColumn,
completedBlockIds = value.completedBlockIds != null
? new List<string>(value.completedBlockIds)
: new List<string>(),
selectedChoices = CloneChoices(value.selectedChoices),
storyVariables = CloneVariables(value.storyVariables)
};
}
return copy;
}
}
}

View File

@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using Ichni.Menu;
using Ichni.Story;
@@ -7,50 +8,68 @@ namespace Ichni
{
/// <summary>
/// 剧情存档模块。
/// <para>故事树状态与对话选项 <b>按章节</b> 保存(单文件、单 ES3 key。</para>
/// <para>剧情变量 <b>全局</b> 保存,可跨章节引用,契合 Yarn 变量模型。</para>
/// <para>一个章节对应一个 ES3 文件,文件内的 <see cref="ChapterStorySave"/> 同时保存 Block 进度、
/// 选项结果、章节变量与 Timeline 回滚快照。</para>
/// <para>章节之间不共享剧情变量。此设计让“重开本章节”能够精确回滚本章剧情,且绝不触及歌曲成绩、
/// Offline 解锁 Key、设置或其它章节。</para>
/// </summary>
public class StorySaveModule
{
private const string ChapterStoryKey = "ChapterStory";
private const string VariablesKey = "StoryVariables";
// 每个剧情存档文件都保存自己的 Schema Version;不要把它与歌曲成绩存档的 Version 混用
// 当前项目尚未正式发布,剧情存档统一从 v1 开始;非 v1 的测试存档会被直接重置
// 正式发布后升级 Schema 时,必须用非破坏性迁移替代当前的重置策略。
// 每个剧情存档文件独立保存 Schema Version。项目尚未发布,当前不兼容的测试存档会重置为 v1
// 正式发布后若需要变更结构,必须新增显式迁移,而不能沿用删除文件的开发期策略
private const string StorySaveSchemaVersionKey = "StorySaveSchemaVersion";
private const int CurrentStorySaveSchemaVersion = 1;
// 已加载章节的内存缓存chapterIndex -> 章节存档)
private readonly Dictionary<string, ChapterStorySave> _chapterSaves =
new Dictionary<string, ChapterStorySave>();
// 旧版将剧情变量保存为全局单文件。它不再读取,但 ClearAllStoryline 会清理它,
// 以免开发机遗留文件造成“已清档但磁盘仍有旧剧情数据”的误解。
private static string LegacyVariablesPath =>
Application.persistentDataPath + "/StorySaves/StoryVariables.json";
// 全局剧情变量float / string / bool 三类型,匹配 Yarn 契约)
public StoryVariablesSave variables = new StoryVariablesSave();
// 已加载章节的内存缓存chapterIndex -> 章节存档)。
private readonly Dictionary<string, ChapterStorySave> _chapterSaves = new();
// 当前仅允许一段 Story TextBlock 对话处于存档事务中。事务期间,章节数据仍会更新内存,
// 但不会写入 ES3由 StoryDialogueController 在对话正常结束时提交,或在中途退出时恢复快照。
private string _transactionChapterIndex;
// 没有进入章节时(例如独立测试场景)使用的非持久化兜底变量。
// 正式剧情流程不会写入这里StoryTreeController 建立章节后会切换到对应 ChapterStorySave。
private readonly StoryVariablesSave _fallbackVariables = new();
private static string GetChapterSavePath(string chapterIndex) =>
Application.persistentDataPath + "/StorySaves/" + chapterIndex + ".json";
private static string VariablesPath =>
Application.persistentDataPath + "/StorySaves/StoryVariables.json";
/// <summary>
/// 补齐章节存档的可空集合,并以文件名对应的 chapterIndex 作为唯一归属
/// 当前用于保证新建 v1 存档的运行时集合非空;未来迁移逻辑应单独实现,不应依赖此方法隐式转换。
/// 补齐运行时会用到的字典。该方法只做空值修复,不承担跨 Schema 的迁移职责
/// </summary>
private static ChapterStorySave NormalizeChapterSave(ChapterStorySave save, string chapterIndex)
{
save ??= new ChapterStorySave();
save.chapterIndex = chapterIndex;
save.completedBlockIds ??= new List<string>();
save.selectedChoices ??= new Dictionary<string, int>();
save.selectedChoices ??= new Dictionary<string, StoryChoiceRecord>();
save.storyVariables = NormalizeVariables(save.storyVariables);
save.markerRollbackSnapshots ??= new Dictionary<string, StoryRollbackSnapshot>();
foreach (StoryRollbackSnapshot snapshot in save.markerRollbackSnapshots.Values)
{
if (snapshot == null)
continue;
snapshot.completedBlockIds ??= new List<string>();
snapshot.selectedChoices ??= new Dictionary<string, StoryChoiceRecord>();
snapshot.storyVariables = NormalizeVariables(snapshot.storyVariables);
}
return save;
}
/// <summary>
/// 补齐全局剧情变量字典,保证新建 v1 存档可被 Yarn 变量访问
/// 补齐章节变量的三种底层字典。数值统一使用 float 字典保存,整数读取时由 StoryVariables 转回整数
/// </summary>
private static StoryVariablesSave NormalizeVariables(StoryVariablesSave storyVariables)
internal static StoryVariablesSave NormalizeVariables(StoryVariablesSave storyVariables)
{
storyVariables ??= new StoryVariablesSave();
storyVariables.floatVariables ??= new Dictionary<string, float>();
@@ -59,10 +78,8 @@ namespace Ichni
return storyVariables;
}
// ── 故事树 + 选项(按章节)────────────────────────────────────────────────
/// <summary>
/// 判断某章节是否尚无可用的 v1 存档(首次进入或旧测试存档待重置)
/// 当前是否没有可用的 v1 章节存档。此方法仅用于调试/展示;实际读取仍应调用 <see cref="GetChapter"/>
/// </summary>
public bool IsNewChapter(string chapterIndex)
{
@@ -73,8 +90,8 @@ namespace Ichni
}
/// <summary>
/// 加载指定章节的 v1 存档容器;无存档或非 v1 的预发布测试存档会直接重置为空容器。
/// 加载结果会写入内存缓存
/// 从磁盘加载一个章节的剧情存档。开发期结构替换后若 ES3 无法反序列化旧 v1 文件,
/// 会安全重置该章节文件;这是用户已确认的发布前删档策略
/// </summary>
public ChapterStorySave LoadChapter(string chapterIndex)
{
@@ -86,39 +103,115 @@ namespace Ichni
if (hasExistingFile && loadedSchemaVersion != CurrentStorySaveSchemaVersion)
{
Debug.LogWarning($"Resetting pre-release chapter story save '{chapterIndex}' from schema v{loadedSchemaVersion} to v{CurrentStorySaveSchemaVersion}.");
Debug.LogWarning($"[StorySave] Resetting pre-release chapter '{chapterIndex}' from schema v{loadedSchemaVersion} to v{CurrentStorySaveSchemaVersion}.");
ES3.DeleteFile(path);
hasExistingFile = false;
}
ChapterStorySave save = hasExistingFile && ES3.KeyExists(ChapterStoryKey, path)
? ES3.Load<ChapterStorySave>(ChapterStoryKey, path)
: new ChapterStorySave();
ChapterStorySave save;
try
{
save = hasExistingFile && ES3.KeyExists(ChapterStoryKey, path)
? ES3.Load<ChapterStorySave>(ChapterStoryKey, path)
: new ChapterStorySave();
}
catch (Exception exception)
{
// 典型原因是开发期 selectedChoices 从 int 改为 StoryChoiceRecord 后的旧 ES3 数据。
// 因 Schema 仍为 v1 且项目未发行,按约定仅重置本章节,而不影响任何其它存档系统。
Debug.LogWarning($"[StorySave] Resetting unreadable pre-release chapter '{chapterIndex}'. {exception.Message}");
if (ES3.FileExists(path))
ES3.DeleteFile(path);
save = new ChapterStorySave();
}
save = NormalizeChapterSave(save, chapterIndex);
_chapterSaves[chapterIndex] = save;
return save;
}
/// <summary>
/// 保存整章存档blocks + connectors + choices单次 ES3 写入
/// 将一个完整章节容器写入 ES3。调用方应先更新 Block、变量、选项或快照再一次性调用本方法
/// </summary>
public void SaveChapter(ChapterStorySave save)
{
if (save == null || string.IsNullOrEmpty(save.chapterIndex))
{
Debug.LogWarning("Cannot save a chapter story record without a chapterIndex.");
Debug.LogWarning("[StorySave] Cannot save a chapter record without chapterIndex.");
return;
}
save = NormalizeChapterSave(save, save.chapterIndex);
_chapterSaves[save.chapterIndex] = save;
if (IsChapterTransactionActive(save.chapterIndex))
return;
SaveChapterImmediately(save);
}
/// <summary>
/// 开始指定章节的临时写入事务。
/// 对话播放期间的选项、变量、Marker 快照和 Block 完成状态仍会写入内存,
/// 但不会立即覆盖 ES3这样中途退出时不会把半段剧情永久记录到存档。
/// </summary>
public bool BeginChapterTransaction(string chapterIndex)
{
if (string.IsNullOrWhiteSpace(chapterIndex))
return false;
if (!string.IsNullOrEmpty(_transactionChapterIndex))
{
Debug.LogWarning($"[StorySave] Chapter transaction '{_transactionChapterIndex}' is already active.");
return false;
}
_transactionChapterIndex = chapterIndex;
return true;
}
/// <summary>
/// 提交指定章节的临时写入事务,并将该章节当前的完整内存状态一次性写入 ES3。
/// 仅应在 TextBlock 的 Yarn 对话正常结束后调用。
/// </summary>
public void CommitChapterTransaction(string chapterIndex)
{
if (!IsChapterTransactionActive(chapterIndex))
return;
_transactionChapterIndex = null;
SaveChapterImmediately(GetChapter(chapterIndex));
}
/// <summary>
/// 放弃当前章节事务,并用进入对话前的快照恢复内存和 ES3。
/// 此方法只作用于章节剧情数据,不会回滚歌曲成绩、解锁 Key、设置或其它章节的数据。
/// </summary>
public void RollbackChapterTransaction(string chapterIndex, ChapterStorySave snapshot)
{
if (!IsChapterTransactionActive(chapterIndex) || snapshot == null)
return;
ChapterStorySave restored = StorySaveCloneUtility.CloneChapter(snapshot);
restored = NormalizeChapterSave(restored, chapterIndex);
_transactionChapterIndex = null;
_chapterSaves[chapterIndex] = restored;
SaveChapterImmediately(restored);
}
/// <summary>当前指定章节是否正处于由 Story TextBlock 开启的临时存档事务中。</summary>
public bool IsChapterTransactionActive(string chapterIndex) =>
!string.IsNullOrEmpty(chapterIndex) && _transactionChapterIndex == chapterIndex;
private void SaveChapterImmediately(ChapterStorySave save)
{
string path = GetChapterSavePath(save.chapterIndex);
ES3.Save(ChapterStoryKey, save, path);
ES3.Save(StorySaveSchemaVersionKey, CurrentStorySaveSchemaVersion, path);
}
/// <summary>
/// 获取内存中的章节存档;若未加载则从磁盘加载。
/// 获取章节的内存存档;首次访问时自动从磁盘加载。
/// </summary>
public ChapterStorySave GetChapter(string chapterIndex)
{
@@ -127,63 +220,76 @@ namespace Ichni
: LoadChapter(chapterIndex);
}
// ── 对话选项(按章节)──────────────────────────────────────────────────────
/// <summary>
/// 记录某章节内一次对话选项的选择结果
/// 返回当前已打开章节的变量容器。章节尚未建立时返回非持久化兜底容器,供独立场景安全运行
/// </summary>
public void SetChoice(string chapterIndex, string choiceKey, int optionIndex)
public StoryVariablesSave GetActiveChapterVariables()
{
GetChapter(chapterIndex).selectedChoices[choiceKey] = optionIndex;
string chapterIndex = StoryManager.instance?.treeController?.ActiveChapterIndex;
return string.IsNullOrEmpty(chapterIndex)
? _fallbackVariables
: GetChapter(chapterIndex).storyVariables;
}
/// <summary>
/// 读取某章节内已记录的对话选项选择结果
/// 立即保存当前已打开章节。返回 false 代表当前没有章节上下文,因此没有产生磁盘写入
/// </summary>
public bool TryGetChoice(string chapterIndex, string choiceKey, out int optionIndex)
public bool SaveActiveChapter()
{
return GetChapter(chapterIndex).selectedChoices.TryGetValue(choiceKey, out optionIndex);
string chapterIndex = StoryManager.instance?.treeController?.ActiveChapterIndex;
if (string.IsNullOrEmpty(chapterIndex))
return false;
SaveChapter(GetChapter(chapterIndex));
return true;
}
// ── 剧情变量(全局)──────────────────────────────────────────────────────
// ── Yarn 选项(章节内) ─────────────────────────────────────────────────
/// <summary>
/// 从磁盘加载全局剧情变量(启动时调用)。
/// 保存一次 Yarn 选项。选项写入应在对应 Timeline 快照已创建后发生,
/// 由 StoryChoiceMemory 统一保证该顺序;若当前 TextBlock 事务仍在进行,只更新内存,
/// 直到对话正常结束后才统一落盘。
/// </summary>
public void LoadVariables()
public void SetChoice(string chapterIndex, StoryChoiceRecord record)
{
bool hasExistingFile = ES3.FileExists(VariablesPath);
int loadedSchemaVersion = hasExistingFile && ES3.KeyExists(StorySaveSchemaVersionKey, VariablesPath)
? ES3.Load<int>(StorySaveSchemaVersionKey, VariablesPath)
: 0;
if (hasExistingFile && loadedSchemaVersion != CurrentStorySaveSchemaVersion)
if (string.IsNullOrEmpty(chapterIndex) || record == null || string.IsNullOrEmpty(record.choiceKey))
{
Debug.LogWarning($"Resetting pre-release global story variables from schema v{loadedSchemaVersion} to v{CurrentStorySaveSchemaVersion}.");
ES3.DeleteFile(VariablesPath);
hasExistingFile = false;
Debug.LogWarning("[StorySave] Cannot save a story choice without chapterIndex and choiceKey.");
return;
}
variables = hasExistingFile && ES3.KeyExists(VariablesKey, VariablesPath)
? ES3.Load<StoryVariablesSave>(VariablesKey, VariablesPath)
: new StoryVariablesSave();
variables = NormalizeVariables(variables);
ChapterStorySave save = GetChapter(chapterIndex);
save.selectedChoices[record.choiceKey] = record;
SaveChapter(save);
}
/// <summary>
/// 将全局剧情变量写入磁盘。
/// </summary>
public void SaveVariables()
public bool TryGetChoice(string chapterIndex, string choiceKey, out StoryChoiceRecord record)
{
variables = NormalizeVariables(variables);
ES3.Save(VariablesKey, variables, VariablesPath);
ES3.Save(StorySaveSchemaVersionKey, CurrentStorySaveSchemaVersion, VariablesPath);
if (string.IsNullOrEmpty(chapterIndex) || string.IsNullOrEmpty(choiceKey))
{
record = null;
return false;
}
return GetChapter(chapterIndex).selectedChoices.TryGetValue(choiceKey, out record) && record != null;
}
// ── 清档 ────────────────────────────────────────────────────────────────
/// <summary>
/// 删除一个失效选项记录。例如 Yarn 改稿后已选 Text ID 不再存在,下一次进入应让玩家重新选择。
/// </summary>
public void RemoveChoice(string chapterIndex, string choiceKey)
{
if (string.IsNullOrEmpty(chapterIndex) || string.IsNullOrEmpty(choiceKey))
return;
ChapterStorySave save = GetChapter(chapterIndex);
if (save.selectedChoices.Remove(choiceKey))
SaveChapter(save);
}
/// <summary>
/// 清除所有章节的故事树存档与全局剧情变量
/// 清除全部章节剧情存档及遗留全局变量文件。不会清除歌曲成绩、设置或 Offline 解锁 Key
/// </summary>
public void ClearAllStoryline()
{
@@ -198,13 +304,14 @@ namespace Ichni
}
_chapterSaves.Clear();
_fallbackVariables.floatVariables.Clear();
_fallbackVariables.stringVariables.Clear();
_fallbackVariables.boolVariables.Clear();
if (ES3.FileExists(VariablesPath))
ES3.DeleteFile(VariablesPath);
if (ES3.FileExists(LegacyVariablesPath))
ES3.DeleteFile(LegacyVariablesPath);
variables = new StoryVariablesSave();
Debug.Log("Cleared all story saves (chapters + global variables).");
Debug.Log("[StorySave] Cleared all chapter story saves.");
}
}
}

View File

@@ -1,8 +1,5 @@
using System.Collections;
using System.Collections.Generic;
using Ichni.Menu.UI;
using Ichni.UI;
using UnityEngine;
using UnityEngine.UI;
namespace Ichni.Story.UI
@@ -14,11 +11,18 @@ namespace Ichni.Story.UI
protected override void Awake()
{
base.Awake();
backButton.onClick.AddListener(() =>
{
FadeOut();
MenuManager.instance.chapterSelectionUIPage.FadeIn();
});
backButton.onClick.AddListener(ReturnToChapterSelection);
}
private void OnDestroy()
{
backButton.onClick.RemoveListener(ReturnToChapterSelection);
}
private void ReturnToChapterSelection()
{
FadeOut();
MenuManager.instance.chapterSelectionUIPage.FadeIn();
}
}
}
}

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using Ichni.Menu.UI;
using TMPro;
using UnityEngine;
using UnityEngine.Localization;
@@ -44,14 +45,18 @@ namespace Ichni.Story.UI
private sealed class RuntimeMarker
{
public StoryTimelineMarkerDefinition definition;
public StoryBlockDefinition anchorDefinition;
public StoryBlockView anchorView;
public StoryTimelineMarkerView view;
}
private readonly List<RuntimeMarker> _markers = new List<RuntimeMarker>();
private StoryTreeController _treeController;
private ScrollRect _subscribedScrollRect;
// StoryPage 在进入对话时会被 FadeOut 并暂时 SetActive(false)。
// 因此需要将“应监听的 ScrollRect”与“当前是否已挂接监听”分开保存
// OnDisable 只解除事件OnEnable 再恢复同一个目标的事件,不能丢失该引用。
private ScrollRect _storyScrollRect;
private bool _isScrollRectSubscribed;
// 重复刻度的初始视觉状态。它们只在当前章节 Timeline 存在期间有效,
// Clear 时会恢复,避免重复进入章节后不断累积额外宽度。
@@ -64,12 +69,17 @@ namespace Ichni.Story.UI
private void OnEnable()
{
LocalizationSettings.SelectedLocaleChanged += OnSelectedLocaleChanged;
SubscribeToStoryScrollRect();
// StoryPage 从对话界面返回后Canvas 与 StoryTree 会在同一帧重新启用。
// 立即按锚定 Block 的当前实际位置刷新一次,避免首帧沿用淡出前的坐标。
RefreshMarkerPositions();
}
private void OnDisable()
{
LocalizationSettings.SelectedLocaleChanged -= OnSelectedLocaleChanged;
SubscribeScrollRect(null);
UnsubscribeFromStoryScrollRect();
}
/// <summary>
@@ -80,7 +90,7 @@ namespace Ichni.Story.UI
{
Clear();
_treeController = treeController;
SubscribeScrollRect(storyScrollRect);
SetStoryScrollRect(storyScrollRect);
if (markerContainer == null || markerPrefab == null || storyData == null)
{
@@ -121,6 +131,7 @@ namespace Ichni.Story.UI
_markers.Clear();
_treeController = null;
SetStoryScrollRect(null);
if (progressText != null)
{
@@ -142,6 +153,8 @@ namespace Ichni.Story.UI
StoryBlockState state = marker.anchorView.state;
bool visible = state == StoryBlockState.Current || state == StoryBlockState.Completed;
marker.view.SetVisible(visible);
marker.view.SetInteractable(visible && _treeController != null &&
_treeController.HasRollbackSnapshot(marker.definition.markerId));
if (state == StoryBlockState.Completed && marker.definition.contributesToMainProgress)
{
@@ -344,16 +357,47 @@ namespace Ichni.Story.UI
view.name = $"StoryTimelineMarker_{marker.markerId}";
view.SetLabel(GetLocalizedLabel(marker.labelKey));
_markers.Add(new RuntimeMarker
RuntimeMarker runtimeMarker = new RuntimeMarker
{
definition = marker,
anchorDefinition = anchorDefinition,
anchorView = anchorView,
view = view
});
};
view.BindClick(() => RequestRollback(runtimeMarker));
_markers.Add(runtimeMarker);
return true;
}
/// <summary>
/// 点击可回滚 Marker 后显示通用 SelectionBox。实际恢复由 StoryTreeController 处理,
/// 因而 Timeline 只承担 UI 入口,不保存任何独立剧情状态。
/// </summary>
private void RequestRollback(RuntimeMarker marker)
{
if (marker == null || _treeController == null ||
!_treeController.HasRollbackSnapshot(marker.definition.markerId))
{
return;
}
MessageUIPage messageUIPage = MessageUIPage.instance;
if (messageUIPage == null)
{
Debug.LogWarning("[StoryTimeline] MessageUIPage 未就绪,无法显示剧情回滚确认框。");
return;
}
string markerLabel = GetLocalizedLabel(marker.definition.labelKey);
messageUIPage.ShowSelectionBox(
"重开本章节",
$"将重置“{markerLabel}”所在列及其之后的剧情选择与进度。歌曲成绩、解锁内容、设置和其它章节不会受到影响。",
new List<SelectionBoxOption>
{
new SelectionBoxOption("重开此时间点", () => _treeController.TryRollbackToMarker(marker.definition.markerId)),
new SelectionBoxOption("取消", () => { })
});
}
/// <summary>
/// 使用 StoryTimeline 指定的 Unity Localization String Table 解析 Label Key。
/// String Table 或条目尚未配置时回退显示 Key确保开发阶段仍能从 UI 上定位缺失翻译。
@@ -385,23 +429,54 @@ namespace Ichni.Story.UI
}
}
private void SubscribeScrollRect(ScrollRect scrollRect)
/// <summary>
/// 设置当前章节 StoryTree 使用的 ScrollRect。
/// 该引用在 Timeline 暂时禁用期间仍会保留,以便 StoryPage 恢复显示时重新建立位置同步。
/// </summary>
private void SetStoryScrollRect(ScrollRect scrollRect)
{
if (_subscribedScrollRect == scrollRect)
if (_storyScrollRect == scrollRect)
{
return;
}
if (_subscribedScrollRect != null)
UnsubscribeFromStoryScrollRect();
_storyScrollRect = scrollRect;
SubscribeToStoryScrollRect();
}
/// <summary>
/// 当 Timeline 处于启用状态时挂接 StoryTree 的拖动回调。
/// 使用独立标志防止 OnEnable、Build 等生命周期入口重复注册同一个监听。
/// </summary>
private void SubscribeToStoryScrollRect()
{
if (_isScrollRectSubscribed || !isActiveAndEnabled || _storyScrollRect == null)
{
_subscribedScrollRect.onValueChanged.RemoveListener(OnStoryTreeScrolled);
return;
}
_subscribedScrollRect = scrollRect;
if (_subscribedScrollRect != null)
_storyScrollRect.onValueChanged.AddListener(OnStoryTreeScrolled);
_isScrollRectSubscribed = true;
}
/// <summary>
/// 仅移除运行时事件监听,不清空 <see cref="_storyScrollRect"/>。
/// 对话期间 StoryPage 被临时隐藏后OnEnable 可据此自动恢复 Marker 与 Block 的同步。
/// </summary>
private void UnsubscribeFromStoryScrollRect()
{
if (!_isScrollRectSubscribed)
{
_subscribedScrollRect.onValueChanged.AddListener(OnStoryTreeScrolled);
return;
}
if (_storyScrollRect != null)
{
_storyScrollRect.onValueChanged.RemoveListener(OnStoryTreeScrolled);
}
_isScrollRectSubscribed = false;
}
private void OnStoryTreeScrolled(Vector2 _)

View File

@@ -1,11 +1,13 @@
using TMPro;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;
namespace Ichni.Story.UI
{
/// <summary>
/// StoryTimeline 中单个时间标记的纯视图组件。
/// 它不读取存档、不判断剧情状态,也不接收点击;可见性、位置与本地化文本均由
/// 它不读取存档、不判断剧情状态;可见性、位置、可点击性与本地化文本均由
/// <see cref="StoryTimelineController"/> 统一驱动,从而避免 Timeline 再维护一份剧情进度。
/// </summary>
public class StoryTimelineMarkerView : MonoBehaviour
@@ -16,6 +18,9 @@ namespace Ichni.Story.UI
[Tooltip("显示时间或特殊叙事文本的 TMP 文本。")]
public TMP_Text labelText;
[Tooltip("Marker 根物体上的 Button。点击后的确认弹窗由 StoryTimelineController 创建。")]
public Button button;
/// <summary>
/// 设置 Marker 的本地化完成文本。文本解析由控制器完成View 只负责呈现。
/// </summary>
@@ -35,12 +40,30 @@ namespace Ichni.Story.UI
}
/// <summary>
/// 显示或隐藏 Marker。Timeline 不可交互,因此隐藏后不会留下射线或导航焦点。
/// 显示或隐藏 Marker。隐藏后不会留下射线或导航焦点。
/// 后续若增加 DOTween 入场动画,可在此处保留当前的最终状态并由控制器显式调用播放函数。
/// </summary>
public void SetVisible(bool visible)
{
gameObject.SetActive(visible);
}
/// <summary>
/// 绑定本 Marker 的唯一点击行为。每次构建章节时都会替换监听,
/// 防止旧章节的闭包继续引用新的 Timeline 数据。
/// </summary>
public void BindClick(UnityAction action)
{
button.onClick.RemoveAllListeners();
button.onClick.AddListener(action);
}
/// <summary>
/// 没有有效快照的 Current Marker 仅作时间展示,不应让玩家触发无效果的回滚。
/// </summary>
public void SetInteractable(bool interactable)
{
button.interactable = interactable;
}
}
}

View File

@@ -10,7 +10,7 @@ namespace Ichni.Story.UI
/// 剧情树中的歌曲 Block 视图。
/// 它只保存 <see cref="StoryBlockDefinition.songName"/> 这一个稳定歌曲标识;歌曲名称、作曲者与插画
/// 均从当前章节的 <see cref="SongItemData"/> 读取,避免 StoryData 与选曲资料出现两份可漂移的数据。
/// 点击后切换到选曲并自动选中对应歌曲的流程将在后续阶段接入
/// 点击后会通过 <see cref="StoryManager"/> 打开选曲并自动选中对应歌曲。
/// </summary>
public class SongBlockView : StoryBlockView
{
@@ -129,8 +129,9 @@ namespace Ichni.Story.UI
return;
}
// 占位:后续接入曲目选择流程
Debug.Log($"[SongBlockView] 点击音乐块 '{blockId}' (song='{definition.songName}')。占位实现,曲目选择将在后续阶段接入。");
// StoryManager 统一保存剧情树位置、下发目标歌曲并负责返回恢复SongBlock 只负责
// 已解析且已授权歌曲的入口请求,避免在各个 Block 中复制跨页面路由逻辑。
StoryManager.instance?.TryOpenSongBlock(this, _chapter, _song);
}
}
}

View File

@@ -20,6 +20,10 @@ namespace Ichni.Story.UI
public RectTransform outPort;
public Button button;
// 运行时实际承载点击事件的 Button。TextBlock 会在切换状态视觉时替换其背景,
// 因此不能只依赖 Prefab 初始序列化的 button 引用。
private Button _boundInteractionButton;
// 关联的定义,供子类读取类型专属字段
protected StoryBlockDefinition definition;
@@ -48,8 +52,7 @@ namespace Ichni.Story.UI
// 都可保留各自的宽高StoryTreeController 会据此计算 Content 边界和连接线端口。
blockRect.anchoredPosition = position;
if (button != null)
button.onClick.AddListener(HandleClick);
BindInteractionButton(button);
ApplyState(state);
}
@@ -62,8 +65,43 @@ namespace Ichni.Story.UI
{
state = newState;
if (button != null)
button.interactable = state != StoryBlockState.Locked && state != StoryBlockState.Forbidden;
RefreshButtonInteractable();
}
/// <summary>
/// 绑定当前 Block 的交互 Button。
/// TextBlock 更换状态背景时会销毁旧背景并生成新的 Button必须先解绑旧对象、
/// 再绑定新对象,才能避免点击监听丢失或因重复绑定而触发多次。
/// </summary>
protected void BindInteractionButton(Button newButton)
{
if (_boundInteractionButton == newButton)
{
RefreshButtonInteractable();
return;
}
if (_boundInteractionButton != null)
_boundInteractionButton.onClick.RemoveListener(HandleClick);
_boundInteractionButton = newButton;
button = newButton;
if (_boundInteractionButton != null)
_boundInteractionButton.onClick.AddListener(HandleClick);
RefreshButtonInteractable();
}
/// <summary>
/// 依据剧情状态刷新当前交互 Button。Completed 仍允许回顾,只有 Locked 与
/// Forbidden 状态禁止点击。
/// </summary>
protected void RefreshButtonInteractable()
{
if (_boundInteractionButton != null)
_boundInteractionButton.interactable =
state != StoryBlockState.Locked && state != StoryBlockState.Forbidden;
}
private void HandleClick()

View File

@@ -1,6 +1,9 @@
using System;
using System.Collections.Generic;
using Ichni.Menu;
using Ichni.Story.UI;
using Sirenix.OdinInspector;
using UniRx;
using UnityEngine;
namespace Ichni.Story
@@ -21,6 +24,22 @@ namespace Ichni.Story
[Tooltip("全章节共用的 Helper 对话数据。它独立于各章节 StoryData避免重复配置相同的 Helper。")]
public StoryHelperData helperData;
/// <summary>
/// 当前运行时实际使用的 Helper 数据。第一版始终由 <see cref="helperData"/> 初始化;
/// 未来的 Helper 更换界面应通过 <see cref="SetActiveHelper"/> 切换,而不要让各个 UI
/// 直接保存自己的 Helper 引用。
/// </summary>
private StoryHelperData _activeHelperData;
/// <summary>当前运行时生效的 Helper 数据。未被运行时覆盖时回退到 Inspector 默认数据。</summary>
public StoryHelperData ActiveHelperData => _activeHelperData != null ? _activeHelperData : helperData;
/// <summary>
/// 当前 Helper 被切换时触发。静态 Image、Spine 或 Live2D 的表现层均可订阅该事件刷新视觉,
/// 而无需了解具体的更换 UI 实现。
/// </summary>
public event Action<StoryHelperData> ActiveHelperChanged;
[Header("Chapter Data")]
[Tooltip("章节索引 (chapterIndex) -> 该章节的 StoryData 资产")]
public Dictionary<string, StoryData> storyDatas = new Dictionary<string, StoryData>();
@@ -28,6 +47,33 @@ namespace Ichni.Story
private void Awake()
{
instance = this;
_activeHelperData = helperData;
}
private void OnDestroy()
{
if (instance == this)
instance = null;
}
/// <summary>
/// 设置本次运行期间的当前 Helper。
/// <para>本方法暂不写入存档:当前没有 Helper 更换功能;后续选择界面完成后,
/// 只需在其外层决定何时持久化 helperId再调用本方法即可。</para>
/// </summary>
public void SetActiveHelper(StoryHelperData data)
{
if (data == null)
{
Debug.LogWarning("[StoryManager] 尝试切换到空的 StoryHelperData已忽略。");
return;
}
if (_activeHelperData == data)
return;
_activeHelperData = data;
ActiveHelperChanged?.Invoke(_activeHelperData);
}
/// <summary>
@@ -47,7 +93,106 @@ namespace Ichni.Story
}
/// <summary>
/// 清除全部剧情存档(所有章节故事树 + 全局变量 + 由剧情授予的内容解锁 Key
/// 从剧情 SongBlock 打开选曲页,并把“来源 Block、目标歌曲、剧情树视口位置”写入
/// 运行时上下文。此处不完成 Block只有玩家确认进入目标歌曲时才会写入剧情进度。
/// </summary>
public bool TryOpenSongBlock(SongBlockView sourceBlock, ChapterSelectionUnit chapter, SongItemData song)
{
if (sourceBlock == null || chapter == null || song == null ||
treeController == null || treeController.content == null ||
storyUIPage == null || MenuManager.instance == null ||
MenuManager.instance.songSelectionUIPage == null ||
MenuManager.instance.songSelectionUIPage.songListController == null ||
InformationTransistor.instance == null)
{
Debug.LogWarning("[StoryManager] SongBlock 缺少打开选曲所需引用,已取消本次跳转。");
return false;
}
InformationTransistor.instance.storySongEntryContext.Begin(
chapter.chapterIndex,
sourceBlock.blockId,
song.songName,
treeController.content.anchoredPosition);
// 强制歌曲只影响本次打开选曲页的初始焦点;原有章节歌曲/难度缓存仍然保留。
ChapterSelectionManager.instance.currentChapter = chapter;
MenuManager.instance.songSelectionUIPage.songListController.RequestInitialSongSelection(song);
storyUIPage.FadeOut();
MenuManager.instance.songSelectionUIPage.FadeIn();
return true;
}
/// <summary>
/// 选曲页确认启动游戏时调用。只有章节和稳定歌曲 ID 都命中剧情入口目标时,
/// 才将对应 SongBlock 标记为完成,并要求 GameScene 返回剧情页。
/// </summary>
public void HandleSongSelectionConfirmed(ChapterSelectionUnit chapter, SongItemData song)
{
StorySongEntryContext context = InformationTransistor.instance?.storySongEntryContext;
if (context == null || !context.IsTargetSong(chapter, song))
return;
// isTried 已在 PrepareGameLaunch 中落盘;此处不再直接写 StorySave避免 SongBlock
// 同时拥有一次性选曲上下文与歌曲存档两套完成来源。
context.returnToStoryAfterGame = true;
}
/// <summary>
/// 处理剧情入口进入选曲后的“返回”操作。返回时不重建故事树,只恢复离开前的
/// Content 位置与来源 Block 焦点;普通选曲返回则由调用方继续回到章节选择页。
/// </summary>
public bool TryReturnFromSongSelection()
{
StorySongEntryContext context = InformationTransistor.instance?.storySongEntryContext;
if (context == null || !context.isActive)
return false;
// SongSaves.isTried 在进入 GameScene 前已落盘。返回时立刻同步,确保后续 Block
// 的解锁条件和 Timeline 状态基于持久化的“已尝试歌曲”记录重新计算。
treeController?.SynchronizeSongBlockAttempts();
RestoreStoryTreeViewport(context.sourceBlockId, context.storyTreeContentPosition);
context.Clear();
return true;
}
/// <summary>
/// GameScene 返回 MenuScene 后调用。此时 MenuManager 已依据 InformationTransistor
/// 重建来源章节;下一帧恢复 Content 位置,确保 Canvas 布局和连接线已稳定。
/// </summary>
public void RestoreStoryAfterGameReturn()
{
StorySongEntryContext context = InformationTransistor.instance?.storySongEntryContext;
if (context == null || !context.isActive || !context.returnToStoryAfterGame)
return;
// SongSaves.isTried 在进入 GameScene 前已落盘。返回时立刻同步,确保后续 Block
// 的解锁条件和 Timeline 状态基于持久化的“已尝试歌曲”记录重新计算。
treeController?.SynchronizeSongBlockAttempts();
RestoreStoryTreeViewport(context.sourceBlockId, context.storyTreeContentPosition);
context.Clear();
}
/// <summary>
/// 在下一帧还原剧情树浏览位置,并使来源 Block 重新成为当前 Block。
/// 参数在调用前即被复制,因此即使运行时上下文随后清理,也不会丢失恢复数据。
/// </summary>
private void RestoreStoryTreeViewport(string sourceBlockId, Vector2 contentPosition)
{
Observable.NextFrame().Subscribe(_ =>
{
if (treeController == null || treeController.content == null)
return;
treeController.content.anchoredPosition = contentPosition;
StoryBlockView sourceBlock = treeController.GetBlockView(sourceBlockId);
if (sourceBlock != null)
treeController.currentBlock = sourceBlock;
}).AddTo(this);
}
/// <summary>
/// 清除全部剧情存档(所有章节故事树、章节变量与由剧情授予的内容解锁 Key
/// </summary>
[Button]
public void ClearAllStorySave()

View File

@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Linq;
using Ichni.Menu;
using Ichni.Story.UI;
using Sirenix.OdinInspector;
using UniRx;
@@ -13,7 +14,7 @@ namespace Ichni.Story
/// 故事树控制器。从 <see cref="StoryData"/> 一次性全量构建所有 block 视图与连接线,
/// 布局由每个 block 的 (gridColumn, gridRow) 静态决定;每个 block 的
/// <see cref="StoryBlockState"/> 由"已完成集合 + 解锁条件"实时推导。
/// 存档保存已完成 block 的 id 集合
/// 存档以章节为单位保存已完成 Block、Yarn 选项、永久剧情变量与 Timeline 回滚快照
/// </summary>
public class StoryTreeController : MonoBehaviour
{
@@ -69,10 +70,10 @@ namespace Ichni.Story
/// <summary>
/// 为剧情页左侧常驻 UI当前为 Helper预留的额外水平空间。
/// 最左 Block 的真实左边缘会被放置在 <see cref="marginLeft"/> 加本值的位置;
/// 默认 400等价于在原有布局基础上将整棵剧情树向右平移 400 单位。
/// 默认 1600等价于在原有布局基础上将整棵剧情树向右平移 1600 单位。
/// </summary>
[Tooltip("为左侧 Helper 等常驻 UI 预留的额外空间。默认 1500会让所有 Block 整体向右移动 1500。")]
public float helperReservedLeftSpace = 1500f;
[Tooltip("为左侧 Helper 等常驻 UI 预留的额外空间。默认 1600会让所有 Block 整体向右移动 1600。")]
public float helperReservedLeftSpace = 1600f;
public float marginRight = 50f;
public float marginTop = 50f;
@@ -146,6 +147,14 @@ namespace Ichni.Story
return;
}
if (_storyData.chapterIndex != chapterIndex)
{
Debug.LogWarning(
$"[StoryTreeController] StoryManager 使用 Key '{chapterIndex}' 打开 StoryData '{_storyData.name}'" +
$"但该资产的 Chapter Index 为 '{_storyData.chapterIndex}'。请让 StoryData、StoryManager 与 ChapterSelectionUnit 使用同一个稳定章节 ID。",
_storyData);
}
if (GameSaveManager.instance == null || GameSaveManager.instance.StorySaveModule == null)
{
Debug.LogWarning("[StoryTreeController] 存档系统尚未就绪GameSaveManager.StorySaveModule 为空),跳过构建。");
@@ -160,14 +169,26 @@ namespace Ichni.Story
foreach (string id in save.completedBlockIds)
_completed.Add(id);
// SongBlock 的完成来源是 SongSaves.isTried而非一次性的选曲上下文。先同步再生成视图
// 使首次进入剧情页、从 GameScene 返回以及重启游戏后的状态保持一致。
SynchronizeSongBlockAttempts();
// 全量生成所有 block位置按网格换算状态按已完成集合 + 解锁条件推导)
foreach (StoryBlockDefinition def in _storyData.blocks)
GenerateBlock(def);
{
if (def != null)
GenerateBlock(def);
}
// 全量生成所有连接线
foreach (StoryBlockDefinition def in _storyData.blocks)
{
if (def?.nextBlockIds == null)
continue;
foreach (string nextId in def.nextBlockIds)
GenerateConnector(def.blockId, nextId);
}
SetUpBackground();
@@ -206,10 +227,12 @@ namespace Ichni.Story
return null;
}
StoryBlockView view = Instantiate(prefab, content).GetComponent<StoryBlockView>();
GameObject blockObject = Instantiate(prefab, content);
StoryBlockView view = blockObject.GetComponent<StoryBlockView>();
if (view == null)
{
Debug.LogError($"[StoryTreeController] 预制体 '{prefab.name}' 缺少 StoryBlockView 组件。");
Destroy(blockObject);
return null;
}
@@ -253,9 +276,9 @@ namespace Ichni.Story
// ── State / Unlocking ─────────────────────────────────────────────────────
/// <summary>
/// 依据“禁用条件 + 已完成集合 + 解锁条件”推导单个 Block 的状态。
/// 依据“禁用条件 + 已完成集合 + 自动连接条件 + 手写解锁条件”推导单个 Block 的状态。
/// Forbidden Condition 满足时优先显示 Forbidden否则依次为 Completed、Current、Locked。
/// Unlock Condition 未配置时视为无条件满足(章节起始即为 Current
/// 自动连接条件与手写 Unlock Condition 按 AND 合并;没有前置连接的章节起始 Block 不受自动条件限制
/// </summary>
private StoryBlockState DeriveState(StoryBlockDefinition def)
{
@@ -268,9 +291,10 @@ namespace Ichni.Story
if (_completed.Contains(def.blockId))
return StoryBlockState.Completed;
if (def.unlockCondition == null ||
!def.unlockCondition.IsConfigured ||
def.unlockCondition.IsSatisfied(GetVariable, IsBlockCompleted))
bool explicitUnlockSatisfied = def.unlockCondition == null ||
!def.unlockCondition.IsConfigured ||
def.unlockCondition.IsSatisfied(GetVariable, IsBlockCompleted);
if (AreConnectedBlockRequirementsSatisfied(def) && explicitUnlockSatisfied)
{
return StoryBlockState.Current;
}
@@ -295,10 +319,74 @@ namespace Ichni.Story
}
/// <summary>
/// 标记某 block 完成,推导重算并保存。对话系统(阶段 2在对话结束时调用
/// 从当前章节存档重新同步已完成 Block 集合,并刷新既有 Tree 与 Timeline 状态
/// <para>供中途退出 TextBlock 对话时使用:不重建 UI也不改变玩家当前拖动到的剧情树位置。</para>
/// </summary>
public void ReloadActiveChapterProgress()
{
if (string.IsNullOrEmpty(_chapterIndex) || GameSaveManager.instance?.StorySaveModule == null)
return;
ChapterStorySave save = GameSaveManager.instance.StorySaveModule.GetChapter(_chapterIndex);
_completed.Clear();
foreach (string completedId in save.completedBlockIds)
_completed.Add(completedId);
RefreshAllStates();
}
/// <summary>
/// 将当前章节所有已尝试歌曲同步为已完成的 Story SongBlock。
/// <para>SongSaves 是“玩家已确认进入该歌曲”的唯一持久化事实来源StorySave 仅缓存由该事实
/// 推导出的剧情 Block 完成集合从而保证原有的解锁条件、Timeline Marker 与章节回滚机制仍可复用。</para>
/// <para>该同步只会补充完成项,绝不从 StorySave 中移除 SongBlock。这样 Timeline 回滚不会撤销已经尝试过的歌曲,
/// 符合“重开章节不回滚游戏记录”的存档边界。</para>
/// </summary>
public void SynchronizeSongBlockAttempts()
{
if (_storyData == null || string.IsNullOrWhiteSpace(_chapterIndex) ||
GameSaveManager.instance?.SongSaveModule == null ||
GameSaveManager.instance?.StorySaveModule == null)
{
return;
}
ChapterSelectionUnit chapter = ChapterSelectionManager.instance?.chapters
.FirstOrDefault(candidate => candidate != null && candidate.chapterIndex == _chapterIndex);
if (chapter == null)
return;
bool changed = false;
foreach (StoryBlockDefinition definition in _storyData.blocks)
{
if (definition == null || definition.blockType != StoryBlockType.Song ||
string.IsNullOrWhiteSpace(definition.songName))
{
continue;
}
var songSave = GameSaveManager.instance.SongSaveModule.GetSongStatusSave(definition.songName);
if (songSave != null && songSave.isTried)
changed |= _completed.Add(definition.blockId);
}
if (!changed)
return;
ChapterStorySave save = GameSaveManager.instance.StorySaveModule.GetChapter(_chapterIndex);
save.completedBlockIds = _completed.ToList();
GameSaveManager.instance.StorySaveModule.SaveChapter(save);
}
/// <summary>
/// 标记某 Block 完成,推导重算并保存。
/// 若该 Block 位于某个 TimelineMarker 所在列,会先保存“该列之前”的快照,
/// 因而通过 Marker 重开时能同时回滚完成状态、选项和章节变量。
/// </summary>
public void OnBlockCompleted(string blockId)
{
EnsureRollbackSnapshotsForBlock(blockId);
if (!_completed.Add(blockId))
{
// 剧情变量可能在 block 已完成后被外部系统修正;即使完成集合没有变化,
@@ -311,10 +399,57 @@ namespace Ichni.Story
SaveChapter();
}
private bool IsBlockCompleted(string blockId) => _completed.Contains(blockId);
/// <summary>
/// 查询当前已构建章节中指定 Block 是否完成。
/// 除故事树自身外Helper 对话等需要复用 <see cref="StoryCondition"/> 的系统也应通过此只读入口
/// 提供 BlockCompletedCondition 的求值结果,避免复制第二份完成状态。
/// </summary>
public bool IsBlockCompleted(string blockId) =>
!string.IsNullOrEmpty(blockId) && _completed.Contains(blockId);
// 阶段 2 将改由 StoryVariableStorage 提供;此处统一经 StoryVariables 读取,
// 以便后续切换到按章节保存的变量容器时不再让故事树直接依赖存档结构。
/// <summary>
/// 从 StoryData 的所有 <see cref="StoryBlockDefinition.nextBlockIds"/> 反向推导本 Block 的直接前置节点,
/// 不额外保存第二份图数据,避免连接线和解锁规则在编辑后脱节。
/// </summary>
private bool AreConnectedBlockRequirementsSatisfied(StoryBlockDefinition target)
{
if (target.connectedBlockUnlockRule == ConnectedBlockUnlockRule.IgnoreConnections ||
_storyData?.blocks == null)
{
return true;
}
bool hasPredecessor = false;
bool anyPredecessorCompleted = false;
foreach (StoryBlockDefinition candidate in _storyData.blocks)
{
if (candidate == null || candidate.nextBlockIds == null ||
!candidate.nextBlockIds.Contains(target.blockId))
{
continue;
}
hasPredecessor = true;
bool isCompleted = IsBlockCompleted(candidate.blockId);
anyPredecessorCompleted |= isCompleted;
if (target.connectedBlockUnlockRule == ConnectedBlockUnlockRule.RequireAllDirectPredecessors &&
!isCompleted)
{
return false;
}
}
// 没有任何前置连接的 Block 是章节起始节点,自动条件不应把它锁住。
if (!hasPredecessor)
return true;
return target.connectedBlockUnlockRule == ConnectedBlockUnlockRule.RequireAllDirectPredecessors ||
anyPredecessorCompleted;
}
// StoryVariables 已统一桥接到当前章节变量容器;故事树只关心数值结果,
// 不直接依赖 ES3 的序列化结构。
private int GetVariable(string variableName)
{
return StoryVariables.GetInt(variableName);
@@ -337,6 +472,121 @@ namespace Ichni.Story
GameSaveManager.instance.StorySaveModule.SaveChapter(save);
}
// ── Timeline 回滚 ───────────────────────────────────────────────────────
/// <summary>
/// 在指定 Block 所在列第一次被执行前,为该列上的所有 TimelineMarker 建立快照。
/// 同列存在多个 Block 时,最先进入的 Block 创建一次快照;后续 Block 不会覆盖它,
/// 因此无论分支以何种顺序执行,回滚边界都稳定地位于整列之前。
/// </summary>
public bool EnsureRollbackSnapshotsForBlock(string blockId)
{
if (string.IsNullOrEmpty(_chapterIndex) || _storyData == null ||
GameSaveManager.instance?.StorySaveModule == null)
{
return false;
}
StoryBlockDefinition sourceBlock = _storyData.GetBlock(blockId);
if (sourceBlock == null || _storyData.timelineMarkers == null)
return false;
ChapterStorySave save = GameSaveManager.instance.StorySaveModule.GetChapter(_chapterIndex);
bool changed = false;
foreach (StoryTimelineMarkerDefinition marker in _storyData.timelineMarkers)
{
if (marker == null || string.IsNullOrWhiteSpace(marker.markerId) ||
save.markerRollbackSnapshots.ContainsKey(marker.markerId))
{
continue;
}
StoryBlockDefinition anchorBlock = _storyData.GetBlock(marker.anchorBlockId);
if (anchorBlock == null || !Mathf.Approximately(anchorBlock.gridColumn, sourceBlock.gridColumn))
continue;
save.markerRollbackSnapshots[marker.markerId] = new StoryRollbackSnapshot
{
markerId = marker.markerId,
markerColumn = anchorBlock.gridColumn,
completedBlockIds = _completed.ToList(),
selectedChoices = StorySaveCloneUtility.CloneChoices(save.selectedChoices),
storyVariables = StorySaveCloneUtility.CloneVariables(save.storyVariables)
};
changed = true;
}
if (changed)
GameSaveManager.instance.StorySaveModule.SaveChapter(save);
return changed;
}
/// <summary>
/// 将当前章节恢复到指定 TimelineMarker 所在列之前的状态。
/// 目标列及其右侧列的快照同时失效并被删除,因为它们依赖于被重置的剧情历史。
/// </summary>
public bool TryRollbackToMarker(string markerId)
{
if (string.IsNullOrEmpty(_chapterIndex) || _storyData == null ||
GameSaveManager.instance?.StorySaveModule == null)
{
return false;
}
StoryTimelineMarkerDefinition marker = _storyData.timelineMarkers?
.FirstOrDefault(item => item != null && item.markerId == markerId);
StoryBlockDefinition anchorBlock = marker == null ? null : _storyData.GetBlock(marker.anchorBlockId);
if (marker == null || anchorBlock == null)
{
Debug.LogWarning($"[StoryTreeController] 未找到可回滚的 TimelineMarker '{markerId}'。");
return false;
}
ChapterStorySave save = GameSaveManager.instance.StorySaveModule.GetChapter(_chapterIndex);
if (!save.markerRollbackSnapshots.TryGetValue(markerId, out StoryRollbackSnapshot snapshot) || snapshot == null)
{
// 旧开发存档没有新快照时,宁可明确拒绝,也不以“当前已完成状态”伪造快照而造成无效回滚。
Debug.LogWarning($"[StoryTreeController] Marker '{markerId}' 缺少回滚快照。请先清除该章节剧情存档并重新游玩到此时间点。");
return false;
}
_completed.Clear();
foreach (string completedId in snapshot.completedBlockIds)
_completed.Add(completedId);
save.completedBlockIds = snapshot.completedBlockIds.ToList();
save.selectedChoices = StorySaveCloneUtility.CloneChoices(snapshot.selectedChoices);
save.storyVariables = StorySaveCloneUtility.CloneVariables(snapshot.storyVariables);
// 回滚点本身和后续列会被重新游玩,旧快照不再代表新的故事历史。
foreach (StoryTimelineMarkerDefinition candidate in _storyData.timelineMarkers)
{
if (candidate == null)
continue;
StoryBlockDefinition candidateAnchor = _storyData.GetBlock(candidate.anchorBlockId);
if (candidateAnchor != null && candidateAnchor.gridColumn >= anchorBlock.gridColumn)
save.markerRollbackSnapshots.Remove(candidate.markerId);
}
GameSaveManager.instance.StorySaveModule.SaveChapter(save);
BuildChapter(_chapterIndex);
return true;
}
/// <summary>
/// 供 Timeline 决定 Marker 是否允许点击。只有已创建“该列之前”快照的 Marker 才能真正回滚;
/// Current 状态但尚未执行到该列的 Marker 仍会显示时间文本,但不会提供无意义的重开操作。
/// </summary>
public bool HasRollbackSnapshot(string markerId)
{
return !string.IsNullOrEmpty(_chapterIndex) &&
GameSaveManager.instance?.StorySaveModule != null &&
GameSaveManager.instance.StorySaveModule.GetChapter(_chapterIndex)
.markerRollbackSnapshots.ContainsKey(markerId);
}
// ── Layout ────────────────────────────────────────────────────────────────
/// <summary>
@@ -436,6 +686,11 @@ namespace Ichni.Story
public StoryBlockView GetBlockView(string blockId) =>
blocks.FirstOrDefault(b => b.blockId == blockId);
/// <summary>
/// 为 Timeline、Yarn 和调试工具提供当前章节静态 Block 定义的查询入口。
/// </summary>
public StoryBlockDefinition GetBlockDefinition(string blockId) => _storyData?.GetBlock(blockId);
private void ClearTree()
{
storyTimeline?.Clear();
@@ -449,6 +704,7 @@ namespace Ichni.Story
connectors.Clear();
_layoutHorizontalOffset = 0f;
currentBlock = null;
if (content != null)
content.sizeDelta = Vector2.zero;
@@ -472,7 +728,7 @@ namespace Ichni.Story
}
/// <summary>
/// 编辑器测试用:模拟完成当前选中的 block验证解锁重算与存档),阶段 2 前用于替代对话
/// 编辑器测试用:模拟完成当前选中的 Block,用于快速验证解锁重算与存档。
/// </summary>
[Button]
public void DebugCompleteCurrentBlock()
@@ -498,11 +754,15 @@ namespace Ichni.Story
return;
}
// TutorialBlock 的后续解锁由全局剧情变量驱动;只清空 completedBlockIds
// 会造成“节点未完成但后续仍解锁”的错误测试结果,因此同时清除本章节声明的教程变量
StoryProgress.ClearTutorialProgressForChapter(_storyData);
// 章节变量、Yarn 选项和 Marker 快照都属于当前章节;调试重置必须一并清空,
// 才不会出现“Block 未完成但分支/教程变量仍然已设置”的伪状态
_completed.Clear();
SaveChapter();
ChapterStorySave save = GameSaveManager.instance.StorySaveModule.GetChapter(_chapterIndex);
save.completedBlockIds.Clear();
save.selectedChoices.Clear();
save.storyVariables = new StoryVariablesSave();
save.markerRollbackSnapshots.Clear();
GameSaveManager.instance.StorySaveModule.SaveChapter(save);
BuildChapter(_chapterIndex);
}
}

View File

@@ -2,11 +2,12 @@ using System.Collections.Generic;
using Ichni.Story.Dialogue;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Ichni.Story.UI
{
/// <summary>
/// 文本块视图。点击后(阶段 2通过 Yarn Spinner 进入对应节点的对话。
/// 文本块视图。点击后通过 Yarn Spinner 进入对应节点的对话。
/// </summary>
public class TextBlockView : StoryBlockView
{
@@ -26,14 +27,14 @@ namespace Ichni.Story.UI
public override void Initialize(StoryBlockDefinition def, Vector2 position, StoryBlockState blockState)
{
// 注意base.Initialize 末尾会调用 ApplyState(state),届时已生成对应背景
base.Initialize(def, position, blockState);
YarnNodeName = def.yarnNodeName;
base.Initialize(def, position, blockState);
if (storyIdText != null)
storyIdText.text = def.blockId;
// 阶段 5 将接入 Unity Localization;此处占位直接显示 titleKey
// 当前仍直接显示 titleKey正式内容接入时需要由 Unity Localization 解析玩家可见标题。
if (titleText != null)
titleText.text = def.titleKey;
}
@@ -55,6 +56,8 @@ namespace Ichni.Story.UI
{
if (_currentVisual != null)
{
// 旧背景中的 Button 即将被销毁,先主动解绑,防止基类继续持有失效引用。
BindInteractionButton(null);
Destroy(_currentVisual);
_currentVisual = null;
}
@@ -81,7 +84,12 @@ namespace Ichni.Story.UI
_currentVisual = Instantiate(prefab, blockRect);
_currentVisual.transform.SetAsFirstSibling();
// 状态视觉可自由调整层级;不再依赖“第一个子物体就是 Button”的脆弱约定。
// 通过基类重新绑定,确保每一次状态背景替换后 TextBlock 仍能响应点击。
Button interactionButton = _currentVisual.GetComponentInChildren<Button>(true);
if (interactionButton == null)
Debug.LogWarning($"[TextBlockView] block '{blockId}' 的状态视觉 '{prefab.name}' 缺少 Button无法点击。");
BindInteractionButton(interactionButton);
// 背景铺满整个 block
if (_currentVisual.transform is RectTransform visualRect)
{

View File

@@ -24,9 +24,19 @@ namespace Ichni.Story.UI
[Header("推进按钮")]
public UnityEngine.UI.Button advanceButton;
[Tooltip("点击后自动快进,直到遇到选项")]
[Tooltip("点击后自动快进;已记忆选项会自动跨过,遇到需要玩家选择的新选项时停止。")]
public UnityEngine.UI.Button fastForwardButton;
[Header("对话辅助功能")]
[Tooltip("中途退出当前 TextBlock本次尚未完成的选项、变量和进度会恢复到进入前状态。")]
[SerializeField] private UnityEngine.UI.Button backButton;
[Tooltip("打开当前一次 TextBlock 的临时对话记录。")]
[SerializeField] private UnityEngine.UI.Button showHistoryButton;
[Tooltip("覆盖在对话上方的记录面板;它不是独立 UIPage。")]
[SerializeField] private DialogueHistoryPage dialogueHistoryPage;
[Header("选项区")]
public GameObject choiceFrame;
public ChoiceButton[] choiceButtons = new ChoiceButton[4];
@@ -58,6 +68,27 @@ namespace Ichni.Story.UI
}
}
private void Start()
{
if (backButton != null)
backButton.onClick.AddListener(OnBackButtonClicked);
if (showHistoryButton != null)
showHistoryButton.onClick.AddListener(OnShowHistoryButtonClicked);
}
private void OnDestroy()
{
if (backButton != null)
backButton.onClick.RemoveListener(OnBackButtonClicked);
if (showHistoryButton != null)
showHistoryButton.onClick.RemoveListener(OnShowHistoryButtonClicked);
if (instance == this)
instance = null;
}
public void PlayFadeIn(UnityAction onComplete = null)
{
FadeIn(0.2f, false, onComplete);
@@ -67,5 +98,26 @@ namespace Ichni.Story.UI
{
FadeOut(0.2f, false, onComplete);
}
private void OnBackButtonClicked()
{
// History 覆盖层本身会拦截输入;此处额外保护,避免错误配置导致 Back 穿透到底层对话。
if (dialogueHistoryPage != null && dialogueHistoryPage.IsOpen)
return;
StoryDialogueController.instance?.CancelActiveDialogue();
}
private void OnShowHistoryButtonClicked()
{
if (dialogueHistoryPage == null || dialogueHistoryPage.IsOpen)
return;
// 打开记录不会推进 Yarn仅取消快进并补全正在显示的一句避免记录与画面不同步。
StoryDialogueController.instance?.dialogueRunner
?.GetComponent<VNDialoguePresenter>()
?.PrepareForHistory();
dialogueHistoryPage.Open(DialogueHistory.Entries);
}
}
}

View File

@@ -0,0 +1,36 @@
using TMPro;
using Ichni.Story.Dialogue;
using UnityEngine;
namespace Ichni.Story.UI
{
/// <summary>DialogHistoryPage 中单条记录的 Prefab 视图。</summary>
public class DialogueHistoryEntryView : MonoBehaviour
{
[Tooltip("普通台词显示的说话者文本;选项记录会自动隐藏。")]
[SerializeField] private TMP_Text speakerText;
[Tooltip("台词或玩家已采用选项的正文文本。")]
[SerializeField] private TMP_Text contentText;
[Tooltip("可选:仅在玩家选项记录时显示的视觉标识。")]
[SerializeField] private GameObject choiceIndicator;
/// <summary>将一条临时对话记录映射到该视图。</summary>
public void SetUp(DialogueHistoryEntry entry)
{
bool isChoice = entry != null && entry.isChoice;
if (speakerText != null)
{
speakerText.gameObject.SetActive(!isChoice && !string.IsNullOrEmpty(entry?.speakerName));
speakerText.text = entry?.speakerName ?? string.Empty;
}
if (contentText != null)
contentText.text = entry?.content ?? string.Empty;
if (choiceIndicator != null)
choiceIndicator.SetActive(isChoice);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: eaf151163c994d31bfabde12ede0fa98

View File

@@ -0,0 +1,87 @@
using System.Collections.Generic;
using Ichni.Story.Dialogue;
using UnityEngine;
using UnityEngine.UI;
namespace Ichni.Story.UI
{
/// <summary>
/// 对话记录覆盖面板。
/// <para>它不是独立 UIPage打开时覆盖在 DialogUIPage 上方,并由全屏 Raycast Target 阻断底层输入;
/// 关闭后仅恢复对话画面,不推进 Yarn 对话。</para>
/// </summary>
public class DialogueHistoryPage : MonoBehaviour
{
[Header("References")]
[Tooltip("覆盖全屏的 CanvasGroup。根物体应位于 DialogCanvas 顶层,并包含可接收 Raycast 的全屏透明 Image。")]
[SerializeField] private CanvasGroup canvasGroup;
[SerializeField] private ScrollRect scrollRect;
[SerializeField] private RectTransform entryContainer;
[SerializeField] private DialogueHistoryEntryView entryPrefab;
[SerializeField] private Button closeButton;
/// <summary>当前覆盖层是否正在显示并拦截 DialogUIPage 输入。</summary>
public bool IsOpen => _isOpen;
private readonly List<DialogueHistoryEntryView> _spawnedEntries = new();
private bool _isOpen;
private void Awake()
{
if (closeButton != null)
closeButton.onClick.AddListener(Close);
SetVisible(false);
}
private void OnDestroy()
{
if (closeButton != null)
closeButton.onClick.RemoveListener(Close);
}
/// <summary>以当前一次 TextBlock 对话的内存记录刷新并打开覆盖面板。</summary>
public void Open(IReadOnlyList<DialogueHistoryEntry> entries)
{
RebuildEntries(entries);
SetVisible(true);
Canvas.ForceUpdateCanvases();
// 记录按时间从上到下排列;打开后停在最早一条,玩家可向下浏览最新内容。
if (scrollRect != null)
scrollRect.verticalNormalizedPosition = 1f;
}
/// <summary>关闭覆盖面板,不修改当前 Yarn 进度或选项状态。</summary>
public void Close() => SetVisible(false);
private void RebuildEntries(IReadOnlyList<DialogueHistoryEntry> entries)
{
foreach (DialogueHistoryEntryView view in _spawnedEntries)
if (view != null)
Destroy(view.gameObject);
_spawnedEntries.Clear();
if (entries == null || entryPrefab == null || entryContainer == null)
return;
foreach (DialogueHistoryEntry entry in entries)
{
DialogueHistoryEntryView view = Instantiate(entryPrefab, entryContainer);
view.SetUp(entry);
_spawnedEntries.Add(view);
}
}
private void SetVisible(bool visible)
{
_isOpen = visible;
if (canvasGroup == null)
return;
canvasGroup.alpha = visible ? 1f : 0f;
canvasGroup.interactable = visible;
canvasGroup.blocksRaycasts = visible;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b01a0f4f2bd84076a5fa1e6c43a21d47

Some files were not shown because too many files have changed in this diff Show More