存档重构,游戏内容解锁机制;教程完善(未完成)
This commit is contained in:
@@ -97,6 +97,36 @@ namespace Ichni.Story
|
||||
[LabelText("Tutorial Name")]
|
||||
public string tutorialName;
|
||||
|
||||
/// <summary>
|
||||
/// 教程曲目在 <see cref="Ichni.Menu.TutorialCollection"/> 中的稳定 Key。
|
||||
/// 只用于运行时查找,不使用展示名称或章节名称作为逻辑匹配条件。
|
||||
/// 命名统一使用小写英文、数字和下划线,例如 <c>chapter0_intro</c>。
|
||||
/// </summary>
|
||||
[FoldoutGroup("$blockId")]
|
||||
[ShowIf("blockType", StoryBlockType.Tutorial)]
|
||||
[LabelText("Tutorial Key")]
|
||||
public string tutorialKey;
|
||||
|
||||
/// <summary>
|
||||
/// 教程被玩家选择“游玩”或“跳过”后写入的全局剧情变量。
|
||||
/// 后续 Block 应通过 <see cref="VariableCondition"/> 检查该变量是否等于 1 来解锁。
|
||||
/// 命名统一使用小写英文、数字和下划线,例如
|
||||
/// <c>story_tutorial_chapter0_intro_resolved</c>。
|
||||
/// </summary>
|
||||
[FoldoutGroup("$blockId")]
|
||||
[ShowIf("blockType", StoryBlockType.Tutorial)]
|
||||
[LabelText("Progress Variable")]
|
||||
public string tutorialProgressVariable;
|
||||
|
||||
/// <summary>
|
||||
/// 要启动的教程难度的稳定存档 ID,对应 <see cref="Ichni.Menu.DifficultyData.saveDifficultyId"/>。
|
||||
/// 不使用难度列表下标,避免调整难度排列后教程进入错误谱面。
|
||||
/// </summary>
|
||||
[FoldoutGroup("$blockId")]
|
||||
[ShowIf("blockType", StoryBlockType.Tutorial)]
|
||||
[LabelText("Tutorial Difficulty Save ID")]
|
||||
public int tutorialDifficultySaveId = -1;
|
||||
|
||||
/// <summary>
|
||||
/// 预览 / 调试用的显示标题:按类型取对应字段,缺省时回退到 blockId。
|
||||
/// </summary>
|
||||
|
||||
@@ -94,6 +94,20 @@ namespace Ichni.Story
|
||||
Variables.stringVariables[key] = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除指定剧情变量在三种底层字典中的所有残留值。
|
||||
/// 仅删除内存值;需要持久化和故事树刷新时,应调用 <see cref="StoryProgress"/> 的统一接口。
|
||||
/// </summary>
|
||||
public static void Remove(string key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key)) return;
|
||||
|
||||
StoryVariablesSave vars = Variables;
|
||||
vars.floatVariables.Remove(key);
|
||||
vars.stringVariables.Remove(key);
|
||||
vars.boolVariables.Remove(key);
|
||||
}
|
||||
|
||||
public static void ClearAll()
|
||||
{
|
||||
StoryVariablesSave vars = Variables;
|
||||
|
||||
@@ -157,7 +157,7 @@ namespace Ichni.Story.Dialogue
|
||||
action?.Invoke();
|
||||
}
|
||||
|
||||
StoryMessageBoxUIPage.instance.FadeIn();
|
||||
MessageUIPage.instance.FadeIn();
|
||||
}
|
||||
|
||||
if (storyUIPage != null)
|
||||
|
||||
8
Assets/Scripts/NewStorySystem/Progress.meta
Normal file
8
Assets/Scripts/NewStorySystem/Progress.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 90d61ae2c68e4a058826e1af1052cd3d
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
115
Assets/Scripts/NewStorySystem/Progress/StoryProgress.cs
Normal file
115
Assets/Scripts/NewStorySystem/Progress/StoryProgress.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ichni.Story
|
||||
{
|
||||
/// <summary>
|
||||
/// 外部玩法系统修改剧情进度的统一入口。
|
||||
///
|
||||
/// StoryVariables 只负责读写内存中的变量字典,不会自动落盘或刷新故事树。
|
||||
/// 本类将“写变量、保存变量、重算 Block 状态”收口,避免教程、小游戏等系统重复实现
|
||||
/// 不完整的存档流程。
|
||||
/// </summary>
|
||||
public static class StoryProgress
|
||||
{
|
||||
/// <summary>
|
||||
/// 写入布尔剧情变量、保存到 StorySaveModule,并立即重新推导当前故事树。
|
||||
/// 可供教程以外的玩法系统使用。
|
||||
/// </summary>
|
||||
public static bool SetBoolAndRefresh(string variableName, bool value)
|
||||
{
|
||||
if (!TrySetBoolAndSave(variableName, value))
|
||||
return false;
|
||||
|
||||
StoryManager.instance?.treeController?.RefreshAllStates();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理一次 TutorialBlock 的选择结果。
|
||||
/// 无论选择“游玩”还是“跳过”,都会将其进度变量设为 true,并将教程节点标记为完成。
|
||||
/// 后续 Block 的解锁应依赖 <see cref="StoryBlockDefinition.tutorialProgressVariable"/>,
|
||||
/// 而不是依赖该节点的 completedBlockIds,以便和 Yarn 及其它剧情变量共用同一条件系统。
|
||||
/// </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) ||
|
||||
string.IsNullOrWhiteSpace(tutorialBlock.tutorialProgressVariable))
|
||||
{
|
||||
Debug.LogWarning($"[StoryProgress] TutorialBlock '{tutorialBlock?.blockId}' 缺少 Block ID 或 Progress Variable,拒绝解锁。");
|
||||
return false;
|
||||
}
|
||||
|
||||
StoryTreeController treeController = StoryManager.instance?.treeController;
|
||||
if (treeController == null)
|
||||
{
|
||||
Debug.LogWarning("[StoryProgress] StoryTreeController 未就绪,拒绝写入教程进度。");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TrySetBoolAndSave(tutorialBlock.tutorialProgressVariable, true))
|
||||
return false;
|
||||
|
||||
// completedBlockIds 只负责教程节点自身的 Completed 外观与不可重复点击;
|
||||
// 后续节点应在 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)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(variableName))
|
||||
{
|
||||
Debug.LogWarning("[StoryProgress] 剧情变量名为空,拒绝写入。");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (GameSaveManager.instance == null || GameSaveManager.instance.StorySaveModule == null)
|
||||
{
|
||||
Debug.LogWarning("[StoryProgress] StorySaveModule 未就绪,拒绝写入剧情变量。");
|
||||
return false;
|
||||
}
|
||||
|
||||
StoryVariables.SetBool(variableName, value);
|
||||
GameSaveManager.instance.StorySaveModule.SaveVariables();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c2e5a902d1d14be4a2e1f06c2353d4a1
|
||||
139
Assets/Scripts/NewStorySystem/Progress/TutorialFlowController.cs
Normal file
139
Assets/Scripts/NewStorySystem/Progress/TutorialFlowController.cs
Normal file
@@ -0,0 +1,139 @@
|
||||
using Ichni.Menu;
|
||||
using Ichni.Menu.UI;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ichni.Story
|
||||
{
|
||||
/// <summary>
|
||||
/// TutorialBlock 的菜单层流程协调器。
|
||||
/// 负责验证配置、生成通用 SelectionBox 选项、写入剧情进度,并在“游玩”时发起 GameScene 运行。
|
||||
/// 不保存“教程成绩”或“教程是否通关”;本阶段规则是玩家确认游玩或跳过即视为已处理。
|
||||
/// </summary>
|
||||
public static class TutorialFlowController
|
||||
{
|
||||
/// <summary>
|
||||
/// 由 <see cref="UI.TutorialBlockView"/> 点击时调用。配置无效或 SelectionBox 页面未配置时只报错,不修改剧情进度。
|
||||
/// </summary>
|
||||
public static void RequestTutorial(StoryBlockDefinition tutorialBlock)
|
||||
{
|
||||
if (!TryResolveTutorialRunData(tutorialBlock, out ChapterSelectionUnit chapter,
|
||||
out SongItemData song, out DifficultyData difficulty))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
MenuManager menuManager = MenuManager.instance;
|
||||
SelectionBoxUIPage selectionBoxPage = menuManager?.selectionBoxUIPage;
|
||||
if (selectionBoxPage == null)
|
||||
{
|
||||
Debug.LogWarning("[TutorialFlowController] MenuManager 未配置 SelectionBoxUIPage,教程不会被解锁。");
|
||||
return;
|
||||
}
|
||||
|
||||
// TutorialFlowController 只描述“有哪些选择、各自触发什么效果”;
|
||||
// 具体按钮由通用 SelectionBox 在运行时生成,后续其它系统可复用同一 UI。
|
||||
selectionBoxPage.Show(
|
||||
tutorialBlock.tutorialName,
|
||||
string.Empty,
|
||||
new List<SelectionBoxOption>
|
||||
{
|
||||
new("游玩教程", () => PlayTutorial(tutorialBlock, chapter, song, difficulty)),
|
||||
new("跳过教程", () => SkipTutorial(tutorialBlock))
|
||||
});
|
||||
}
|
||||
|
||||
private static void PlayTutorial(StoryBlockDefinition tutorialBlock, ChapterSelectionUnit chapter,
|
||||
SongItemData song, DifficultyData difficulty)
|
||||
{
|
||||
MenuManager menuManager = MenuManager.instance;
|
||||
if (menuManager == null || menuManager.isEnteringGame)
|
||||
{
|
||||
Debug.LogWarning("[TutorialFlowController] 菜单正在切换,忽略教程游玩请求。");
|
||||
return;
|
||||
}
|
||||
|
||||
// 先验证能否启动 GameScene,再写入“已处理”进度;避免无效配置导致错误解锁。
|
||||
if (!CanBeginTutorialGame(chapter, song, difficulty))
|
||||
return;
|
||||
|
||||
if (!StoryProgress.ResolveTutorial(tutorialBlock))
|
||||
return;
|
||||
|
||||
// 规则:玩家确认“游玩教程”的瞬间即解锁后续剧情,不等待教程结算。
|
||||
menuManager.TryBeginGame(chapter, song, difficulty, MenuReturnDestination.Story,
|
||||
menuManager.storyUIPage);
|
||||
}
|
||||
|
||||
private static void SkipTutorial(StoryBlockDefinition tutorialBlock)
|
||||
{
|
||||
// 规则:玩家确认“跳过教程”的瞬间即解锁后续剧情,并保留在故事树。
|
||||
StoryProgress.ResolveTutorial(tutorialBlock);
|
||||
}
|
||||
|
||||
private static bool CanBeginTutorialGame(ChapterSelectionUnit chapter, SongItemData song, DifficultyData difficulty)
|
||||
{
|
||||
if (MenuManager.instance == null || InformationTransistor.instance == null ||
|
||||
!MenuManager.instance.CanBeginGame)
|
||||
{
|
||||
Debug.LogWarning("[TutorialFlowController] MenuManager、InformationTransistor 或 GameScene 预加载未就绪,无法进入教程。");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (chapter == null || song == null || difficulty == null)
|
||||
{
|
||||
Debug.LogWarning("[TutorialFlowController] 教程缺少章节、歌曲或难度配置,无法进入教程。");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析 TutorialBlock 的 Key、教程曲目与稳定难度 ID。
|
||||
/// 任何一项缺失都会失败且不写入剧情变量,防止错误配置意外解锁后续内容。
|
||||
/// </summary>
|
||||
private static bool TryResolveTutorialRunData(StoryBlockDefinition tutorialBlock,
|
||||
out ChapterSelectionUnit chapter, out SongItemData song, out DifficultyData difficulty)
|
||||
{
|
||||
chapter = ChapterSelectionManager.instance?.currentChapter;
|
||||
song = null;
|
||||
difficulty = null;
|
||||
|
||||
if (tutorialBlock == null || tutorialBlock.blockType != StoryBlockType.Tutorial ||
|
||||
string.IsNullOrWhiteSpace(tutorialBlock.tutorialKey) ||
|
||||
string.IsNullOrWhiteSpace(tutorialBlock.tutorialProgressVariable) ||
|
||||
tutorialBlock.tutorialDifficultySaveId < 0)
|
||||
{
|
||||
Debug.LogWarning($"[TutorialFlowController] TutorialBlock '{tutorialBlock?.blockId}' 的 Tutorial Key、Progress Variable 或 Difficulty Save ID 未配置完整。");
|
||||
return false;
|
||||
}
|
||||
|
||||
TutorialCollection collection = ChapterSelectionManager.instance?.tutorialCollection;
|
||||
if (chapter == null || collection == null || !collection.TryGetTutorialSong(tutorialBlock.tutorialKey, out song))
|
||||
{
|
||||
Debug.LogWarning($"[TutorialFlowController] TutorialBlock '{tutorialBlock.blockId}' 无法解析章节或教程曲目。");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (song.difficultyDataList == null)
|
||||
{
|
||||
Debug.LogWarning($"[TutorialFlowController] 教程 '{tutorialBlock.tutorialKey}' 没有难度列表。");
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (DifficultyData candidate in song.difficultyDataList)
|
||||
{
|
||||
if (candidate != null && candidate.isAvailable &&
|
||||
candidate.saveDifficultyId == tutorialBlock.tutorialDifficultySaveId)
|
||||
{
|
||||
difficulty = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Debug.LogWarning($"[TutorialFlowController] 教程 '{tutorialBlock.tutorialKey}' 中不存在可用的 Difficulty Save ID '{tutorialBlock.tutorialDifficultySaveId}'。");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a44d2b7e3f954b9b86f6beec1a7cb621
|
||||
@@ -15,6 +15,12 @@ namespace Ichni
|
||||
private const string ChapterStoryKey = "ChapterStory";
|
||||
private const string VariablesKey = "StoryVariables";
|
||||
|
||||
// 每个剧情存档文件都保存自己的 Schema Version;不要把它与歌曲成绩存档的 Version 混用。
|
||||
// 当前项目尚未正式发布,剧情存档统一从 v1 开始;非 v1 的测试存档会被直接重置。
|
||||
// 正式发布后升级 Schema 时,必须用非破坏性迁移替代当前的重置策略。
|
||||
private const string StorySaveSchemaVersionKey = "StorySaveSchemaVersion";
|
||||
private const int CurrentStorySaveSchemaVersion = 1;
|
||||
|
||||
// 已加载章节的内存缓存(chapterIndex -> 章节存档)
|
||||
private readonly Dictionary<string, ChapterStorySave> _chapterSaves =
|
||||
new Dictionary<string, ChapterStorySave>();
|
||||
@@ -28,31 +34,67 @@ namespace Ichni
|
||||
private static string VariablesPath =>
|
||||
Application.persistentDataPath + "/StorySaves/StoryVariables.json";
|
||||
|
||||
/// <summary>
|
||||
/// 补齐章节存档的可空集合,并以文件名对应的 chapterIndex 作为唯一归属。
|
||||
/// 当前用于保证新建 v1 存档的运行时集合非空;未来迁移逻辑应单独实现,不应依赖此方法隐式转换。
|
||||
/// </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>();
|
||||
return save;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 补齐全局剧情变量字典,保证新建 v1 存档可被 Yarn 变量访问。
|
||||
/// </summary>
|
||||
private static StoryVariablesSave NormalizeVariables(StoryVariablesSave storyVariables)
|
||||
{
|
||||
storyVariables ??= new StoryVariablesSave();
|
||||
storyVariables.floatVariables ??= new Dictionary<string, float>();
|
||||
storyVariables.stringVariables ??= new Dictionary<string, string>();
|
||||
storyVariables.boolVariables ??= new Dictionary<string, bool>();
|
||||
return storyVariables;
|
||||
}
|
||||
|
||||
// ── 故事树 + 选项(按章节)────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 判断某章节是否尚无存档(首次进入)。
|
||||
/// 判断某章节是否尚无可用的 v1 存档(首次进入或旧测试存档待重置)。
|
||||
/// </summary>
|
||||
public bool IsNewChapter(string chapterIndex) =>
|
||||
!ES3.FileExists(GetChapterSavePath(chapterIndex));
|
||||
public bool IsNewChapter(string chapterIndex)
|
||||
{
|
||||
string path = GetChapterSavePath(chapterIndex);
|
||||
return !ES3.FileExists(path) || !ES3.KeyExists(ChapterStoryKey, path) ||
|
||||
!ES3.KeyExists(StorySaveSchemaVersionKey, path) ||
|
||||
ES3.Load<int>(StorySaveSchemaVersionKey, path) != CurrentStorySaveSchemaVersion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载指定章节的存档容器;无存档时返回一个新的空容器。加载结果会写入内存缓存。
|
||||
/// 加载指定章节的 v1 存档容器;无存档或非 v1 的预发布测试存档会直接重置为空容器。
|
||||
/// 加载结果会写入内存缓存。
|
||||
/// </summary>
|
||||
public ChapterStorySave LoadChapter(string chapterIndex)
|
||||
{
|
||||
string path = GetChapterSavePath(chapterIndex);
|
||||
bool hasExistingFile = ES3.FileExists(path);
|
||||
int loadedSchemaVersion = hasExistingFile && ES3.KeyExists(StorySaveSchemaVersionKey, path)
|
||||
? ES3.Load<int>(StorySaveSchemaVersionKey, path)
|
||||
: 0;
|
||||
|
||||
ChapterStorySave save;
|
||||
if (ES3.FileExists(path) && ES3.KeyExists(ChapterStoryKey, path))
|
||||
if (hasExistingFile && loadedSchemaVersion != CurrentStorySaveSchemaVersion)
|
||||
{
|
||||
save = ES3.Load<ChapterStorySave>(ChapterStoryKey, path);
|
||||
}
|
||||
else
|
||||
{
|
||||
save = new ChapterStorySave { chapterIndex = chapterIndex };
|
||||
Debug.LogWarning($"Resetting pre-release chapter story save '{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();
|
||||
save = NormalizeChapterSave(save, chapterIndex);
|
||||
_chapterSaves[chapterIndex] = save;
|
||||
return save;
|
||||
}
|
||||
@@ -62,8 +104,17 @@ namespace Ichni
|
||||
/// </summary>
|
||||
public void SaveChapter(ChapterStorySave save)
|
||||
{
|
||||
if (save == null || string.IsNullOrEmpty(save.chapterIndex))
|
||||
{
|
||||
Debug.LogWarning("Cannot save a chapter story record without a chapterIndex.");
|
||||
return;
|
||||
}
|
||||
|
||||
save = NormalizeChapterSave(save, save.chapterIndex);
|
||||
_chapterSaves[save.chapterIndex] = save;
|
||||
ES3.Save(ChapterStoryKey, save, GetChapterSavePath(save.chapterIndex));
|
||||
string path = GetChapterSavePath(save.chapterIndex);
|
||||
ES3.Save(ChapterStoryKey, save, path);
|
||||
ES3.Save(StorySaveSchemaVersionKey, CurrentStorySaveSchemaVersion, path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -101,14 +152,22 @@ namespace Ichni
|
||||
/// </summary>
|
||||
public void LoadVariables()
|
||||
{
|
||||
if (ES3.FileExists(VariablesPath) && ES3.KeyExists(VariablesKey, VariablesPath))
|
||||
bool hasExistingFile = ES3.FileExists(VariablesPath);
|
||||
int loadedSchemaVersion = hasExistingFile && ES3.KeyExists(StorySaveSchemaVersionKey, VariablesPath)
|
||||
? ES3.Load<int>(StorySaveSchemaVersionKey, VariablesPath)
|
||||
: 0;
|
||||
|
||||
if (hasExistingFile && loadedSchemaVersion != CurrentStorySaveSchemaVersion)
|
||||
{
|
||||
variables = ES3.Load<StoryVariablesSave>(VariablesKey, VariablesPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
variables = new StoryVariablesSave();
|
||||
Debug.LogWarning($"Resetting pre-release global story variables from schema v{loadedSchemaVersion} to v{CurrentStorySaveSchemaVersion}.");
|
||||
ES3.DeleteFile(VariablesPath);
|
||||
hasExistingFile = false;
|
||||
}
|
||||
|
||||
variables = hasExistingFile && ES3.KeyExists(VariablesKey, VariablesPath)
|
||||
? ES3.Load<StoryVariablesSave>(VariablesKey, VariablesPath)
|
||||
: new StoryVariablesSave();
|
||||
variables = NormalizeVariables(variables);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -116,7 +175,9 @@ namespace Ichni
|
||||
/// </summary>
|
||||
public void SaveVariables()
|
||||
{
|
||||
variables = NormalizeVariables(variables);
|
||||
ES3.Save(VariablesKey, variables, VariablesPath);
|
||||
ES3.Save(StorySaveSchemaVersionKey, CurrentStorySaveSchemaVersion, VariablesPath);
|
||||
}
|
||||
|
||||
// ── 清档 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -44,13 +44,13 @@ namespace Ichni.Story
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清除全部剧情存档(所有章节故事树 + 全局变量 + 剧情解锁 key)。
|
||||
/// 清除全部剧情存档(所有章节故事树 + 全局变量 + 由剧情授予的内容解锁 Key)。
|
||||
/// </summary>
|
||||
[Button]
|
||||
public void ClearAllStorySave()
|
||||
{
|
||||
GameSaveManager.instance.StorySaveModule.ClearAllStoryline();
|
||||
GameSaveManager.instance.SongSaveModule.ClearStoryKeys();
|
||||
GameSaveManager.instance.UnlockSaveModule.ClearAllKeys();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +216,12 @@ namespace Ichni.Story
|
||||
public void OnBlockCompleted(string blockId)
|
||||
{
|
||||
if (!_completed.Add(blockId))
|
||||
return; // 已完成,无需处理
|
||||
{
|
||||
// 剧情变量可能在 block 已完成后被外部系统修正;即使完成集合没有变化,
|
||||
// 也要重新推导依赖变量的后续 Block 状态。
|
||||
RefreshAllStates();
|
||||
return;
|
||||
}
|
||||
|
||||
RefreshAllStates();
|
||||
SaveChapter();
|
||||
@@ -382,6 +387,9 @@ namespace Ichni.Story
|
||||
return;
|
||||
}
|
||||
|
||||
// TutorialBlock 的后续解锁由全局剧情变量驱动;只清空 completedBlockIds
|
||||
// 会造成“节点未完成但后续仍解锁”的错误测试结果,因此同时清除本章节声明的教程变量。
|
||||
StoryProgress.ClearTutorialProgressForChapter(_storyData);
|
||||
_completed.Clear();
|
||||
SaveChapter();
|
||||
BuildChapter(_chapterIndex);
|
||||
|
||||
@@ -4,7 +4,8 @@ using UnityEngine;
|
||||
namespace Ichni.Story.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 教程块视图(占位)。点击后将进入该章节的教程游玩,后续阶段接入。
|
||||
/// 教程块视图。点击后由 <see cref="TutorialFlowController"/> 显示确认窗口,
|
||||
/// 玩家选择游玩或跳过时都会写入教程剧情变量并刷新故事树。
|
||||
/// </summary>
|
||||
public class TutorialBlockView : StoryBlockView
|
||||
{
|
||||
@@ -21,8 +22,19 @@ namespace Ichni.Story.UI
|
||||
|
||||
protected override void OnClicked()
|
||||
{
|
||||
// 占位:后续接入教程进入流程
|
||||
Debug.Log($"[TutorialBlockView] 点击教程块 '{blockId}' (tutorial='{definition.tutorialName}')。占位实现,教程进入将在后续阶段接入。");
|
||||
TutorialFlowController.RequestTutorial(definition);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 已处理的教程不再重复弹出“游玩 / 跳过”确认窗口。
|
||||
/// 教程重玩将来应由独立的教程入口提供,不复用剧情进度节点。
|
||||
/// </summary>
|
||||
public override void ApplyState(StoryBlockState newState)
|
||||
{
|
||||
base.ApplyState(newState);
|
||||
|
||||
if (button != null && newState == StoryBlockState.Completed)
|
||||
button.interactable = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using Ichni.Menu.UI;
|
||||
using Ichni.UI;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ichni.Story.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 用于在剧情系统中管理并顺序展示 MessageBox 的独立 UI 页面。
|
||||
/// 支持动态实例化给定的 MessageBox Prefab,并在所有消息展示完毕后进行大页面的 FadeOut。
|
||||
/// </summary>
|
||||
public class StoryMessageBoxUIPage : UIPageBase
|
||||
{
|
||||
public static StoryMessageBoxUIPage instance;
|
||||
|
||||
[Header("Prefabs & Containers")]
|
||||
[Tooltip("默认使用的 MessageBox 预制体(请从 Project 中拖拽,而非场景中的实例)")]
|
||||
public MessageBox defaultMessageBoxPrefab;
|
||||
|
||||
[Tooltip("生成的 MessageBox 挂载在哪?如果不填,默认挂载在自己身上")]
|
||||
public Transform messageContainer;
|
||||
|
||||
// 用于排队的内部消息数据结构
|
||||
private struct PendingMessage
|
||||
{
|
||||
public string title;
|
||||
public string content;
|
||||
public MessageBox customPrefab; // 支持未来传入不同种类的 Prefab
|
||||
}
|
||||
|
||||
private Queue<PendingMessage> _messageQueue = new Queue<PendingMessage>();
|
||||
private MessageBox _currentActiveBox;
|
||||
private bool _isPageActive = false;
|
||||
|
||||
protected override void Awake()
|
||||
{
|
||||
base.Awake();
|
||||
|
||||
if (instance == null)
|
||||
{
|
||||
instance = this;
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning("[StoryMessageBoxUIPage] 场景中存在多个实例,正在销毁多余的实例。");
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageContainer == null)
|
||||
{
|
||||
messageContainer = this.transform;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将歌曲解锁提示加入弹窗队列。
|
||||
/// </summary>
|
||||
public void ShowUnlockMessage(string songUnlockKey)
|
||||
{
|
||||
string title = "New Song Unlocked!";
|
||||
string content = $"You have unlocked the song: {songUnlockKey}!";
|
||||
EnqueueMessage(title, content, defaultMessageBoxPrefab);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将自定义文本提示加入弹窗队列。
|
||||
/// </summary>
|
||||
public void ShowCustomMessage(string title, string content)
|
||||
{
|
||||
EnqueueMessage(title, content, defaultMessageBoxPrefab);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将指定消息入队。如果是空闲状态,则唤醒大页面并开始处理队列。
|
||||
/// </summary>
|
||||
private void EnqueueMessage(string title, string content, MessageBox prefab)
|
||||
{
|
||||
if (prefab == null)
|
||||
{
|
||||
Debug.LogError("[StoryMessageBoxUIPage] 没有可用的 MessageBox 预制体!");
|
||||
return;
|
||||
}
|
||||
|
||||
_messageQueue.Enqueue(new PendingMessage
|
||||
{
|
||||
title = title,
|
||||
content = content,
|
||||
customPrefab = prefab
|
||||
});
|
||||
|
||||
// 如果当前页面没有在显示中,则开始整体流程
|
||||
if (!_isPageActive)
|
||||
{
|
||||
_isPageActive = true;
|
||||
|
||||
// 唤醒大页面(执行 UIPageBase 的渐入并拦截底层射线)
|
||||
this.FadeIn(0.5f, false, ShowNextInQueue);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从队列中取出一个并实例化显示。
|
||||
/// </summary>
|
||||
private void ShowNextInQueue()
|
||||
{
|
||||
if (_messageQueue.Count == 0)
|
||||
{
|
||||
// 队列处理完毕,大页面整体退场
|
||||
_currentActiveBox = null;
|
||||
_isPageActive = false;
|
||||
this.FadeOut();
|
||||
return;
|
||||
}
|
||||
|
||||
PendingMessage msg = _messageQueue.Dequeue();
|
||||
|
||||
// 实例化预制体
|
||||
_currentActiveBox = Instantiate(msg.customPrefab, messageContainer);
|
||||
|
||||
// 订阅“当这个消息框彻底结束关闭时”的事件
|
||||
_currentActiveBox.onAllMessagesClosed.AddListener(() =>
|
||||
{
|
||||
// 销毁旧实例
|
||||
if (_currentActiveBox != null)
|
||||
{
|
||||
Destroy(_currentActiveBox.gameObject);
|
||||
}
|
||||
|
||||
// 检查并显示下一个
|
||||
ShowNextInQueue();
|
||||
});
|
||||
|
||||
// 配置并启动 MessageBox
|
||||
_currentActiveBox.Clear();
|
||||
_currentActiveBox.AddInfo(msg.title, msg.content, null);
|
||||
|
||||
// MessageBox.SetUp() 内部会激活自己并执行自己的 FadeIn
|
||||
_currentActiveBox.gameObject.SetActive(true);
|
||||
_currentActiveBox.SetUp();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 07f2a7ffd4b513d4e90d60a6587914cc
|
||||
@@ -14,39 +14,29 @@ namespace Ichni.Story.YarnFunctions
|
||||
// ── Yarn Commands ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 解锁一首新歌曲,并在当前这段对话结束(对话 UI 淡出)后,弹出 MessageBox 提示。
|
||||
/// 格式: <<unlock_song songUnlockKey>>
|
||||
/// 兼容既有 Yarn 脚本:授予歌曲相关的解锁 Key,并在当前对话结束后弹出提示。
|
||||
/// 格式: <<unlock_song unlock_key>>
|
||||
/// <para>Key 必须使用小写字母、数字和下划线,例如 <c>story_ch0_prologue_completed</c>。</para>
|
||||
/// 新内容若不需要歌曲提示,优先使用 <see cref="GrantUnlock"/>。
|
||||
/// </summary>
|
||||
/// <param name="songUnlockKey">要解锁的歌曲密钥/ID</param>
|
||||
/// <param name="songUnlockKey">要授予的稳定解锁 Key,而不是歌曲显示名称。</param>
|
||||
[YarnCommand("unlock_song")]
|
||||
public static void UnlockSong(string songUnlockKey)
|
||||
{
|
||||
if (GameSaveManager.instance == null || GameSaveManager.instance.SongSaveModule == null)
|
||||
if (!TryGrantUnlockKey(songUnlockKey, out bool wasNewlyGranted) || !wasNewlyGranted)
|
||||
{
|
||||
Debug.LogError("[StoryTreeCommands] 无法解锁歌曲:GameSaveManager 或其 SongSaveModule 未初始化!");
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 检查是否已经解锁
|
||||
if (GameSaveManager.instance.SongSaveModule.CheckStoryKey(songUnlockKey))
|
||||
{
|
||||
Debug.Log($"[StoryTreeCommands] 歌曲 {songUnlockKey} 已处于解锁状态。");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 写入解锁状态并保存存档
|
||||
GameSaveManager.instance.SongSaveModule.storyUnlockKeys.Add(songUnlockKey);
|
||||
GameSaveManager.instance.SongSaveModule.SaveStoryUnlockKeys();
|
||||
Debug.Log($"[StoryTreeCommands] 已成功写入并保存歌曲解锁密钥:{songUnlockKey}");
|
||||
|
||||
// 3. 将 UI 弹窗提示加入到对话结束队列中
|
||||
// 将歌曲提示加入对话结束队列。弹窗本地化将由后续 Localization 阶段替换,
|
||||
// 这里不参与内容是否解锁的授权判断。
|
||||
if (StoryDialogueController.instance != null)
|
||||
{
|
||||
StoryDialogueController.instance.dialogueEndActions.Add(() =>
|
||||
{
|
||||
if (StoryMessageBoxUIPage.instance != null)
|
||||
if (MessageUIPage.instance != null)
|
||||
{
|
||||
StoryMessageBoxUIPage.instance.ShowUnlockMessage(songUnlockKey);
|
||||
MessageUIPage.instance.ShowUnlockMessage(songUnlockKey);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -60,6 +50,18 @@ namespace Ichni.Story.YarnFunctions
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通用内容解锁命令,可用于章节、歌曲、教程及未来内容。
|
||||
/// 格式: <<grant_unlock unlock_key>>。
|
||||
/// 本命令只授予并保存 Key,不绑定任何 UI 提示;需要提示时应由 Yarn 额外调用 <c>show_message</c>。
|
||||
/// </summary>
|
||||
/// <param name="unlockKey">符合 <see cref="UnlockSaveModule.KeyNamingRule"/> 的稳定解锁 Key。</param>
|
||||
[YarnCommand("grant_unlock")]
|
||||
public static void GrantUnlock(string unlockKey)
|
||||
{
|
||||
TryGrantUnlockKey(unlockKey, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在对话结束后,弹出一个自定义 MessageBox 提示。
|
||||
/// 会尝试使用 Unity Localization 进行翻译,如果找不到对应条目则回退显示原文本(Key)。
|
||||
@@ -76,10 +78,39 @@ namespace Ichni.Story.YarnFunctions
|
||||
Debug.Log($"[StoryTreeCommands] 准备在对话结束后显示自定义弹窗:标题='{translatedTitle}',内容='{translatedContent}'");
|
||||
StoryDialogueController.instance.dialogueEndActions.Add(() =>
|
||||
{
|
||||
StoryMessageBoxUIPage.instance.ShowCustomMessage(translatedTitle, translatedContent);
|
||||
MessageUIPage.instance.ShowCustomMessage(translatedTitle, translatedContent);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 所有 Yarn 解锁命令共用的授予入口。它负责模块存在性检查、重复授予的幂等处理及立即 ES3 保存。
|
||||
/// </summary>
|
||||
/// <returns>模块已正常处理该 Key 时返回 true;Key 非法或模块尚未初始化时返回 false。</returns>
|
||||
private static bool TryGrantUnlockKey(string unlockKey, out bool wasNewlyGranted)
|
||||
{
|
||||
wasNewlyGranted = false;
|
||||
UnlockSaveModule unlockSaveModule = GameSaveManager.instance?.UnlockSaveModule;
|
||||
if (unlockSaveModule == null)
|
||||
{
|
||||
Debug.LogError("[StoryTreeCommands] 无法授予解锁 Key:UnlockSaveModule 尚未初始化!");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (unlockSaveModule.HasKey(unlockKey))
|
||||
{
|
||||
Debug.Log($"[StoryTreeCommands] 解锁 Key 已存在:{unlockKey}");
|
||||
return true;
|
||||
}
|
||||
|
||||
wasNewlyGranted = unlockSaveModule.GrantKey(unlockKey);
|
||||
if (wasNewlyGranted)
|
||||
{
|
||||
Debug.Log($"[StoryTreeCommands] 已成功写入并保存解锁 Key:{unlockKey}");
|
||||
}
|
||||
|
||||
return wasNewlyGranted;
|
||||
}
|
||||
|
||||
// ── 内部辅助 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static string GetTranslatedText(string tableName, string key)
|
||||
|
||||
Reference in New Issue
Block a user