feat: add TreeFlow horizontal flowchart, replace vertical tree in StoryGallery

This commit is contained in:
2026-06-11 21:51:47 +08:00
parent 73ac54fe95
commit 337221ba87
5 changed files with 413 additions and 1250 deletions

52
CHANGELOG.md Normal file
View File

@@ -0,0 +1,52 @@
# 更新日志
## 2026-06-09
| P | 功能 | 状态 |
|---|------|:--:|
| P17 | 主菜单统一化 — 游戏入口整理 | ✅ |
| P16 | 可访问性设置 — 字幕 + QTE 辅助 + 防误触 + 暂停 | ✅ |
| P15 | 结局画廊 + 章节回顾 — 列表 + 完成度百分比 + 条件提示 | ✅ |
| P14 | 成就系统 — 纯变量检测 + 单一检查点 + Toast 队列 | ✅ |
| P13 | 关键选择提示 — 选前标识 + 选后浮现 | ✅ |
| P12 | ~~场景过渡特效~~ | 废弃 |
| P11 | 完整 i18n — 字幕 + UI 国际化,自制 useI18n | ✅ |
| P10a | 键盘导航 — 方向键+确认键驱动全流程 | ✅ |
| P9 | 跳过已看 + 倍速播放 | ✅ |
| P8 | 章节选择 — 到达即解锁,主菜单+通关后跳转 | ✅ |
## 2026-06-10
| P | 功能 | 状态 |
|---|------|:--:|
| P23 | 玩家树可视化 — 缩进树取代平铺列表 | ✅ |
| P22 | 故事进度总览 — 章节选择 + 画廊合并 | ✅ |
| P21 | 菜单系统重构 — 主菜单 + 暂停菜单 + 设置 + 游戏内顶栏 | ✅ |
| P20 | 开场流程 — 启动视频 + 菜单背景视频 | ✅ |
| P19 | 制作者工具链 — HTML / macOS / Windows 打包 | ✅ |
## 2026-06-08
| P | 功能 | 状态 |
|---|------|:--:|
| P7 | 全屏模式 — 沉浸式浏览器体验 | ✅ |
| P6 | 独立背景音乐 + Ducking — 画面循环不打断 BGM | ✅ |
| P5 | 选择等待循环 — 单文件内时间锚点无缝循环 | ✅ |
| P4 | 视频/图片热点 — 点击画面区域触发分支 | ✅ |
## 2026-06-07
| P | 功能 | 状态 |
|---|------|:--:|
| P3 | 编辑器 — 可视化剧情编辑 | ✅ |
| P2 | QTE + 字幕 + 多存档槽 | ✅ |
| P1 | 无缝切换 + 条件分支 + 存档 | ✅ |
| P0 | MVP — 最小可玩原型 | ✅ |
## 废弃项
| P | 功能 | 原因 |
|---|------|------|
| P12 | 场景过渡特效 | 引擎 A/B cross-fade 已覆盖技术缓冲需求,艺术转场由剪辑师处理 |
| ~~P16~~ | 自适应码率 | 离线应用模式不需要,移入 FUTURE.md |
| P14 | 沉浸感提升 | 功能拆分到其他 P 或远期 |

1261
ROADMAP.md

File diff suppressed because it is too large Load Diff

43
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,43 @@
# 架构决策记录
## 1. 引擎与 UI 分离
`engine/` 下纯 TS 类,不 import Vue。UI 层通过 composables 桥接。
## 2. A/B 双缓冲
两个 `<video>` 元素轮换一个播放时另一个预加载候选视频。300ms CSS opacity 交叉淡化。
## 3. JSON 驱动
所有剧情数据放在 JSON 中,编辑器本质是 JSON 的可视化读写工具。
## 4. IndexedDB 存档
比 localStorage 容量大,可存储截屏缩略图。多槽位支持,跨会话持久化。
## 5. 故事图与玩家树
创作端的 JSON 是有向图Graph玩家端展示的是严格树Tree
- 数据层:故事图 + 玩家存档
- 渲染层:`buildPlayerTree()` 将图投影为树
- 汇聚节点在树上复制展示,每条路径独立
- 回环用 `pathSet` 精确剪枝 + `depth > 10` 兜底
详见 [ROADMAP.md P23](../ROADMAP.md#p23-玩家树可视化--缩进树取代平铺列表)
## 6. 成就 — 纯变量单一检查点
所有成就通过变量检测,在 `StateManager.apply` 末尾单一检查点触发。事件型成就改写为变量型QTE 成功 = 在 effects 中 `set` 标记变量)。
## 7. i18n — 双文件分层
- `src/locales/` — UI 文本(静态 import构建时打包
- `public/locales/` — 故事文本fetch 动态加载)
`useI18n.t()` 优先查故事消息fallback 到 UI 消息。
## 8. 章节 — 单文件共享场景
所有场景在一个 JSON 中,章节用 `startScene` 标记边界。场景可跨章引用以保持叙事灵活性,但建议制作者保持各章 BFS 独立。

