存档重构,游戏内容解锁机制;教程完善(未完成)

This commit is contained in:
SoulliesOfficial
2026-07-18 16:51:18 -04:00
parent d48ef1e65e
commit dda354ebb9
123 changed files with 4032 additions and 558 deletions

View 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;
}
}
}

View File

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

View 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;
}
}
}

View File

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