剧情+对话完善
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user