454 lines
11 KiB
Vue
454 lines
11 KiB
Vue
<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
|
|
scenes?: Record<string, { thumbnail?: string }>
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
selectScene: [sceneId: string]
|
|
selectGateway: [chapterId: string]
|
|
}>()
|
|
|
|
interface FlowNode {
|
|
id: string
|
|
sceneId: string
|
|
label: string
|
|
thumbnail?: string
|
|
visited: boolean
|
|
isMystery: boolean
|
|
isGateway: boolean
|
|
gatewayChapterId?: string
|
|
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; 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 }[] = []
|
|
|
|
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) {
|
|
const dagreId = parentId ? `${parentId}/${node.sceneId}` : node.sceneId
|
|
dagreNodes.push({
|
|
id: dagreId,
|
|
sceneId: node.sceneId,
|
|
thumbnail: props.scenes?.[node.sceneId]?.thumbnail,
|
|
parent: parentId,
|
|
label: node.label,
|
|
visited: true,
|
|
isMystery: false,
|
|
isGateway: false,
|
|
locked: node.locked,
|
|
lockHint: node.lockHint,
|
|
})
|
|
if (parentId) {
|
|
dagreEdges.push({ from: parentId, to: dagreId, visited: true })
|
|
}
|
|
|
|
const unvisited: PlayerTreeNode[] = []
|
|
for (const child of node.children) {
|
|
if (child.visited || child.isGateway) {
|
|
walk(child, dagreId)
|
|
} else {
|
|
unvisited.push(child)
|
|
}
|
|
}
|
|
|
|
if (unvisited.length > 0) {
|
|
const mysteryId = `${dagreId}/__mystery`
|
|
dagreNodes.push({
|
|
id: mysteryId,
|
|
sceneId: '',
|
|
parent: dagreId,
|
|
label: '? ?',
|
|
visited: false,
|
|
isMystery: true,
|
|
isGateway: false,
|
|
locked: true,
|
|
})
|
|
dagreEdges.push({ from: dagreId, to: mysteryId, visited: false })
|
|
}
|
|
}
|
|
}
|
|
|
|
walk(root, null)
|
|
|
|
if (dagreNodes.length === 0) return
|
|
|
|
const g = new dagre.graphlib.Graph()
|
|
g.setGraph({ rankdir: 'LR', nodesep: 60, ranksep: 200, marginx: 24, marginy: 24 })
|
|
g.setDefaultEdgeLabel(() => ({}))
|
|
|
|
const baseW = 128
|
|
const baseH = 48
|
|
const thumbH = 78
|
|
|
|
for (const n of dagreNodes) {
|
|
const hasThumb = !!n.thumbnail
|
|
g.setNode(n.id, { width: hasThumb ? baseW + 20 : baseW, height: hasThumb ? baseH + thumbH + 8 : baseH })
|
|
}
|
|
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)
|
|
const hasThumb = !!n.thumbnail
|
|
const nw = hasThumb ? baseW + 20 : baseW
|
|
const nh = hasThumb ? baseH + thumbH + 8 : baseH
|
|
return {
|
|
id: n.id,
|
|
sceneId: n.sceneId,
|
|
thumbnail: n.thumbnail,
|
|
label: n.label,
|
|
visited: n.visited,
|
|
isMystery: n.isMystery,
|
|
isGateway: n.isGateway || false,
|
|
gatewayChapterId: n.gatewayChapterId,
|
|
locked: n.locked,
|
|
lockHint: n.lockHint,
|
|
x: pos.x - nw / 2,
|
|
y: pos.y - nh / 2,
|
|
w: nw,
|
|
h: nh,
|
|
}
|
|
})
|
|
|
|
const nodeMap = new Map(resultNodes.map((n) => [n.id, n]))
|
|
|
|
const resultEdges: FlowEdge[] = dagreEdges.map((e) => {
|
|
const edge = g.edge(e.from, e.to)
|
|
const pts = edge.points || []
|
|
if (pts.length >= 2) {
|
|
const src = nodeMap.get(e.from)
|
|
const dst = nodeMap.get(e.to)
|
|
if (src) {
|
|
pts[0] = { x: src.x + src.w, y: src.y + src.h / 2 }
|
|
}
|
|
if (dst) {
|
|
pts[pts.length - 1] = { x: dst.x, y: dst.y + dst.h / 2 }
|
|
}
|
|
}
|
|
return {
|
|
from: e.from,
|
|
to: e.to,
|
|
visited: e.visited,
|
|
points: pts,
|
|
}
|
|
})
|
|
|
|
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 < 2) return ''
|
|
const r = 5
|
|
let d = `M ${e.points[0].x} ${e.points[0].y}`
|
|
|
|
for (let i = 1; i < e.points.length; i++) {
|
|
const a = e.points[i - 1]
|
|
const b = e.points[i]
|
|
const dx = b.x - a.x
|
|
const dy = b.y - a.y
|
|
|
|
if (Math.abs(dy) < 0.5) {
|
|
d += ` L ${b.x} ${b.y}`
|
|
} else if (Math.abs(dx) < 0.5) {
|
|
d += ` L ${b.x} ${b.y}`
|
|
} else {
|
|
const cr = Math.min(r, Math.abs(dx) / 2, Math.abs(dy) / 2)
|
|
const midX = b.x
|
|
const midY = a.y
|
|
d += ` L ${midX - Math.sign(dx) * cr} ${midY}`
|
|
if (cr > 0.5) d += ` Q ${midX} ${midY} ${midX} ${midY + Math.sign(dy) * cr}`
|
|
d += ` L ${b.x} ${b.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"
|
|
>
|
|
<defs>
|
|
<linearGradient id="edge-gold" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
<stop offset="0%" stop-color="#8b6914"/>
|
|
<stop offset="50%" stop-color="#e0c060"/>
|
|
<stop offset="100%" stop-color="#8b6914"/>
|
|
</linearGradient>
|
|
<filter id="edge-glow">
|
|
<feGaussianBlur stdDeviation="0.6" result="b"/>
|
|
<feMerge>
|
|
<feMergeNode in="b"/>
|
|
<feMergeNode in="SourceGraphic"/>
|
|
</feMerge>
|
|
</filter>
|
|
</defs>
|
|
|
|
<g
|
|
v-for="edge in edges"
|
|
:key="edge.from + '-' + edge.to"
|
|
>
|
|
<path
|
|
v-if="edge.visited"
|
|
:d="edgePath(edge)"
|
|
fill="none"
|
|
stroke="url(#edge-gold)"
|
|
stroke-width="1.6"
|
|
filter="url(#edge-glow)"
|
|
opacity="0.18"
|
|
/>
|
|
<path
|
|
v-if="edge.visited"
|
|
:d="edgePath(edge)"
|
|
fill="none"
|
|
stroke="#c9a84c"
|
|
stroke-width="1.4"
|
|
class="edge-main"
|
|
/>
|
|
<path
|
|
v-if="edge.visited"
|
|
:d="edgePath(edge)"
|
|
fill="none"
|
|
stroke="#e0c060"
|
|
stroke-width="0.7"
|
|
stroke-dasharray="2 35"
|
|
class="edge-flow"
|
|
opacity="0.35"
|
|
/>
|
|
<path
|
|
v-if="!edge.visited"
|
|
:d="edgePath(edge)"
|
|
fill="none"
|
|
stroke="#333"
|
|
stroke-width="1"
|
|
stroke-dasharray="5 4"
|
|
/>
|
|
</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, 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' }"
|
|
:title="n.lockHint || ''"
|
|
@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" />
|
|
<span class="node-icon">{{ n.visited ? '✦' : n.isMystery ? '?' : n.isGateway ? '►' : '⬜' }}</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;
|
|
z-index: 0;
|
|
}
|
|
|
|
.flow-nodes {
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
|
|
.flow-node {
|
|
position: absolute;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 4px;
|
|
padding: 4px 8px 6px;
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
transition: all 0.15s;
|
|
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 {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.flow-node.clickable:hover {
|
|
background: rgba(201, 168, 76, 0.22);
|
|
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 {
|
|
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;
|
|
}
|
|
|
|
@keyframes flowDash {
|
|
to { stroke-dashoffset: -23; }
|
|
}
|
|
|
|
.edge-main {
|
|
stroke-linecap: round;
|
|
}
|
|
|
|
.edge-flow {
|
|
stroke-linecap: round;
|
|
animation: flowDash 5s linear infinite;
|
|
}
|
|
</style>
|