feat: chapter boundary gateway nodes in flow, prevent cross-chapter BFS expansion
This commit is contained in:
@@ -161,4 +161,6 @@ export interface PlayerTreeNode {
|
|||||||
locked: boolean
|
locked: boolean
|
||||||
lockHint?: string
|
lockHint?: string
|
||||||
children: PlayerTreeNode[]
|
children: PlayerTreeNode[]
|
||||||
|
isGateway?: boolean
|
||||||
|
gatewayChapterId?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,11 @@ const emit = defineEmits<{
|
|||||||
const showChapterPicker = ref(false)
|
const showChapterPicker = ref(false)
|
||||||
const showDetails = ref(false)
|
const showDetails = ref(false)
|
||||||
|
|
||||||
function collectReachable(startId: string): Set<string> {
|
function isOtherChapterStart(sceneId: string, ownChapterId: string): boolean {
|
||||||
|
return props.chapters.some(c => c.id !== ownChapterId && c.startScene === sceneId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectReachable(startId: string, chapterId: string): Set<string> {
|
||||||
const visited = new Set<string>()
|
const visited = new Set<string>()
|
||||||
const queue = [startId]
|
const queue = [startId]
|
||||||
while (queue.length > 0) {
|
while (queue.length > 0) {
|
||||||
@@ -34,17 +38,22 @@ function collectReachable(startId: string): Set<string> {
|
|||||||
visited.add(id)
|
visited.add(id)
|
||||||
if (scene.choices) {
|
if (scene.choices) {
|
||||||
for (const c of scene.choices) {
|
for (const c of scene.choices) {
|
||||||
if (c.targetScene && !visited.has(c.targetScene)) queue.push(c.targetScene)
|
if (c.targetScene && !visited.has(c.targetScene) && !isOtherChapterStart(c.targetScene, chapterId))
|
||||||
|
queue.push(c.targetScene)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (scene.nextScene && !visited.has(scene.nextScene)) queue.push(scene.nextScene)
|
if (scene.nextScene && !visited.has(scene.nextScene) && !isOtherChapterStart(scene.nextScene, chapterId))
|
||||||
|
queue.push(scene.nextScene)
|
||||||
if (scene.qte) {
|
if (scene.qte) {
|
||||||
if (scene.qte.successScene && !visited.has(scene.qte.successScene)) queue.push(scene.qte.successScene)
|
if (scene.qte.successScene && !visited.has(scene.qte.successScene) && !isOtherChapterStart(scene.qte.successScene, chapterId))
|
||||||
if (scene.qte.failScene && !visited.has(scene.qte.failScene)) queue.push(scene.qte.failScene)
|
queue.push(scene.qte.successScene)
|
||||||
|
if (scene.qte.failScene && !visited.has(scene.qte.failScene) && !isOtherChapterStart(scene.qte.failScene, chapterId))
|
||||||
|
queue.push(scene.qte.failScene)
|
||||||
}
|
}
|
||||||
if (scene.hotspots) {
|
if (scene.hotspots) {
|
||||||
for (const h of scene.hotspots) {
|
for (const h of scene.hotspots) {
|
||||||
if (h.targetScene && !visited.has(h.targetScene)) queue.push(h.targetScene)
|
if (h.targetScene && !visited.has(h.targetScene) && !isOtherChapterStart(h.targetScene, chapterId))
|
||||||
|
queue.push(h.targetScene)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,7 +63,7 @@ function collectReachable(startId: string): Set<string> {
|
|||||||
const chapterReachable = computed(() => {
|
const chapterReachable = computed(() => {
|
||||||
const result: Record<string, Set<string>> = {}
|
const result: Record<string, Set<string>> = {}
|
||||||
for (const ch of props.chapters) {
|
for (const ch of props.chapters) {
|
||||||
result[ch.id] = collectReachable(ch.startScene)
|
result[ch.id] = collectReachable(ch.startScene, ch.id)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
@@ -129,7 +138,7 @@ function selectChapter(chapterId: string) {
|
|||||||
showChapterPicker.value = false
|
showChapterPicker.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPlayerTree(sceneId: string, depth: number, pathSet: Set<string>): PlayerTreeNode | null {
|
function buildPlayerTree(sceneId: string, chapterId: string, depth: number, pathSet: Set<string>): PlayerTreeNode | null {
|
||||||
if (depth > 10) return null
|
if (depth > 10) return null
|
||||||
if (pathSet.has(sceneId)) return null
|
if (pathSet.has(sceneId)) return null
|
||||||
const scene = props.scenes[sceneId]
|
const scene = props.scenes[sceneId]
|
||||||
@@ -140,35 +149,34 @@ function buildPlayerTree(sceneId: string, depth: number, pathSet: Set<string>):
|
|||||||
pathSet.add(sceneId)
|
pathSet.add(sceneId)
|
||||||
const children: PlayerTreeNode[] = []
|
const children: PlayerTreeNode[] = []
|
||||||
if (scene) {
|
if (scene) {
|
||||||
if (scene.choices) {
|
function pushChild(target: string | undefined) {
|
||||||
for (const c of scene.choices) {
|
if (!target) return
|
||||||
if (c.targetScene) {
|
if (isOtherChapterStart(target, chapterId)) {
|
||||||
const child = buildPlayerTree(c.targetScene, depth + 1, pathSet)
|
const gatewayCh = props.chapters.find(c => c.startScene === target)
|
||||||
if (child) children.push(child)
|
children.push({
|
||||||
}
|
sceneId: '',
|
||||||
|
label: gatewayCh ? (t(gatewayCh.labelKey || gatewayCh.label)) : target,
|
||||||
|
visited: false,
|
||||||
|
locked: !props.unlockedChapterIds.has(gatewayCh?.id ?? ''),
|
||||||
|
children: [],
|
||||||
|
isGateway: true,
|
||||||
|
gatewayChapterId: gatewayCh?.id,
|
||||||
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
const child = buildPlayerTree(target, chapterId, depth + 1, pathSet)
|
||||||
if (scene.nextScene) {
|
|
||||||
const child = buildPlayerTree(scene.nextScene, depth + 1, pathSet)
|
|
||||||
if (child) children.push(child)
|
if (child) children.push(child)
|
||||||
}
|
}
|
||||||
|
if (scene.choices) {
|
||||||
|
for (const c of scene.choices) pushChild(c.targetScene)
|
||||||
|
}
|
||||||
|
pushChild(scene.nextScene)
|
||||||
if (scene.qte) {
|
if (scene.qte) {
|
||||||
if (scene.qte.successScene) {
|
pushChild(scene.qte.successScene)
|
||||||
const child = buildPlayerTree(scene.qte.successScene, depth + 1, pathSet)
|
pushChild(scene.qte.failScene)
|
||||||
if (child) children.push(child)
|
|
||||||
}
|
|
||||||
if (scene.qte.failScene) {
|
|
||||||
const child = buildPlayerTree(scene.qte.failScene, depth + 1, pathSet)
|
|
||||||
if (child) children.push(child)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (scene.hotspots) {
|
if (scene.hotspots) {
|
||||||
for (const h of scene.hotspots) {
|
for (const h of scene.hotspots) pushChild(h.targetScene)
|
||||||
if (h.targetScene) {
|
|
||||||
const child = buildPlayerTree(h.targetScene, depth + 1, pathSet)
|
|
||||||
if (child) children.push(child)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pathSet.delete(sceneId)
|
pathSet.delete(sceneId)
|
||||||
@@ -178,7 +186,7 @@ function buildPlayerTree(sceneId: string, depth: number, pathSet: Set<string>):
|
|||||||
function buildTreeForChapter(chapterId: string): PlayerTreeNode | null {
|
function buildTreeForChapter(chapterId: string): PlayerTreeNode | null {
|
||||||
const ch = props.chapters.find(c => c.id === chapterId)
|
const ch = props.chapters.find(c => c.id === chapterId)
|
||||||
if (!ch) return null
|
if (!ch) return null
|
||||||
return buildPlayerTree(ch.startScene, 0, new Set())
|
return buildPlayerTree(ch.startScene, chapterId, 0, new Set())
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSelectScene(sceneId: string) {
|
function onSelectScene(sceneId: string) {
|
||||||
@@ -208,6 +216,7 @@ const tree = computed(() => buildTreeForChapter(currentChapterId.value))
|
|||||||
:scenes="scenes"
|
:scenes="scenes"
|
||||||
:key="currentChapterId"
|
:key="currentChapterId"
|
||||||
@select-scene="onSelectScene"
|
@select-scene="onSelectScene"
|
||||||
|
@select-gateway="selectChapter"
|
||||||
/>
|
/>
|
||||||
<div v-else class="story-empty">暂无故事数据</div>
|
<div v-else class="story-empty">暂无故事数据</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
selectScene: [sceneId: string]
|
selectScene: [sceneId: string]
|
||||||
|
selectGateway: [chapterId: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
interface FlowNode {
|
interface FlowNode {
|
||||||
@@ -19,6 +20,8 @@ interface FlowNode {
|
|||||||
thumbnail?: string
|
thumbnail?: string
|
||||||
visited: boolean
|
visited: boolean
|
||||||
isMystery: boolean
|
isMystery: boolean
|
||||||
|
isGateway: boolean
|
||||||
|
gatewayChapterId?: string
|
||||||
locked: boolean
|
locked: boolean
|
||||||
lockHint?: string
|
lockHint?: string
|
||||||
x: number
|
x: number
|
||||||
@@ -40,10 +43,29 @@ 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; thumbnail?: 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; isGateway: boolean; gatewayChapterId?: string; 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) {
|
||||||
|
if (node.isGateway) {
|
||||||
|
const dagreId = parentId ? `${parentId}/gw_${node.gatewayChapterId}` : `gw_${node.gatewayChapterId}`
|
||||||
|
dagreNodes.push({
|
||||||
|
id: dagreId,
|
||||||
|
sceneId: '',
|
||||||
|
parent: parentId,
|
||||||
|
label: node.label,
|
||||||
|
visited: false,
|
||||||
|
isMystery: false,
|
||||||
|
isGateway: true,
|
||||||
|
gatewayChapterId: node.gatewayChapterId,
|
||||||
|
locked: node.locked,
|
||||||
|
})
|
||||||
|
if (parentId) {
|
||||||
|
dagreEdges.push({ from: parentId, to: dagreId, visited: false })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (node.visited) {
|
if (node.visited) {
|
||||||
const dagreId = parentId ? `${parentId}/${node.sceneId}` : node.sceneId
|
const dagreId = parentId ? `${parentId}/${node.sceneId}` : node.sceneId
|
||||||
dagreNodes.push({
|
dagreNodes.push({
|
||||||
@@ -54,6 +76,7 @@ function buildFlow(root: PlayerTreeNode) {
|
|||||||
label: node.label,
|
label: node.label,
|
||||||
visited: true,
|
visited: true,
|
||||||
isMystery: false,
|
isMystery: false,
|
||||||
|
isGateway: false,
|
||||||
locked: node.locked,
|
locked: node.locked,
|
||||||
lockHint: node.lockHint,
|
lockHint: node.lockHint,
|
||||||
})
|
})
|
||||||
@@ -63,7 +86,7 @@ function buildFlow(root: PlayerTreeNode) {
|
|||||||
|
|
||||||
const unvisited: PlayerTreeNode[] = []
|
const unvisited: PlayerTreeNode[] = []
|
||||||
for (const child of node.children) {
|
for (const child of node.children) {
|
||||||
if (child.visited) {
|
if (child.visited || child.isGateway) {
|
||||||
walk(child, dagreId)
|
walk(child, dagreId)
|
||||||
} else {
|
} else {
|
||||||
unvisited.push(child)
|
unvisited.push(child)
|
||||||
@@ -79,6 +102,7 @@ function buildFlow(root: PlayerTreeNode) {
|
|||||||
label: '? ?',
|
label: '? ?',
|
||||||
visited: false,
|
visited: false,
|
||||||
isMystery: true,
|
isMystery: true,
|
||||||
|
isGateway: false,
|
||||||
locked: true,
|
locked: true,
|
||||||
})
|
})
|
||||||
dagreEdges.push({ from: dagreId, to: mysteryId, visited: false })
|
dagreEdges.push({ from: dagreId, to: mysteryId, visited: false })
|
||||||
@@ -120,6 +144,8 @@ function buildFlow(root: PlayerTreeNode) {
|
|||||||
label: n.label,
|
label: n.label,
|
||||||
visited: n.visited,
|
visited: n.visited,
|
||||||
isMystery: n.isMystery,
|
isMystery: n.isMystery,
|
||||||
|
isGateway: n.isGateway || false,
|
||||||
|
gatewayChapterId: n.gatewayChapterId,
|
||||||
locked: n.locked,
|
locked: n.locked,
|
||||||
lockHint: n.lockHint,
|
lockHint: n.lockHint,
|
||||||
x: pos.x - nw / 2,
|
x: pos.x - nw / 2,
|
||||||
@@ -238,13 +264,13 @@ const svgH = computed(() => containerH.value)
|
|||||||
v-for="n in nodes"
|
v-for="n in nodes"
|
||||||
:key="n.id"
|
:key="n.id"
|
||||||
class="flow-node"
|
class="flow-node"
|
||||||
:class="{ visited: n.visited, mystery: n.isMystery, locked: n.locked, clickable: n.visited && n.sceneId }"
|
:class="{ visited: n.visited, mystery: n.isMystery, locked: n.locked, gateway: n.isGateway, clickable: (n.visited && n.sceneId) || n.isGateway }"
|
||||||
:style="{ left: n.x + 'px', top: n.y + 'px', width: n.w + 'px', height: n.h + 'px' }"
|
:style="{ left: n.x + 'px', top: n.y + 'px', width: n.w + 'px', height: n.h + 'px' }"
|
||||||
:title="n.lockHint || ''"
|
:title="n.lockHint || ''"
|
||||||
@click="n.visited && n.sceneId && emit('selectScene', n.sceneId)"
|
@click="n.isGateway ? emit('selectGateway', n.gatewayChapterId!) : n.visited && n.sceneId && emit('selectScene', n.sceneId)"
|
||||||
>
|
>
|
||||||
<img v-if="n.thumbnail" :src="n.thumbnail" class="node-thumb" />
|
<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 ? '?' : n.isGateway ? '►' : '⬜' }}</span>
|
||||||
<span class="node-label">{{ n.label }}</span>
|
<span class="node-label">{{ n.label }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -306,6 +332,24 @@ const svgH = computed(() => containerH.value)
|
|||||||
border-color: rgba(201, 168, 76, 0.5);
|
border-color: rgba(201, 168, 76, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flow-node.gateway {
|
||||||
|
background: rgba(201, 168, 76, 0.04);
|
||||||
|
border: 1px dashed rgba(201, 168, 76, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-node.gateway:hover {
|
||||||
|
background: rgba(201, 168, 76, 0.12);
|
||||||
|
border-color: rgba(201, 168, 76, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-node.gateway .node-icon {
|
||||||
|
color: #c9a84c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-node.gateway.locked {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
.flow-node.visited {
|
.flow-node.visited {
|
||||||
background: rgba(201, 168, 76, 0.12);
|
background: rgba(201, 168, 76, 0.12);
|
||||||
border: 1px solid rgba(201, 168, 76, 0.3);
|
border: 1px solid rgba(201, 168, 76, 0.3);
|
||||||
|
|||||||
Reference in New Issue
Block a user