剧情+对话完善
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2e8fa775d6ca4c6aa9c5f701c0d52eab
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user