Storyline+Dialog初步
This commit is contained in:
81
Assets/Scripts/NewStorySystem/Tree/BlockConnectorView.cs
Normal file
81
Assets/Scripts/NewStorySystem/Tree/BlockConnectorView.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI.Extensions;
|
||||
|
||||
namespace Ichni.Story.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 故事树中两个 block 之间的连接线视图,使用 UILineRenderer 绘制 S 形曲线。
|
||||
/// </summary>
|
||||
public class BlockConnectorView : MonoBehaviour
|
||||
{
|
||||
// 判定两端口坐标是否塌缩为同一点的阈值(像素)。
|
||||
private const float DegenerateDistanceThreshold = 1f;
|
||||
|
||||
public UILineRenderer curve;
|
||||
public StoryBlockView startBlock;
|
||||
public StoryBlockView endBlock;
|
||||
|
||||
/// <summary>
|
||||
/// 设置连接线的起止 block 并重算曲线点。传入 null 时保留已有引用。
|
||||
/// 注意:必须在 block 的 RectTransform 完成布局(Canvas 已刷新)后调用,
|
||||
/// 否则端口世界坐标尚未生效,会导致所有顶点塌缩到同一点。
|
||||
/// </summary>
|
||||
public void SetCurve(StoryBlockView start = null, StoryBlockView end = null)
|
||||
{
|
||||
if (start != null) startBlock = start;
|
||||
if (end != null) endBlock = end;
|
||||
|
||||
if (startBlock == null || endBlock == null)
|
||||
{
|
||||
Debug.LogWarning("[BlockConnectorView] 起点或终点 block 未设置,无法绘制曲线。");
|
||||
return;
|
||||
}
|
||||
|
||||
RectTransform selfRect = (RectTransform)transform;
|
||||
|
||||
// ScreenSpaceCamera / WorldSpace 画布下的世界→屏幕转换需要对应相机;Overlay 传 null。
|
||||
Camera uiCamera = ResolveUICamera();
|
||||
|
||||
Vector2 startPosition = GetLocalPoint(startBlock.outPort, selfRect, uiCamera);
|
||||
Vector2 endPosition = GetLocalPoint(endBlock.inPort, selfRect, uiCamera);
|
||||
|
||||
if (Vector2.Distance(startPosition, endPosition) < DegenerateDistanceThreshold)
|
||||
{
|
||||
Debug.LogWarning(
|
||||
$"[BlockConnectorView] '{startBlock.blockId}' -> '{endBlock.blockId}' 端口坐标塌缩为同一点 " +
|
||||
$"(start={startPosition}, end={endPosition}),通常是在 block 布局完成前调用了 SetCurve。");
|
||||
}
|
||||
|
||||
// 两个控制点构成 S 形:先水平离开起点,再水平进入终点。
|
||||
float midX = (startPosition.x + endPosition.x) / 2f;
|
||||
Vector2 mid1 = new Vector2(midX, startPosition.y);
|
||||
Vector2 mid2 = new Vector2(midX, endPosition.y);
|
||||
|
||||
curve.Points = new[] { startPosition, mid1, mid2, endPosition };
|
||||
curve.SetVerticesDirty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用 <see cref="RectTransformUtility"/> 将 <paramref name="port"/> 的世界位置
|
||||
/// 转换到 <paramref name="space"/> 的局部坐标(相对 pivot),即 UILineRenderer 顶点所用的坐标系。
|
||||
/// </summary>
|
||||
private static Vector2 GetLocalPoint(RectTransform port, RectTransform space, Camera uiCamera)
|
||||
{
|
||||
Vector2 screenPoint = RectTransformUtility.WorldToScreenPoint(uiCamera, port.position);
|
||||
RectTransformUtility.ScreenPointToLocalPointInRectangle(space, screenPoint, uiCamera, out Vector2 localPoint);
|
||||
return localPoint;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析用于坐标转换的相机:Overlay 画布返回 null,其余(ScreenSpaceCamera / WorldSpace)返回画布相机。
|
||||
/// </summary>
|
||||
private Camera ResolveUICamera()
|
||||
{
|
||||
Canvas canvas = curve != null ? curve.canvas : GetComponentInParent<Canvas>();
|
||||
if (canvas == null || canvas.renderMode == RenderMode.ScreenSpaceOverlay)
|
||||
return null;
|
||||
|
||||
return canvas.worldCamera;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9d179a524aa28d74f9ae667fd78aed20
|
||||
28
Assets/Scripts/NewStorySystem/Tree/SongBlockView.cs
Normal file
28
Assets/Scripts/NewStorySystem/Tree/SongBlockView.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ichni.Story.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 音乐块视图(占位)。点击后将切换到曲目选择并自动选中对应歌曲,后续阶段接入。
|
||||
/// </summary>
|
||||
public class SongBlockView : StoryBlockView
|
||||
{
|
||||
[Header("Song Block")]
|
||||
public TMP_Text songNameText;
|
||||
|
||||
public override void Initialize(StoryBlockDefinition def, Vector2 position, StoryBlockState blockState)
|
||||
{
|
||||
base.Initialize(def, position, blockState);
|
||||
|
||||
if (songNameText != null)
|
||||
songNameText.text = def.songName;
|
||||
}
|
||||
|
||||
protected override void OnClicked()
|
||||
{
|
||||
// 占位:后续接入曲目选择流程
|
||||
Debug.Log($"[SongBlockView] 点击音乐块 '{blockId}' (song='{definition.songName}')。占位实现,曲目选择将在后续阶段接入。");
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/NewStorySystem/Tree/SongBlockView.cs.meta
Normal file
2
Assets/Scripts/NewStorySystem/Tree/SongBlockView.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ad764389c6c056c4d84cdf0155d7e7aa
|
||||
80
Assets/Scripts/NewStorySystem/Tree/StoryBlockView.cs
Normal file
80
Assets/Scripts/NewStorySystem/Tree/StoryBlockView.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using Sirenix.OdinInspector;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Ichni.Story.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 故事树中单个 block 的视图基类。负责通用的定位、状态视觉与点击分发。
|
||||
/// 具体类型(文本 / 音乐 / 教程)由子类实现 <see cref="OnClicked"/>。
|
||||
/// </summary>
|
||||
public abstract class StoryBlockView : SerializedMonoBehaviour
|
||||
{
|
||||
[Header("Block State")]
|
||||
public string blockId;
|
||||
public StoryBlockState state;
|
||||
|
||||
[Header("Common References")]
|
||||
public RectTransform blockRect;
|
||||
public RectTransform inPort;
|
||||
public RectTransform outPort;
|
||||
public Button button;
|
||||
|
||||
// 关联的定义,供子类读取类型专属字段
|
||||
protected StoryBlockDefinition definition;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化 block 视图。逻辑坐标已由控制器按网格换算为 <paramref name="position"/>
|
||||
/// (以 BlockContainer 左侧中线为原点、x 向右为正、y 向下为正的 anchoredPosition)。
|
||||
/// </summary>
|
||||
/// <param name="def">block 定义(提供尺寸与类型专属数据)。</param>
|
||||
/// <param name="position">已换算好的 anchoredPosition。</param>
|
||||
/// <param name="blockState">推导得到的 block 状态。</param>
|
||||
public virtual void Initialize(StoryBlockDefinition def, Vector2 position, StoryBlockState blockState)
|
||||
{
|
||||
definition = def;
|
||||
blockId = def.blockId;
|
||||
state = blockState;
|
||||
|
||||
// 统一坐标约定:以 BlockContainer 的左侧中线为原点,向右为 +x、向下为 +y。
|
||||
blockRect.anchorMin = new Vector2(0f, 0.5f);
|
||||
blockRect.anchorMax = new Vector2(0f, 0.5f);
|
||||
blockRect.pivot = new Vector2(0f, 0.5f);
|
||||
blockRect.sizeDelta = new Vector2(400, 200);
|
||||
blockRect.anchoredPosition = position;
|
||||
|
||||
if (button != null)
|
||||
button.onClick.AddListener(HandleClick);
|
||||
|
||||
ApplyState(state);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 应用状态并更新视觉。基类默认按状态切换按钮可交互性;
|
||||
/// 子类可 override 以更新锁定图标、颜色等具体表现。
|
||||
/// </summary>
|
||||
public virtual void ApplyState(StoryBlockState newState)
|
||||
{
|
||||
state = newState;
|
||||
|
||||
if (button != null)
|
||||
button.interactable = state != StoryBlockState.Locked;
|
||||
}
|
||||
|
||||
private void HandleClick()
|
||||
{
|
||||
if (state == StoryBlockState.Locked)
|
||||
return;
|
||||
|
||||
if (StoryManager.instance != null && StoryManager.instance.treeController != null)
|
||||
StoryManager.instance.treeController.currentBlock = this;
|
||||
|
||||
OnClicked();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 点击(非锁定状态)时的具体行为,由子类实现。
|
||||
/// </summary>
|
||||
protected abstract void OnClicked();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 74cc3bad2b6adeb4b93cd509340a23be
|
||||
56
Assets/Scripts/NewStorySystem/Tree/StoryManager.cs
Normal file
56
Assets/Scripts/NewStorySystem/Tree/StoryManager.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System.Collections.Generic;
|
||||
using Ichni.Story.UI;
|
||||
using Sirenix.OdinInspector;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ichni.Story
|
||||
{
|
||||
/// <summary>
|
||||
/// 剧情系统协调器(单例)。持有故事树控制器、剧情页面与章节数据引用,
|
||||
/// 是外部系统(章节选择、存档等)与剧情系统交互的入口。
|
||||
/// </summary>
|
||||
public class StoryManager : SerializedMonoBehaviour
|
||||
{
|
||||
public static StoryManager instance;
|
||||
|
||||
[Header("References")]
|
||||
public StoryTreeController treeController;
|
||||
public StoryUIPage storyUIPage;
|
||||
public CharacterRegistry characterRegistry;
|
||||
|
||||
[Header("Chapter Data")]
|
||||
[Tooltip("章节索引 (chapterIndex) -> 该章节的 StoryData 资产")]
|
||||
public Dictionary<string, StoryData> storyDatas = new Dictionary<string, StoryData>();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
instance = this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打开指定章节,构建其故事树。
|
||||
/// </summary>
|
||||
public void OpenChapter(string chapterIndex)
|
||||
{
|
||||
treeController.BuildChapter(chapterIndex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据章节索引获取对应的 StoryData,未找到返回 null。
|
||||
/// </summary>
|
||||
public StoryData GetStoryData(string chapterIndex)
|
||||
{
|
||||
return storyDatas.TryGetValue(chapterIndex, out StoryData data) ? data : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清除全部剧情存档(所有章节故事树 + 全局变量 + 剧情解锁 key)。
|
||||
/// </summary>
|
||||
[Button]
|
||||
public void ClearAllStorySave()
|
||||
{
|
||||
GameSaveManager.instance.StorySaveModule.ClearAllStoryline();
|
||||
GameSaveManager.instance.SongSaveModule.ClearStoryKeys();
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/NewStorySystem/Tree/StoryManager.cs.meta
Normal file
2
Assets/Scripts/NewStorySystem/Tree/StoryManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a26cc5f52c5322d46b18240eb73c0a62
|
||||
390
Assets/Scripts/NewStorySystem/Tree/StoryTreeController.cs
Normal file
390
Assets/Scripts/NewStorySystem/Tree/StoryTreeController.cs
Normal file
@@ -0,0 +1,390 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Ichni.Story.UI;
|
||||
using Sirenix.OdinInspector;
|
||||
using UniRx;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ichni.Story
|
||||
{
|
||||
/// <summary>
|
||||
/// 故事树控制器。从 <see cref="StoryData"/> 一次性全量构建所有 block 视图与连接线,
|
||||
/// 布局由每个 block 的 (gridColumn, gridRow) 静态决定;每个 block 的
|
||||
/// <see cref="StoryBlockState"/> 由"已完成集合 + 解锁条件"实时推导。
|
||||
/// 存档只保存已完成 block 的 id 集合。
|
||||
/// </summary>
|
||||
public class StoryTreeController : MonoBehaviour
|
||||
{
|
||||
[Header("UI References")]
|
||||
[Tooltip("blocks 与 connectors 共用的单一容器(即 ScrollRect 的 content,localScale 必须为 1)")]
|
||||
public RectTransform content;
|
||||
public GameObject textBlockPrefab;
|
||||
public GameObject songBlockPrefab;
|
||||
public GameObject tutorialBlockPrefab;
|
||||
public GameObject connectorPrefab;
|
||||
|
||||
[Header("Layout Settings")]
|
||||
[Tooltip("网格单元格尺寸(决定相邻列/行的步距,全局统一)")]
|
||||
public Vector2 cellSize = new Vector2(400f, 200f);
|
||||
|
||||
[Tooltip("相邻单元格之间的间隔(水平 x、垂直 y)")]
|
||||
public Vector2 cellSpacing = new Vector2(120f, 150f);
|
||||
|
||||
public float marginLeft = 50f;
|
||||
public float marginRight = 50f;
|
||||
public float marginTop = 50f;
|
||||
public float marginBottom = 50f;
|
||||
public float minContentWidth = 2560f;
|
||||
public float minContentHeight = 1440f;
|
||||
|
||||
[Header("Runtime")]
|
||||
public StoryBlockView currentBlock;
|
||||
public List<StoryBlockView> blocks = new List<StoryBlockView>();
|
||||
public List<BlockConnectorView> connectors = new List<BlockConnectorView>();
|
||||
|
||||
private string _chapterIndex;
|
||||
private StoryData _storyData;
|
||||
|
||||
/// <summary>当前已构建章节的 StoryData(供对话控制器切换 YarnProject 等使用)。</summary>
|
||||
public StoryData ActiveStoryData => _storyData;
|
||||
|
||||
/// <summary>当前已构建章节的索引。</summary>
|
||||
public string ActiveChapterIndex => _chapterIndex;
|
||||
|
||||
// 当前章节已完成的 block id 集合(状态推导的唯一进度来源)
|
||||
private readonly HashSet<string> _completed = new HashSet<string>();
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// 存档系统未就绪时跳过;正式流程由 StoryManager.OpenChapter 在进入剧情页时触发构建。
|
||||
if (GameSaveManager.instance == null || GameSaveManager.instance.StorySaveModule == null)
|
||||
return;
|
||||
|
||||
if (ChapterSelectionManager.instance != null &&
|
||||
ChapterSelectionManager.instance.currentChapter != null)
|
||||
{
|
||||
BuildChapter(ChapterSelectionManager.instance.currentChapter.chapterIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 构建指定章节的故事树:全量生成所有 block 与连接线,状态由存档中的已完成集合推导。
|
||||
/// </summary>
|
||||
public void BuildChapter(string chapterIndex)
|
||||
{
|
||||
_chapterIndex = chapterIndex;
|
||||
_storyData = StoryManager.instance != null ? StoryManager.instance.GetStoryData(chapterIndex) : null;
|
||||
|
||||
if (_storyData == null)
|
||||
{
|
||||
Debug.LogError($"[StoryTreeController] 找不到章节 '{chapterIndex}' 的 StoryData,请在 StoryManager.storyDatas 中登记。");
|
||||
return;
|
||||
}
|
||||
|
||||
if (GameSaveManager.instance == null || GameSaveManager.instance.StorySaveModule == null)
|
||||
{
|
||||
Debug.LogWarning("[StoryTreeController] 存档系统尚未就绪(GameSaveManager.StorySaveModule 为空),跳过构建。");
|
||||
return;
|
||||
}
|
||||
|
||||
ClearTree();
|
||||
|
||||
// 载入已完成集合(新章节为空)
|
||||
ChapterStorySave save = GameSaveManager.instance.StorySaveModule.GetChapter(chapterIndex);
|
||||
_completed.Clear();
|
||||
foreach (string id in save.completedBlockIds)
|
||||
_completed.Add(id);
|
||||
|
||||
// 全量生成所有 block(位置按网格换算,状态按已完成集合 + 解锁条件推导)
|
||||
foreach (StoryBlockDefinition def in _storyData.blocks)
|
||||
GenerateBlock(def);
|
||||
|
||||
// 全量生成所有连接线
|
||||
foreach (StoryBlockDefinition def in _storyData.blocks)
|
||||
foreach (string nextId in def.nextBlockIds)
|
||||
GenerateConnector(def.blockId, nextId);
|
||||
|
||||
SetUpBackground();
|
||||
|
||||
// 连线在下一帧刷新(见 RefreshConnectors):等待 Unity 完成 Canvas 布局,
|
||||
// 使 block 端口的世界坐标稳定,避免端口坐标尚未生效时塌缩到同一点。
|
||||
RefreshConnectors();
|
||||
}
|
||||
|
||||
// ── Generation ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 按定义生成一个 block 视图:位置由网格换算,状态由推导得出。
|
||||
/// </summary>
|
||||
public StoryBlockView GenerateBlock(StoryBlockDefinition def)
|
||||
{
|
||||
GameObject prefab = def.blockType switch
|
||||
{
|
||||
StoryBlockType.Text => textBlockPrefab,
|
||||
StoryBlockType.Song => songBlockPrefab,
|
||||
StoryBlockType.Tutorial => tutorialBlockPrefab,
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (prefab == null)
|
||||
{
|
||||
Debug.LogError($"[StoryTreeController] block 类型 {def.blockType} 未配置预制体。");
|
||||
return null;
|
||||
}
|
||||
|
||||
StoryBlockView view = Instantiate(prefab, content).GetComponent<StoryBlockView>();
|
||||
if (view == null)
|
||||
{
|
||||
Debug.LogError($"[StoryTreeController] 预制体 '{prefab.name}' 缺少 StoryBlockView 组件。");
|
||||
return null;
|
||||
}
|
||||
|
||||
view.Initialize(def, GetGridPosition(def), DeriveState(def));
|
||||
blocks.Add(view);
|
||||
return view;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在两个已存在的 block 之间生成连接线。
|
||||
/// </summary>
|
||||
public void GenerateConnector(string fromBlockId, string toBlockId)
|
||||
{
|
||||
StoryBlockView from = GetBlockView(fromBlockId);
|
||||
StoryBlockView to = GetBlockView(toBlockId);
|
||||
|
||||
if (from == null || to == null)
|
||||
{
|
||||
Debug.LogWarning($"[StoryTreeController] 无法连线:'{fromBlockId}' -> '{toBlockId}'(block 不存在)。");
|
||||
return;
|
||||
}
|
||||
|
||||
// 连接线与 block 同处一个容器(content);置为第一个子物体,渲染在所有 block 之后方。
|
||||
GameObject connectorObject = Instantiate(connectorPrefab, content);
|
||||
connectorObject.transform.SetAsFirstSibling();
|
||||
|
||||
BlockConnectorView connector = connectorObject.GetComponent<BlockConnectorView>();
|
||||
if (connector == null)
|
||||
{
|
||||
Debug.LogError($"[StoryTreeController] 连接线预制体 '{connectorPrefab.name}' 缺少 BlockConnectorView 组件。");
|
||||
Destroy(connectorObject);
|
||||
return;
|
||||
}
|
||||
|
||||
// 仅记录起止 block 引用;曲线在布局刷新后由 RefreshConnectors 统一计算。
|
||||
connector.startBlock = from;
|
||||
connector.endBlock = to;
|
||||
connectors.Add(connector);
|
||||
}
|
||||
|
||||
// ── State / Unlocking ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 依据"已完成集合 + 解锁条件"推导单个 block 的状态。
|
||||
/// 已完成 → Completed;否则满足解锁条件 → Current;否则 → Locked。
|
||||
/// 解锁条件为空视为无条件满足(章节起始即为 Current)。
|
||||
/// </summary>
|
||||
private StoryBlockState DeriveState(StoryBlockDefinition def)
|
||||
{
|
||||
if (_completed.Contains(def.blockId))
|
||||
return StoryBlockState.Completed;
|
||||
|
||||
if (def.unlockCondition.IsSatisfied(GetVariable, IsBlockCompleted))
|
||||
return StoryBlockState.Current;
|
||||
|
||||
return StoryBlockState.Locked;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重新推导并应用所有 block 的状态(在完成某 block 或变量变化后调用)。
|
||||
/// </summary>
|
||||
public void RefreshAllStates()
|
||||
{
|
||||
foreach (StoryBlockView view in blocks)
|
||||
{
|
||||
StoryBlockDefinition def = _storyData.GetBlock(view.blockId);
|
||||
if (def == null) continue;
|
||||
|
||||
view.ApplyState(DeriveState(def));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 标记某 block 完成,推导重算并保存。对话系统(阶段 2)在对话结束时调用。
|
||||
/// </summary>
|
||||
public void OnBlockCompleted(string blockId)
|
||||
{
|
||||
if (!_completed.Add(blockId))
|
||||
return; // 已完成,无需处理
|
||||
|
||||
RefreshAllStates();
|
||||
SaveChapter();
|
||||
}
|
||||
|
||||
private bool IsBlockCompleted(string blockId) => _completed.Contains(blockId);
|
||||
|
||||
// 阶段 2 将改由 StoryVariableStorage 提供;此处从全局变量存档读取作占位
|
||||
private int GetVariable(string variableName)
|
||||
{
|
||||
StoryVariablesSave vars = GameSaveManager.instance.StorySaveModule.variables;
|
||||
|
||||
if (vars.floatVariables.TryGetValue(variableName, out float f))
|
||||
return Mathf.RoundToInt(f);
|
||||
|
||||
if (vars.boolVariables.TryGetValue(variableName, out bool b))
|
||||
return b ? 1 : 0;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Persistence ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 保存当前章节进度(已完成 block 集合)。选项由对话系统维护、不在此覆盖。
|
||||
/// </summary>
|
||||
[Button]
|
||||
public void SaveChapter()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_chapterIndex)) return;
|
||||
|
||||
ChapterStorySave save = GameSaveManager.instance.StorySaveModule.GetChapter(_chapterIndex);
|
||||
save.chapterIndex = _chapterIndex;
|
||||
save.completedBlockIds = _completed.ToList();
|
||||
|
||||
GameSaveManager.instance.StorySaveModule.SaveChapter(save);
|
||||
}
|
||||
|
||||
// ── Layout ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 将 block 的网格坐标 (gridColumn, gridRow) 换算为 BlockContainer 中的 anchoredPosition。
|
||||
/// 约定左侧中线为原点:列向右为正,行向下为正、向上为负(anchoredPosition.y 取负)。
|
||||
/// gridColumn / gridRow 支持负数与小数,可用于微调 block 位置。
|
||||
/// </summary>
|
||||
private Vector2 GetGridPosition(StoryBlockDefinition def)
|
||||
{
|
||||
float x = marginLeft + def.gridColumn * (cellSize.x + cellSpacing.x);
|
||||
float y = -def.gridRow * (cellSize.y + cellSpacing.y);
|
||||
return new Vector2(x, y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据 block 分布计算并设置 content(BlockContainer) 尺寸。
|
||||
/// block 采用左中 pivot:anchoredPosition.x 为左边缘,y 相对垂直中线(上正下负)。
|
||||
/// </summary>
|
||||
public void SetUpBackground()
|
||||
{
|
||||
if (blocks.Count == 0)
|
||||
{
|
||||
content.sizeDelta = new Vector2(minContentWidth, minContentHeight);
|
||||
return;
|
||||
}
|
||||
|
||||
float maxRight = 0f;
|
||||
float maxVertical = 0f; // 距垂直中线的最大延伸(上下取较大者,用于对称扩展 content 高度)
|
||||
|
||||
foreach (StoryBlockView block in blocks)
|
||||
{
|
||||
RectTransform rect = block.blockRect;
|
||||
Vector2 pos = rect.anchoredPosition;
|
||||
Vector2 size = rect.sizeDelta;
|
||||
|
||||
float rightEdge = pos.x + size.x;
|
||||
float topExtent = pos.y + size.y * 0.5f;
|
||||
float bottomExtent = -(pos.y - size.y * 0.5f);
|
||||
|
||||
if (rightEdge > maxRight) maxRight = rightEdge;
|
||||
if (topExtent > maxVertical) maxVertical = topExtent;
|
||||
if (bottomExtent > maxVertical) maxVertical = bottomExtent;
|
||||
}
|
||||
|
||||
float width = Mathf.Max(maxRight + marginRight, minContentWidth);
|
||||
float height = Mathf.Max(maxVertical * 2f + marginTop + marginBottom, minContentHeight);
|
||||
|
||||
content.sizeDelta = new Vector2(width, height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重算所有连接线曲线(在 block 位置确定后调用)。
|
||||
/// </summary>
|
||||
public void RefreshConnectors()
|
||||
{
|
||||
Observable.NextFrame().Subscribe(_ =>
|
||||
{
|
||||
foreach (BlockConnectorView connector in connectors)
|
||||
{
|
||||
connector.SetCurve();
|
||||
}
|
||||
}).AddTo(this);
|
||||
}
|
||||
|
||||
// ── Helpers / Debug ─────────────────────────────────────────────────────
|
||||
|
||||
public StoryBlockView GetBlockView(string blockId) =>
|
||||
blocks.FirstOrDefault(b => b.blockId == blockId);
|
||||
|
||||
private void ClearTree()
|
||||
{
|
||||
foreach (StoryBlockView block in blocks)
|
||||
if (block != null) Destroy(block.gameObject);
|
||||
blocks.Clear();
|
||||
|
||||
foreach (BlockConnectorView connector in connectors)
|
||||
if (connector != null) Destroy(connector.gameObject);
|
||||
connectors.Clear();
|
||||
|
||||
if (content != null)
|
||||
content.sizeDelta = Vector2.zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 编辑器测试用:以当前章节重新构建故事树。
|
||||
/// </summary>
|
||||
[Button]
|
||||
public void DebugBuildCurrentChapter()
|
||||
{
|
||||
if (ChapterSelectionManager.instance != null &&
|
||||
ChapterSelectionManager.instance.currentChapter != null)
|
||||
{
|
||||
BuildChapter(ChapterSelectionManager.instance.currentChapter.chapterIndex);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning("[StoryTreeController] 当前没有选中的章节。");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 编辑器测试用:模拟完成当前选中的 block(验证解锁重算与存档),阶段 2 前用于替代对话。
|
||||
/// </summary>
|
||||
[Button]
|
||||
public void DebugCompleteCurrentBlock()
|
||||
{
|
||||
if (currentBlock == null)
|
||||
{
|
||||
Debug.LogWarning("[StoryTreeController] currentBlock 为空,请先点击一个 block。");
|
||||
return;
|
||||
}
|
||||
|
||||
OnBlockCompleted(currentBlock.blockId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 编辑器测试用:清空当前章节进度并重建(全部回到未完成的初始推导状态)。
|
||||
/// </summary>
|
||||
[Button]
|
||||
public void DebugResetChapterProgress()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_chapterIndex))
|
||||
{
|
||||
Debug.LogWarning("[StoryTreeController] 尚未构建任何章节。");
|
||||
return;
|
||||
}
|
||||
|
||||
_completed.Clear();
|
||||
SaveChapter();
|
||||
BuildChapter(_chapterIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7b00483ab980b3248a62618eeb0bcb4b
|
||||
95
Assets/Scripts/NewStorySystem/Tree/TextBlockView.cs
Normal file
95
Assets/Scripts/NewStorySystem/Tree/TextBlockView.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
using System.Collections.Generic;
|
||||
using Ichni.Story.Dialogue;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ichni.Story.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 文本块视图。点击后(阶段 2)将通过 Yarn Spinner 进入对应节点的对话。
|
||||
/// </summary>
|
||||
public class TextBlockView : StoryBlockView
|
||||
{
|
||||
[Header("Text Block")]
|
||||
public TMP_Text storyIdText;
|
||||
public TMP_Text titleText;
|
||||
|
||||
[Tooltip("三种状态对应的背景预制体,生成 block 时按当前状态实例化为第一个子物体")]
|
||||
public Dictionary<StoryBlockState, GameObject> blockVisuals = new Dictionary<StoryBlockState, GameObject>();
|
||||
|
||||
/// <summary>该文本块对应的 Yarn 节点名。</summary>
|
||||
public string YarnNodeName { get; private set; }
|
||||
|
||||
// 当前已实例化的状态背景对象;状态切换时销毁并替换
|
||||
private GameObject _currentVisual;
|
||||
|
||||
public override void Initialize(StoryBlockDefinition def, Vector2 position, StoryBlockState blockState)
|
||||
{
|
||||
// 注意:base.Initialize 末尾会调用 ApplyState(state),届时已生成对应背景
|
||||
base.Initialize(def, position, blockState);
|
||||
|
||||
YarnNodeName = def.yarnNodeName;
|
||||
|
||||
if (storyIdText != null)
|
||||
storyIdText.text = def.blockId;
|
||||
|
||||
// 阶段 5 将接入 Unity Localization;此处占位直接显示 titleKey
|
||||
if (titleText != null)
|
||||
titleText.text = def.titleKey;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 应用状态:在基类逻辑(按钮可交互性)之外,切换到对应状态的背景视觉。
|
||||
/// </summary>
|
||||
public override void ApplyState(StoryBlockState newState)
|
||||
{
|
||||
base.ApplyState(newState);
|
||||
UpdateVisual(newState);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按当前状态实例化对应的背景预制体,作为第一个子物体(位于文本/端口/按钮之下),
|
||||
/// 并拉伸铺满整个 block。状态切换时先销毁旧背景。
|
||||
/// </summary>
|
||||
private void UpdateVisual(StoryBlockState newState)
|
||||
{
|
||||
if (_currentVisual != null)
|
||||
{
|
||||
Destroy(_currentVisual);
|
||||
_currentVisual = null;
|
||||
}
|
||||
|
||||
if (blockVisuals == null ||
|
||||
!blockVisuals.TryGetValue(newState, out GameObject prefab) ||
|
||||
prefab == null)
|
||||
{
|
||||
Debug.LogWarning($"[TextBlockView] block '{blockId}' 缺少状态 {newState} 的背景预制体(blockVisuals 未配置)。");
|
||||
return;
|
||||
}
|
||||
|
||||
_currentVisual = Instantiate(prefab, blockRect);
|
||||
_currentVisual.transform.SetAsFirstSibling();
|
||||
|
||||
// 背景铺满整个 block
|
||||
if (_currentVisual.transform is RectTransform visualRect)
|
||||
{
|
||||
visualRect.anchorMin = Vector2.zero;
|
||||
visualRect.anchorMax = Vector2.one;
|
||||
visualRect.offsetMin = Vector2.zero;
|
||||
visualRect.offsetMax = Vector2.zero;
|
||||
visualRect.localScale = Vector3.one;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnClicked()
|
||||
{
|
||||
if (StoryDialogueController.instance == null)
|
||||
{
|
||||
Debug.LogWarning($"[TextBlockView] 场景中缺少 StoryDialogueController,无法播放 block '{blockId}' 的对话。");
|
||||
return;
|
||||
}
|
||||
|
||||
StoryDialogueController.instance.PlayBlock(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/NewStorySystem/Tree/TextBlockView.cs.meta
Normal file
2
Assets/Scripts/NewStorySystem/Tree/TextBlockView.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 28d84946aa995484dbcbe24b7aab1652
|
||||
28
Assets/Scripts/NewStorySystem/Tree/TutorialBlockView.cs
Normal file
28
Assets/Scripts/NewStorySystem/Tree/TutorialBlockView.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ichni.Story.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 教程块视图(占位)。点击后将进入该章节的教程游玩,后续阶段接入。
|
||||
/// </summary>
|
||||
public class TutorialBlockView : StoryBlockView
|
||||
{
|
||||
[Header("Tutorial Block")]
|
||||
public TMP_Text tutorialNameText;
|
||||
|
||||
public override void Initialize(StoryBlockDefinition def, Vector2 position, StoryBlockState blockState)
|
||||
{
|
||||
base.Initialize(def, position, blockState);
|
||||
|
||||
if (tutorialNameText != null)
|
||||
tutorialNameText.text = def.tutorialName;
|
||||
}
|
||||
|
||||
protected override void OnClicked()
|
||||
{
|
||||
// 占位:后续接入教程进入流程
|
||||
Debug.Log($"[TutorialBlockView] 点击教程块 '{blockId}' (tutorial='{definition.tutorialName}')。占位实现,教程进入将在后续阶段接入。");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 66b3d929576d6c54ca51069ae0e313e5
|
||||
Reference in New Issue
Block a user