feat: add scene thumbnails to TreeFlow nodes with auto-generated demo thumbs

This commit is contained in:
2026-06-12 12:08:39 +08:00
parent ac0a6e2cd6
commit 9baa7b5ab3
14 changed files with 230 additions and 58 deletions

View File

@@ -3,6 +3,7 @@ export interface SceneNode {
type?: 'video' | 'image' type?: 'video' | 'image'
videoUrl: string videoUrl: string
imageUrl?: string imageUrl?: string
thumbnail?: string
contentSize?: { w: number; h: number } contentSize?: { w: number; h: number }
subtitleUrl?: string subtitleUrl?: string
subtitles?: Record<string, string> subtitles?: Record<string, string>

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
public/demo/intro/thumb.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
public/demo/stay/thumb.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -2,7 +2,11 @@
"assetBase": "demo/", "assetBase": "demo/",
"locales": { "locales": {
"path": "locales/", "path": "locales/",
"languages": ["zh", "en", "ja"] "languages": [
"zh",
"en",
"ja"
]
}, },
"startScene": "intro", "startScene": "intro",
"variables": { "variables": {
@@ -21,7 +25,11 @@
"descKey": "achievement.qte_master.desc", "descKey": "achievement.qte_master.desc",
"icon": "", "icon": "",
"hidden": false, "hidden": false,
"condition": { "variable": "qte_succeeded", "op": ">=", "value": 1 } "condition": {
"variable": "qte_succeeded",
"op": ">=",
"value": 1
}
}, },
{ {
"id": "explorer", "id": "explorer",
@@ -31,7 +39,11 @@
"descKey": "achievement.explorer.desc", "descKey": "achievement.explorer.desc",
"icon": "", "icon": "",
"hidden": false, "hidden": false,
"condition": { "variable": "investigation", "op": ">=", "value": 2 } "condition": {
"variable": "investigation",
"op": ">=",
"value": 2
}
}, },
{ {
"id": "game_finished", "id": "game_finished",
@@ -41,13 +53,38 @@
"descKey": "achievement.game_finished.desc", "descKey": "achievement.game_finished.desc",
"icon": "", "icon": "",
"hidden": false, "hidden": false,
"condition": { "variable": "completed_game", "op": ">=", "value": 1 } "condition": {
"variable": "completed_game",
"op": ">=",
"value": 1
}
} }
], ],
"endings": [ "endings": [
{ "id": "trust_end", "label": "信任的伙伴", "labelKey": "ending.trust_end", "sceneId": "trust_ending", "chapterId": "ch1", "thumbnail": "ui/images/end_trust.jpg" }, {
{ "id": "alone_end", "label": "独行之路", "labelKey": "ending.alone_end", "sceneId": "alone_ending", "chapterId": "ch1", "thumbnail": "ui/images/end_alone.jpg" }, "id": "trust_end",
{ "id": "continue_end", "label": "继续前行", "labelKey": "ending.continue_end", "sceneId": "continue_ending", "chapterId": "ch3", "thumbnail": "ui/images/end_continue.jpg" } "label": "信任的伙伴",
"labelKey": "ending.trust_end",
"sceneId": "trust_ending",
"chapterId": "ch1",
"thumbnail": "ui/images/end_trust.jpg"
},
{
"id": "alone_end",
"label": "独行之路",
"labelKey": "ending.alone_end",
"sceneId": "alone_ending",
"chapterId": "ch1",
"thumbnail": "ui/images/end_alone.jpg"
},
{
"id": "continue_end",
"label": "继续前行",
"labelKey": "ending.continue_end",
"sceneId": "continue_ending",
"chapterId": "ch3",
"thumbnail": "ui/images/end_continue.jpg"
}
], ],
"chapters": [ "chapters": [
{ {
@@ -56,7 +93,11 @@
"labelKey": "chapter.ch1", "labelKey": "chapter.ch1",
"startScene": "intro", "startScene": "intro",
"thumbnail": "ui/images/ch1.jpg", "thumbnail": "ui/images/ch1.jpg",
"defaultVariables": { "trust": 50, "courage": 0, "investigation": 0 } "defaultVariables": {
"trust": 50,
"courage": 0,
"investigation": 0
}
}, },
{ {
"id": "ch2", "id": "ch2",
@@ -64,7 +105,11 @@
"labelKey": "chapter.ch2", "labelKey": "chapter.ch2",
"startScene": "desk_detail", "startScene": "desk_detail",
"thumbnail": "ui/images/ch2.jpg", "thumbnail": "ui/images/ch2.jpg",
"defaultVariables": { "trust": 60, "courage": 10, "investigation": 1 } "defaultVariables": {
"trust": 60,
"courage": 10,
"investigation": 1
}
}, },
{ {
"id": "ch3", "id": "ch3",
@@ -72,7 +117,11 @@
"labelKey": "chapter.ch3", "labelKey": "chapter.ch3",
"startScene": "qte_success", "startScene": "qte_success",
"thumbnail": "ui/images/ch3.jpg", "thumbnail": "ui/images/ch3.jpg",
"defaultVariables": { "trust": 70, "courage": 20, "investigation": 2 } "defaultVariables": {
"trust": 70,
"courage": 20,
"investigation": 2
}
} }
], ],
"scenes": { "scenes": {
@@ -95,7 +144,11 @@
"textKey": "intro.choice.left_door", "textKey": "intro.choice.left_door",
"targetScene": "left_door", "targetScene": "left_door",
"effects": [ "effects": [
{ "type": "add", "target": "courage", "value": 10 } {
"type": "add",
"target": "courage",
"value": 10
}
] ]
}, },
{ {
@@ -103,7 +156,11 @@
"textKey": "intro.choice.right_door", "textKey": "intro.choice.right_door",
"targetScene": "right_door", "targetScene": "right_door",
"effects": [ "effects": [
{ "type": "add", "target": "courage", "value": -5 } {
"type": "add",
"target": "courage",
"value": -5
}
] ]
}, },
{ {
@@ -116,14 +173,18 @@
"textKey": "intro.choice.stay", "textKey": "intro.choice.stay",
"targetScene": "stay" "targetScene": "stay"
} }
] ],
"thumbnail": "intro/thumb.jpg"
}, },
"investigation_site": { "investigation_site": {
"id": "investigation_site", "id": "investigation_site",
"type": "image", "type": "image",
"videoUrl": "", "videoUrl": "",
"imageUrl": "investigation_site/investigation_scene.jpg", "imageUrl": "investigation_site/investigation_scene.jpg",
"contentSize": { "w": 1280, "h": 720 }, "contentSize": {
"w": 1280,
"h": 720
},
"subtitleUrl": "investigation_site/investigation.vtt", "subtitleUrl": "investigation_site/investigation.vtt",
"subtitles": { "subtitles": {
"zh": "investigation_site/investigation.vtt", "zh": "investigation_site/investigation.vtt",
@@ -136,10 +197,20 @@
"label": "查看书桌", "label": "查看书桌",
"labelKey": "investigation_site.hotspot.desk", "labelKey": "investigation_site.hotspot.desk",
"targetScene": "desk_detail", "targetScene": "desk_detail",
"x": 154, "y": 144, "width": 230, "height": 101, "x": 154,
"y": 144,
"width": 230,
"height": 101,
"effects": [ "effects": [
{ "type": "add", "target": "investigation", "value": 1 }, {
{ "type": "toggleFlag", "target": "checked_desk" } "type": "add",
"target": "investigation",
"value": 1
},
{
"type": "toggleFlag",
"target": "checked_desk"
}
] ]
}, },
{ {
@@ -147,56 +218,86 @@
"label": "查看窗户", "label": "查看窗户",
"labelKey": "investigation_site.hotspot.window", "labelKey": "investigation_site.hotspot.window",
"targetScene": "corridor", "targetScene": "corridor",
"x": 602, "y": 43, "width": 192, "height": 202 "x": 602,
"y": 43,
"width": 192,
"height": 202
}, },
{ {
"id": "hs_closet", "id": "hs_closet",
"label": "检查衣柜", "label": "检查衣柜",
"labelKey": "investigation_site.hotspot.closet", "labelKey": "investigation_site.hotspot.closet",
"targetScene": "desk_detail", "targetScene": "desk_detail",
"x": 422, "y": 346, "width": 128, "height": 187, "x": 422,
"y": 346,
"width": 128,
"height": 187,
"conditions": [ "conditions": [
{ "variable": "investigation", "op": ">=", "value": 1 } {
"variable": "investigation",
"op": ">=",
"value": 1
}
], ],
"effects": [ "effects": [
{ "type": "add", "target": "investigation", "value": 1 } {
"type": "add",
"target": "investigation",
"value": 1
}
] ]
} }
], ],
"choices": [ "choices": []
]
}, },
"corridor": { "corridor": {
"id": "corridor", "id": "corridor",
"videoUrl": "corridor/corridor.mp4", "videoUrl": "corridor/corridor.mp4",
"contentSize": { "w": 1280, "h": 720 }, "contentSize": {
"w": 1280,
"h": 720
},
"skippable": false, "skippable": false,
"hotspots": [ "hotspots": [
{ {
"id": "hs_left", "id": "hs_left",
"label": "走向左边通道", "label": "走向左边通道",
"targetScene": "left_door", "targetScene": "left_door",
"x": 26, "y": 216, "width": 384, "height": 324, "x": 26,
"y": 216,
"width": 384,
"height": 324,
"showAt": 1.5, "showAt": 1.5,
"effects": [ "effects": [
{ "type": "add", "target": "courage", "value": 5 } {
"type": "add",
"target": "courage",
"value": 5
}
] ]
}, },
{ {
"id": "hs_center", "id": "hs_center",
"label": "走向中间通道", "label": "走向中间通道",
"targetScene": "trust_ending", "targetScene": "trust_ending",
"x": 422, "y": 180, "width": 435, "height": 396, "x": 422,
"y": 180,
"width": 435,
"height": 396,
"showAt": 3.0 "showAt": 3.0
}, },
{ {
"id": "hs_right", "id": "hs_right",
"label": "走向右边通道", "label": "走向右边通道",
"targetScene": "alone_ending", "targetScene": "alone_ending",
"x": 870, "y": 216, "width": 384, "height": 324, "x": 870,
"y": 216,
"width": 384,
"height": 324,
"showAt": 5.0 "showAt": 5.0
} }
] ],
"thumbnail": "corridor/thumb.jpg"
}, },
"left_door": { "left_door": {
"id": "left_door", "id": "left_door",
@@ -215,7 +316,11 @@
"promptKey": "left_door.prompt.handshake", "promptKey": "left_door.prompt.handshake",
"targetScene": "trust_ending", "targetScene": "trust_ending",
"effects": [ "effects": [
{ "type": "add", "target": "trust", "value": 30 } {
"type": "add",
"target": "trust",
"value": 30
}
] ]
}, },
{ {
@@ -223,7 +328,8 @@
"textKey": "left_door.choice.reject", "textKey": "left_door.choice.reject",
"targetScene": "alone_ending" "targetScene": "alone_ending"
} }
] ],
"thumbnail": "left_door/thumb.jpg"
}, },
"right_door": { "right_door": {
"id": "right_door", "id": "right_door",
@@ -237,19 +343,39 @@
"triggerTime": 1.0, "triggerTime": 1.0,
"prompt": "躲避飞来的石块!", "prompt": "躲避飞来的石块!",
"promptKey": "right_door.qte.dodge", "promptKey": "right_door.qte.dodge",
"keys": ["ArrowLeft", "ArrowRight", "a", "d"], "keys": [
"ArrowLeft",
"ArrowRight",
"a",
"d"
],
"timeLimit": 3.0, "timeLimit": 3.0,
"successScene": "qte_success", "successScene": "qte_success",
"failScene": "qte_fail", "failScene": "qte_fail",
"effects": { "effects": {
"success": [ "success": [
{ "type": "add", "target": "courage", "value": 15 }, {
{ "type": "set", "target": "qte_succeeded", "value": 1 } "type": "add",
"target": "courage",
"value": 15
},
{
"type": "set",
"target": "qte_succeeded",
"value": 1
}
], ],
"fail": [{ "type": "add", "target": "trust", "value": -20 }] "fail": [
{
"type": "add",
"target": "trust",
"value": -20
} }
]
} }
}, },
"thumbnail": "right_door/thumb.jpg"
},
"qte_success": { "qte_success": {
"id": "qte_success", "id": "qte_success",
"videoUrl": "qte_success/qte_success.mp4", "videoUrl": "qte_success/qte_success.mp4",
@@ -264,7 +390,8 @@
"textKey": "qte_success.choice.back", "textKey": "qte_success.choice.back",
"targetScene": "intro" "targetScene": "intro"
} }
] ],
"thumbnail": "qte_success/thumb.jpg"
}, },
"qte_fail": { "qte_fail": {
"id": "qte_fail", "id": "qte_fail",
@@ -280,7 +407,8 @@
"textKey": "qte_fail.choice.back", "textKey": "qte_fail.choice.back",
"targetScene": "intro" "targetScene": "intro"
} }
] ],
"thumbnail": "qte_fail/thumb.jpg"
}, },
"desk_detail": { "desk_detail": {
"id": "desk_detail", "id": "desk_detail",
@@ -296,7 +424,8 @@
"textKey": "desk_detail.choice.leave", "textKey": "desk_detail.choice.leave",
"targetScene": "corridor" "targetScene": "corridor"
} }
] ],
"thumbnail": "shared/thumb.jpg"
}, },
"stay": { "stay": {
"id": "stay", "id": "stay",
@@ -313,8 +442,13 @@
"loopStart": 3.0, "loopStart": 3.0,
"loopEnd": 6.0, "loopEnd": 6.0,
"choices": [ "choices": [
{ "text": "站起来离开", "textKey": "stay.choice.stand", "targetScene": "alone_ending" } {
] "text": "站起来离开",
"textKey": "stay.choice.stand",
"targetScene": "alone_ending"
}
],
"thumbnail": "stay/thumb.jpg"
}, },
"trust_ending": { "trust_ending": {
"id": "trust_ending", "id": "trust_ending",
@@ -327,7 +461,11 @@
"promptKey": "trust_ending.prompt.journey", "promptKey": "trust_ending.prompt.journey",
"targetScene": "secret_ending", "targetScene": "secret_ending",
"conditions": [ "conditions": [
{ "variable": "trust", "op": ">=", "value": 80 } {
"variable": "trust",
"op": ">=",
"value": 80
}
] ]
}, },
{ {
@@ -335,25 +473,33 @@
"textKey": "trust_ending.choice.leave", "textKey": "trust_ending.choice.leave",
"targetScene": "alone_ending" "targetScene": "alone_ending"
} }
] ],
"thumbnail": "trust_ending/thumb.jpg"
}, },
"secret_ending": { "secret_ending": {
"id": "secret_ending", "id": "secret_ending",
"videoUrl": "shared/continue_ending.mp4", "videoUrl": "shared/continue_ending.mp4",
"choices": [] "choices": [],
"thumbnail": "shared/thumb.jpg"
}, },
"alone_ending": { "alone_ending": {
"id": "alone_ending", "id": "alone_ending",
"videoUrl": "alone_ending/alone_ending.mp4", "videoUrl": "alone_ending/alone_ending.mp4",
"choices": [], "choices": [],
"onEnter": [ "onEnter": [
{ "type": "set", "target": "completed_game", "value": 1 } {
] "type": "set",
"target": "completed_game",
"value": 1
}
],
"thumbnail": "alone_ending/thumb.jpg"
}, },
"continue_ending": { "continue_ending": {
"id": "continue_ending", "id": "continue_ending",
"videoUrl": "shared/continue_ending.mp4", "videoUrl": "shared/continue_ending.mp4",
"choices": [] "choices": [],
"thumbnail": "shared/thumb.jpg"
} }
} }
} }

