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