View File

@@ -2,7 +2,7 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import type { ChapterInfo, SceneNode, EndingDef, PlayerTreeNode } from '@engine/types' import type { ChapterInfo, SceneNode, EndingDef, PlayerTreeNode } from '@engine/types'
import { useI18n } from '@/composables/useI18n' import { useI18n } from '@/composables/useI18n'
import TreeNode from './TreeNode.vue' import TreeFlow from './TreeFlow.vue'
const { t } = useI18n() const { t } = useI18n()
@@ -267,10 +267,9 @@ const totalChaptersComplete = computed(() => {
<div class="detail-tree"> <div class="detail-tree">
<div class="section-label">故事树</div> <div class="section-label">故事树</div>
<div class="tree-container"> <div class="tree-container">
<TreeNode <TreeFlow
v-if="buildTreeForChapter(selectedChapter.id)" v-if="buildTreeForChapter(selectedChapter.id)"
:node="buildTreeForChapter(selectedChapter.id)!" :node="buildTreeForChapter(selectedChapter.id)!"
:depth="0"
/> />
<div v-else class="tree-empty">暂无数据</div> <div v-else class="tree-empty">暂无数据</div>
</div> </div>
@@ -620,14 +619,14 @@ const totalChaptersComplete = computed(() => {
flex: 1; flex: 1;
padding: 12px 0; padding: 12px 0;
border-top: 1px solid rgba(255,255,255,0.04); border-top: 1px solid rgba(255,255,255,0.04);
min-height: 100px;
} }
.tree-container { .tree-container {
background: rgba(0,0,0,0.2); background: rgba(0,0,0,0.2);
border: 1px solid rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.04);
border-radius: 6px; border-radius: 6px;
padding: 12px 16px; padding: 0;
max-height: 240px;
overflow: auto; overflow: auto;
} }

298
src/components/TreeFlow.vue Normal file
View File

@@ -0,0 +1,298 @@
<script setup lang="ts">
import { computed, onMounted, ref, nextTick } from 'vue'
import type { PlayerTreeNode } from '@engine/types'
import dagre from 'dagre'
const props = defineProps<{
node: PlayerTreeNode | null
}>()
interface FlowNode {
id: string
label: string
visited: boolean
isMystery: boolean
locked: boolean
lockHint?: string
x: number
y: number
w: number
h: number
}
interface FlowEdge {
from: string
to: string
visited: boolean
points: { x: number; y: number }[]
}
const nodes = ref<FlowNode[]>([])
const edges = ref<FlowEdge[]>([])
const containerW = ref(800)
const containerH = ref(400)
function buildFlow(root: PlayerTreeNode) {
const dagreNodes: { id: string; parent: string | null; label: string; visited: boolean; isMystery: boolean; locked: boolean; lockHint?: string }[] = []
const dagreEdges: { from: string; to: string; visited: boolean }[] = []
function walk(node: PlayerTreeNode, parentId: string | null) {
if (node.visited) {
dagreNodes.push({
id: node.sceneId,
parent: parentId,
label: node.label,
visited: true,
isMystery: false,
locked: node.locked,
lockHint: node.lockHint,
})
if (parentId) {
dagreEdges.push({ from: parentId, to: node.sceneId, visited: true })
}
const unvisited: PlayerTreeNode[] = []
for (const child of node.children) {
if (child.visited) {
walk(child, node.sceneId)
} else {
unvisited.push(child)
}
}
if (unvisited.length > 0) {
const mysteryId = `${node.sceneId}__mystery`
dagreNodes.push({
id: mysteryId,
parent: node.sceneId,
label: '? ?',
visited: false,
isMystery: true,
locked: true,
})
dagreEdges.push({ from: node.sceneId, to: mysteryId, visited: false })
}
}
}
walk(root, null)
if (dagreNodes.length === 0) return
const g = new dagre.graphlib.Graph()
g.setGraph({ rankdir: 'LR', nodesep: 20, ranksep: 60, marginx: 20, marginy: 20 })
g.setDefaultEdgeLabel(() => ({}))
const nodeW = 120
const nodeH = 44
for (const n of dagreNodes) {
g.setNode(n.id, { width: nodeW, height: nodeH })
}
for (const e of dagreEdges) {
g.setEdge(e.from, e.to)
}
dagre.layout(g)
const resultNodes: FlowNode[] = dagreNodes.map((n) => {
const pos = g.node(n.id)
return {
id: n.id,
label: n.label,
visited: n.visited,
isMystery: n.isMystery,
locked: n.locked,
lockHint: n.lockHint,
x: pos.x - nodeW / 2,
y: pos.y - nodeH / 2,
w: nodeW,
h: nodeH,
}
})
const resultEdges: FlowEdge[] = dagreEdges.map((e) => {
const edge = g.edge(e.from, e.to)
return {
from: e.from,
to: e.to,
visited: e.visited,
points: edge.points || [],
}
})
nodes.value = resultNodes
edges.value = resultEdges
const maxX = resultNodes.reduce((m, n) => Math.max(m, n.x + n.w), 0) + 40
const maxY = resultNodes.reduce((m, n) => Math.max(m, n.y + n.h), 0) + 40
containerW.value = Math.max(400, maxX)
containerH.value = Math.max(100, maxY)
}
function buildFromRoot() {
if (props.node) buildFlow(props.node)
}
onMounted(() => {
nextTick(buildFromRoot)
})
function edgePath(e: FlowEdge): string {
if (e.points.length === 0) return ''
let d = `M ${e.points[0].x} ${e.points[0].y}`
for (let i = 1; i < e.points.length; i++) {
d += ` L ${e.points[i].x} ${e.points[i].y}`
}
return d
}
const svgW = computed(() => containerW.value)
const svgH = computed(() => containerH.value)
</script>
<template>
<div class="tree-flow">
<div v-if="!node || nodes.length === 0" class="flow-empty">暂无故事数据</div>
<svg
v-else
class="flow-svg"
:viewBox="`0 0 ${svgW} ${svgH}`"
:width="svgW"
:height="svgH"
>
<g
v-for="edge in edges"
:key="edge.from + '-' + edge.to"
class="flow-edge-group"
>
<path
:d="edgePath(edge)"
fill="none"
:stroke="edge.visited ? '#c9a84c' : '#333'"
:stroke-width="edge.visited ? 1.5 : 1"
:stroke-dasharray="edge.visited ? '' : '4 3'"
/>
<polygon
v-if="edge.points.length >= 2"
:points="(() => {
const pts = edge.points
const last = pts[pts.length - 1]
const prev = pts[pts.length - 2]
const angle = Math.atan2(last.y - prev.y, last.x - prev.x)
const s = 5
return [
`${last.x + Math.cos(angle) * s} ${last.y + Math.sin(angle) * s}`,
`${last.x + Math.cos(angle + 2.5) * s} ${last.y + Math.sin(angle + 2.5) * s}`,
`${last.x + Math.cos(angle - 2.5) * s} ${last.y + Math.sin(angle - 2.5) * s}`,
].join(' ')
})()"
:fill="edge.visited ? '#c9a84c' : '#333'"
/>
</g>
</svg>
<div class="flow-nodes" :style="{ width: svgW + 'px', height: svgH + 'px' }">
<div
v-for="n in nodes"
:key="n.id"
class="flow-node"
:class="{ visited: n.visited, mystery: n.isMystery, locked: n.locked }"
:style="{ left: n.x + 'px', top: n.y + 'px', width: n.w + 'px', height: n.h + 'px' }"
:title="n.lockHint || ''"
>
<span class="node-icon">{{ n.visited ? '✦' : n.isMystery ? '?' : '⬜' }}</span>
<span class="node-label">{{ n.label }}</span>
</div>
</div>
</div>
</template>
<style scoped>
.tree-flow {
position: relative;
overflow: auto;
max-height: 320px;
}
.flow-svg {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
}
.flow-nodes {
position: relative;
}
.flow-node {
position: absolute;
display: flex;
align-items: center;
gap: 6px;
padding: 0 10px;
border-radius: 4px;
font-size: 12px;
transition: all 0.15s;
overflow: hidden;
}
.flow-node.visited {
background: rgba(201, 168, 76, 0.12);
border: 1px solid rgba(201, 168, 76, 0.3);
}
.flow-node.locked {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.flow-node.mystery {
background: rgba(255, 255, 255, 0.02);
border: 1px dashed rgba(255, 255, 255, 0.12);
justify-content: center;
}
.node-icon {
font-size: 12px;
flex-shrink: 0;
}
.flow-node.visited .node-icon {
color: #c9a84c;
}
.flow-node.locked .node-icon,
.flow-node.mystery .node-icon {
color: #444;
}
.node-label {
color: #ddd;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.flow-node.locked .node-label {
color: #555;
}
.flow-node.mystery .node-label {
color: #444;
}
.flow-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100px;
font-size: 13px;
color: #444;
}
.flow-edge-group {
opacity: 0.6;
}
</style>