View File

@@ -205,6 +205,7 @@ const tree = computed(() => buildTreeForChapter(currentChapterId.value))
<TreeFlow <TreeFlow
v-if="tree" v-if="tree"
:node="tree" :node="tree"
:scenes="scenes"
:key="currentChapterId" :key="currentChapterId"
@select-scene="onSelectScene" @select-scene="onSelectScene"
/> />

View File

@@ -5,6 +5,7 @@ import dagre from 'dagre'
const props = defineProps<{ const props = defineProps<{
node: PlayerTreeNode | null node: PlayerTreeNode | null
scenes?: Record<string, { thumbnail?: string }>
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -15,6 +16,7 @@ interface FlowNode {
id: string id: string
sceneId: string sceneId: string
label: string label: string
thumbnail?: string
visited: boolean visited: boolean
isMystery: boolean isMystery: boolean
locked: boolean locked: boolean
@@ -38,7 +40,7 @@ const containerW = ref(800)
const containerH = ref(400) const containerH = ref(400)
function buildFlow(root: PlayerTreeNode) { function buildFlow(root: PlayerTreeNode) {
const dagreNodes: { id: string; sceneId: string; parent: string | null; label: string; visited: boolean; isMystery: boolean; locked: boolean; lockHint?: string }[] = [] const dagreNodes: { id: string; sceneId: string; thumbnail?: string; parent: string | null; label: string; visited: boolean; isMystery: boolean; locked: boolean; lockHint?: string }[] = []
const dagreEdges: { from: string; to: string; visited: boolean }[] = [] const dagreEdges: { from: string; to: string; visited: boolean }[] = []
function walk(node: PlayerTreeNode, parentId: string | null) { function walk(node: PlayerTreeNode, parentId: string | null) {
@@ -47,6 +49,7 @@ function buildFlow(root: PlayerTreeNode) {
dagreNodes.push({ dagreNodes.push({
id: dagreId, id: dagreId,
sceneId: node.sceneId, sceneId: node.sceneId,
thumbnail: props.scenes?.[node.sceneId]?.thumbnail,
parent: parentId, parent: parentId,
label: node.label, label: node.label,
visited: true, visited: true,
@@ -91,11 +94,13 @@ function buildFlow(root: PlayerTreeNode) {
g.setGraph({ rankdir: 'LR', nodesep: 40, ranksep: 80, marginx: 24, marginy: 24 }) g.setGraph({ rankdir: 'LR', nodesep: 40, ranksep: 80, marginx: 24, marginy: 24 })
g.setDefaultEdgeLabel(() => ({})) g.setDefaultEdgeLabel(() => ({}))
const nodeW = 120 const baseW = 128
const nodeH = 44 const baseH = 40
const thumbH = 78
for (const n of dagreNodes) { for (const n of dagreNodes) {
g.setNode(n.id, { width: nodeW, height: nodeH }) const hasThumb = !!n.thumbnail
g.setNode(n.id, { width: hasThumb ? baseW + 20 : baseW, height: hasThumb ? baseH + thumbH + 8 : baseH })
} }
for (const e of dagreEdges) { for (const e of dagreEdges) {
g.setEdge(e.from, e.to) g.setEdge(e.from, e.to)
@@ -105,18 +110,22 @@ function buildFlow(root: PlayerTreeNode) {
const resultNodes: FlowNode[] = dagreNodes.map((n) => { const resultNodes: FlowNode[] = dagreNodes.map((n) => {
const pos = g.node(n.id) const pos = g.node(n.id)
const hasThumb = !!n.thumbnail
const nw = hasThumb ? baseW + 20 : baseW
const nh = hasThumb ? baseH + thumbH + 8 : baseH
return { return {
id: n.id, id: n.id,
sceneId: n.sceneId, sceneId: n.sceneId,
thumbnail: n.thumbnail,
label: n.label, label: n.label,
visited: n.visited, visited: n.visited,
isMystery: n.isMystery, isMystery: n.isMystery,
locked: n.locked, locked: n.locked,
lockHint: n.lockHint, lockHint: n.lockHint,
x: pos.x - nodeW / 2, x: pos.x - nw / 2,
y: pos.y - nodeH / 2, y: pos.y - nh / 2,
w: nodeW, w: nw,
h: nodeH, h: nh,
} }
}) })
@@ -234,6 +243,7 @@ const svgH = computed(() => containerH.value)
:title="n.lockHint || ''" :title="n.lockHint || ''"
@click="n.visited && n.sceneId && emit('selectScene', n.sceneId)" @click="n.visited && n.sceneId && emit('selectScene', n.sceneId)"
> >
<img v-if="n.thumbnail" :src="n.thumbnail" class="node-thumb" />
<span class="node-icon">{{ n.visited ? '✦' : n.isMystery ? '?' : '⬜' }}</span> <span class="node-icon">{{ n.visited ? '✦' : n.isMystery ? '?' : '⬜' }}</span>
<span class="node-label">{{ n.label }}</span> <span class="node-label">{{ n.label }}</span>
</div> </div>
@@ -264,15 +274,29 @@ const svgH = computed(() => containerH.value)
.flow-node { .flow-node {
position: absolute; position: absolute;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
gap: 6px; justify-content: center;
padding: 0 10px; gap: 4px;
padding: 4px 8px 6px;
border-radius: 4px; border-radius: 4px;
font-size: 12px; font-size: 12px;
transition: all 0.15s; transition: all 0.15s;
overflow: hidden; overflow: hidden;
} }
.flow-node:has(.node-thumb) {
padding: 4px 4px 6px;
}
.node-thumb {
width: 128px;
height: 72px;
object-fit: cover;
border-radius: 3px;
border: 1px solid rgba(255,255,255,0.06);
}
.flow-node.clickable { .flow-node.clickable {
cursor: pointer; cursor: pointer;
} }