Files
tianshu-engine/src/components/TreeFlow.vue

367 lines
8.6 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]
}>()
interface FlowNode {
id: string
sceneId: string
label: string
thumbnail?: 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; 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 }[] = []
function walk(node: PlayerTreeNode, parentId: string | null) {
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,
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) {
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,
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: 40, ranksep: 80, marginx: 24, marginy: 24 })
g.setDefaultEdgeLabel(() => ({}))
const baseW = 128
const baseH = 40
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,
locked: n.locked,
lockHint: n.lockHint,
x: pos.x - nw / 2,
y: pos.y - nh / 2,
w: nw,
h: nh,
}
})
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 < 2) return ''
const r = 6
let d = `M ${e.points[0].x} ${e.points[0].y}`
for (let i = 1; i < e.points.length - 1; i++) {
const prev = e.points[i - 1]
const curr = e.points[i]
const next = e.points[i + 1]
const dx1 = curr.x - prev.x
const dy1 = curr.y - prev.y
const len1 = Math.sqrt(dx1 * dx1 + dy1 * dy1) || 1
const dx2 = next.x - curr.x
const dy2 = next.y - curr.y
const len2 = Math.sqrt(dx2 * dx2 + dy2 * dy2) || 1
const rx = Math.min(r, len1 / 2, len2 / 2)
const ax = curr.x - (dx1 / len1) * rx
const ay = curr.y - (dy1 / len1) * rx
const bx = curr.x + (dx2 / len2) * rx
const by = curr.y + (dy2 / len2) * rx
d += ` L ${ax} ${ay} Q ${curr.x} ${curr.y} ${bx} ${by}`
}
const last = e.points[e.points.length - 1]
d += ` L ${last.x} ${last.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, clickable: n.visited && n.sceneId }"
:style="{ left: n.x + 'px', top: n.y + 'px', width: n.w + 'px', height: n.h + 'px' }"
:title="n.lockHint || ''"
@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-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.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>