refactor: rewrite editor with immutable state, async-safe Vue Flow, and loading guard
This commit is contained in:
145
editor/App.vue
145
editor/App.vue
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import type { GameData, SceneNode } from '@engine/types'
|
import type { GameData, SceneNode } from '@engine/types'
|
||||||
import { useGraphEditor } from './composables/useGraphEditor'
|
import { useGraphEditor } from './composables/useGraphEditor'
|
||||||
import SceneGraph from './components/SceneGraph.vue'
|
import SceneGraph from './components/SceneGraph.vue'
|
||||||
@@ -9,73 +9,18 @@ import PreviewPanel from './components/PreviewPanel.vue'
|
|||||||
const editor = useGraphEditor()
|
const editor = useGraphEditor()
|
||||||
const dirty = ref(false)
|
const dirty = ref(false)
|
||||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
const sceneNodes = computed(() => {
|
const error = ref('')
|
||||||
return Object.values(editor.gameData.value.scenes).map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
label: s.id,
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
const sceneEdges = computed(() => {
|
|
||||||
const edges: { id: string; source: string; target: string; label?: string }[] = []
|
|
||||||
for (const [id, scene] of Object.entries(editor.gameData.value.scenes)) {
|
|
||||||
if (scene.choices) {
|
|
||||||
let ci = 0
|
|
||||||
for (const c of scene.choices) {
|
|
||||||
if (c.targetScene) {
|
|
||||||
edges.push({
|
|
||||||
id: `${id}_choice_${ci}`,
|
|
||||||
source: id,
|
|
||||||
target: c.targetScene,
|
|
||||||
label: c.text.slice(0, 10),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
ci++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (scene.nextScene) {
|
|
||||||
edges.push({
|
|
||||||
id: `${id}_next`,
|
|
||||||
source: id,
|
|
||||||
target: scene.nextScene,
|
|
||||||
label: '→ 默认',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (scene.qte) {
|
|
||||||
if (scene.qte.successScene) {
|
|
||||||
edges.push({
|
|
||||||
id: `${id}_qte_s`,
|
|
||||||
source: id,
|
|
||||||
target: scene.qte.successScene,
|
|
||||||
label: 'QTE成功',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (scene.qte.failScene) {
|
|
||||||
edges.push({
|
|
||||||
id: `${id}_qte_f`,
|
|
||||||
source: id,
|
|
||||||
target: scene.qte.failScene,
|
|
||||||
label: 'QTE失败',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return edges
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectedScene = computed<SceneNode | null>(() => {
|
const selectedScene = computed<SceneNode | null>(() => {
|
||||||
if (!editor.selectedNodeId.value) return null
|
if (!editor.selectedNodeId.value) return null
|
||||||
return editor.gameData.value.scenes[editor.selectedNodeId.value] ?? null
|
return editor.gameData.value.scenes[editor.selectedNodeId.value] ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
const sceneList = computed(() => editor.sceneList.value)
|
|
||||||
|
|
||||||
const previewVideoUrl = computed(() => {
|
const previewVideoUrl = computed(() => {
|
||||||
if (!selectedScene.value?.videoUrl) return null
|
const url = selectedScene.value?.videoUrl
|
||||||
const url = selectedScene.value.videoUrl
|
if (!url) return null
|
||||||
if (url.startsWith('/')) return url
|
return url.startsWith('/') ? url : '/' + url
|
||||||
return '/' + url
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function newNode() {
|
function newNode() {
|
||||||
@@ -112,16 +57,15 @@ function onDeleteChoice(sourceId: string, index: number) {
|
|||||||
function onAddEdge(source: string, target: string) {
|
function onAddEdge(source: string, target: string) {
|
||||||
const scene = editor.gameData.value.scenes[source]
|
const scene = editor.gameData.value.scenes[source]
|
||||||
if (!scene) return
|
if (!scene) return
|
||||||
if (!scene.choices) scene.choices = []
|
const newChoices = [...(scene.choices || []), { text: `${source} → ${target}`, targetScene: target }]
|
||||||
scene.choices.push({
|
editor.gameData.value = {
|
||||||
text: `${source} → ${target}`,
|
...editor.gameData.value,
|
||||||
targetScene: target,
|
scenes: { ...editor.gameData.value.scenes, [source]: { ...scene, choices: newChoices } },
|
||||||
})
|
}
|
||||||
editor.gameData.value.scenes = { ...editor.gameData.value.scenes }
|
|
||||||
dirty.value = true
|
dirty.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exportJSON() {
|
function exportJSON() {
|
||||||
const data = editor.exportJSON()
|
const data = editor.exportJSON()
|
||||||
const json = JSON.stringify(data, null, 2)
|
const json = JSON.stringify(data, null, 2)
|
||||||
const blob = new Blob([json], { type: 'application/json' })
|
const blob = new Blob([json], { type: 'application/json' })
|
||||||
@@ -141,21 +85,29 @@ function importJSON() {
|
|||||||
async function onFileSelected(e: Event) {
|
async function onFileSelected(e: Event) {
|
||||||
const file = (e.target as HTMLInputElement).files?.[0]
|
const file = (e.target as HTMLInputElement).files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
const text = await file.text()
|
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(text) as GameData
|
const data = JSON.parse(await file.text()) as GameData
|
||||||
|
if (!data.scenes || typeof data.scenes !== 'object') throw new Error('无效的场景数据')
|
||||||
editor.loadJSON(data)
|
editor.loadJSON(data)
|
||||||
dirty.value = false
|
dirty.value = false
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
alert('JSON 解析失败:' + (err as Error).message)
|
error.value = '导入失败:' + err.message
|
||||||
|
setTimeout(() => (error.value = ''), 3000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDemo() {
|
async function loadDemo() {
|
||||||
const resp = await fetch('/scenes/demo.json')
|
try {
|
||||||
const data = await resp.json()
|
loading.value = true
|
||||||
editor.loadJSON(data)
|
const resp = await fetch('/scenes/demo.json')
|
||||||
dirty.value = false
|
if (!resp.ok) throw new Error('HTTP ' + resp.status)
|
||||||
|
editor.loadJSON(await resp.json())
|
||||||
|
dirty.value = false
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = '加载示例失败:' + err.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -182,25 +134,30 @@ onMounted(() => {
|
|||||||
class="start-select"
|
class="start-select"
|
||||||
>
|
>
|
||||||
<option value="">-- 选择 --</option>
|
<option value="">-- 选择 --</option>
|
||||||
<option v-for="n in sceneNodes" :key="n.id" :value="n.id">{{ n.label }}</option>
|
<option v-for="n in editor.sceneNodes.value" :key="n.id" :value="n.id">{{ n.label }}</option>
|
||||||
</select>
|
</select>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="error-bar">{{ error }}</div>
|
||||||
|
|
||||||
<div class="editor-main">
|
<div class="editor-main">
|
||||||
<SceneGraph
|
<SceneGraph
|
||||||
|
v-if="!loading"
|
||||||
class="graph-area"
|
class="graph-area"
|
||||||
:scene-nodes="sceneNodes"
|
:scene-nodes="editor.sceneNodes.value"
|
||||||
:scene-edges="sceneEdges"
|
:scene-edges="editor.sceneEdges.value"
|
||||||
:start-scene="editor.startSceneId.value"
|
:start-scene="editor.startSceneId.value"
|
||||||
:selected-node-id="editor.selectedNodeId.value"
|
:selected-node-id="editor.selectedNodeId.value"
|
||||||
@select-node="editor.selectedNodeId.value = $event"
|
@select-node="editor.selectedNodeId.value = $event"
|
||||||
@add-edge="onAddEdge"
|
@add-edge="onAddEdge"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div v-else class="graph-area loading-hint">加载剧情数据…</div>
|
||||||
|
|
||||||
<NodeEditor
|
<NodeEditor
|
||||||
:scene="selectedScene"
|
:scene="selectedScene"
|
||||||
:scene-list="sceneList"
|
:scene-list="editor.sceneList.value"
|
||||||
@update="onUpdateScene"
|
@update="onUpdateScene"
|
||||||
@add-choice="onAddChoice"
|
@add-choice="onAddChoice"
|
||||||
@update-choice="onUpdateChoice"
|
@update-choice="onUpdateChoice"
|
||||||
@@ -212,13 +169,7 @@ onMounted(() => {
|
|||||||
<PreviewPanel :video-url="previewVideoUrl" />
|
<PreviewPanel :video-url="previewVideoUrl" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input ref="fileInputRef" type="file" accept=".json" style="display:none" @change="onFileSelected" />
|
||||||
ref="fileInputRef"
|
|
||||||
type="file"
|
|
||||||
accept=".json"
|
|
||||||
style="display:none"
|
|
||||||
@change="onFileSelected"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -286,9 +237,7 @@ html, body {
|
|||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-actions button:hover {
|
.toolbar-actions button:hover { background: rgba(255,255,255,0.14); }
|
||||||
background: rgba(255,255,255,0.14);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-actions button.secondary {
|
.toolbar-actions button.secondary {
|
||||||
color: #8cf;
|
color: #8cf;
|
||||||
@@ -319,6 +268,14 @@ html, body {
|
|||||||
color: #ff9800;
|
color: #ff9800;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error-bar {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #4a1a1a;
|
||||||
|
color: #f88;
|
||||||
|
font-size: 13px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-main {
|
.editor-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -328,4 +285,12 @@ html, body {
|
|||||||
.graph-area {
|
.graph-area {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loading-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #555;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { watch, onMounted, nextTick } from 'vue'
|
import { watch, nextTick } from 'vue'
|
||||||
import { VueFlow, useVueFlow } from '@vue-flow/core'
|
import { VueFlow, useVueFlow } from '@vue-flow/core'
|
||||||
import { Background } from '@vue-flow/background'
|
import { Background } from '@vue-flow/background'
|
||||||
import { Controls } from '@vue-flow/controls'
|
import { Controls } from '@vue-flow/controls'
|
||||||
import '@vue-flow/core/dist/style.css'
|
import '@vue-flow/core/dist/style.css'
|
||||||
import '@vue-flow/controls/dist/style.css'
|
import '@vue-flow/controls/dist/style.css'
|
||||||
import '@vue-flow/core/dist/theme-default.css'
|
import '@vue-flow/core/dist/theme-default.css'
|
||||||
import type { Node, Edge, Connection } from '@vue-flow/core'
|
import type { Connection } from '@vue-flow/core'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
sceneNodes: { id: string; label: string }[]
|
sceneNodes: { id: string; label: string }[]
|
||||||
@@ -17,82 +17,82 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
selectNode: [id: string]
|
selectNode: [id: string]
|
||||||
addNode: []
|
|
||||||
addEdge: [source: string, target: string]
|
addEdge: [source: string, target: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { nodes, edges, onNodeClick, onConnect, fitView } = useVueFlow()
|
const { nodes, edges, onNodeClick, onConnect, fitView } = useVueFlow()
|
||||||
|
|
||||||
function buildNodes() {
|
function makeNodes() {
|
||||||
return props.sceneNodes.map((n, i) => ({
|
return props.sceneNodes.map((n, i) => ({
|
||||||
id: n.id,
|
id: n.id,
|
||||||
type: 'default',
|
type: 'default',
|
||||||
position: { x: (i % 4) * 220 + 50, y: Math.floor(i / 4) * 120 + 50 },
|
position: { x: (i % 4) * 220 + 50, y: Math.floor(i / 4) * 120 + 50 },
|
||||||
data: { label: n.id === props.startScene ? `▶ ${n.label}` : n.label },
|
data: { label: n.id === props.startScene ? `\u25b6 ${n.label}` : n.label },
|
||||||
style: n.id === props.startScene
|
style: n.id === props.startScene
|
||||||
? 'background:#1b5e20; color:#fff; border-color:#388e3c'
|
? { background: '#1b5e20', color: '#fff', borderColor: '#388e3c' }
|
||||||
: n.id === props.selectedNodeId
|
: n.id === props.selectedNodeId
|
||||||
? 'background:#1565c0; color:#fff; border-color:#1976d2'
|
? { background: '#1565c0', color: '#fff', borderColor: '#1976d2' }
|
||||||
: '',
|
: {},
|
||||||
sourcePosition: 'right' as const,
|
sourcePosition: 'right' as const,
|
||||||
targetPosition: 'left' as const,
|
targetPosition: 'left' as const,
|
||||||
connectable: true,
|
connectable: true,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildEdges() {
|
function makeEdges() {
|
||||||
return props.sceneEdges.map((e) => ({
|
return props.sceneEdges.map((e) => ({
|
||||||
id: e.id,
|
id: e.id,
|
||||||
source: e.source,
|
source: e.source,
|
||||||
target: e.target,
|
target: e.target,
|
||||||
label: e.label || '',
|
label: e.label ?? '',
|
||||||
animated: true,
|
animated: true,
|
||||||
style: { stroke: '#4fc3f7' },
|
|
||||||
labelStyle: { fill: '#aaa', fontWeight: 400, fontSize: 11 },
|
|
||||||
labelBgStyle: { fill: 'rgba(0,0,0,0.7)' },
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastNodesLen = 0
|
let prevNodeCount = -1
|
||||||
let lastEdgesLen = 0
|
let prevEdgeCount = -1
|
||||||
|
let didFitView = false
|
||||||
|
let rebuildSeq = 0
|
||||||
|
|
||||||
async function structuralRebuild() {
|
async function structuralRebuild() {
|
||||||
|
const seq = ++rebuildSeq
|
||||||
edges.value = []
|
edges.value = []
|
||||||
await nextTick()
|
await nextTick()
|
||||||
nodes.value = buildNodes()
|
if (seq !== rebuildSeq) return
|
||||||
|
nodes.value = makeNodes()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
edges.value = buildEdges()
|
if (seq !== rebuildSeq) return
|
||||||
lastNodesLen = props.sceneNodes.length
|
edges.value = makeEdges()
|
||||||
lastEdgesLen = props.sceneEdges.length
|
prevNodeCount = props.sceneNodes.length
|
||||||
|
prevEdgeCount = props.sceneEdges.length
|
||||||
|
if (!didFitView && nodes.value.length > 0) {
|
||||||
|
didFitView = true
|
||||||
|
setTimeout(() => fitView({ padding: 0.2 }), 80)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function styleOnlyRebuild() {
|
function inlineRebuild() {
|
||||||
nodes.value = buildNodes()
|
nodes.value = makeNodes()
|
||||||
edges.value = buildEdges()
|
edges.value = makeEdges()
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => [props.sceneNodes, props.sceneEdges, props.startScene, props.selectedNodeId], () => {
|
watch(
|
||||||
const nodesLen = props.sceneNodes.length
|
() => [props.sceneNodes, props.sceneEdges, props.startScene, props.selectedNodeId] as const,
|
||||||
const edgesLen = props.sceneEdges.length
|
() => {
|
||||||
if (nodesLen !== lastNodesLen || edgesLen !== lastEdgesLen) {
|
const nc = props.sceneNodes.length
|
||||||
structuralRebuild()
|
const ec = props.sceneEdges.length
|
||||||
} else {
|
if (nc !== prevNodeCount || ec !== prevEdgeCount) {
|
||||||
styleOnlyRebuild()
|
structuralRebuild()
|
||||||
}
|
} else {
|
||||||
})
|
inlineRebuild()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
onNodeClick((event) => {
|
onNodeClick((ev) => emit('selectNode', ev.node.id))
|
||||||
emit('selectNode', event.node.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
onConnect((connection: Connection) => {
|
onConnect((conn: Connection) => {
|
||||||
if (connection.source && connection.target) {
|
if (conn.source && conn.target) emit('addEdge', conn.source, conn.target)
|
||||||
emit('addEdge', connection.source, connection.target)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
setTimeout(() => fitView({ padding: 0.2 }), 100)
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,58 @@
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed, shallowRef, triggerRef } from 'vue'
|
||||||
import type { GameData, SceneNode, Choice } from '@engine/types'
|
import type { GameData, SceneNode, Choice } from '@engine/types'
|
||||||
|
|
||||||
export interface EditorNode {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
videoUrl: string
|
|
||||||
subtitleUrl: string
|
|
||||||
choices: Choice[]
|
|
||||||
nextScene: string
|
|
||||||
onEnter: any[]
|
|
||||||
qte: any | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGraphEditor() {
|
export function useGraphEditor() {
|
||||||
const gameData = ref<GameData>({ scenes: {}, startScene: '', variables: {} })
|
const gameData = shallowRef<GameData>({ scenes: {}, startScene: '', variables: {} })
|
||||||
const selectedNodeId = ref<string | null>(null)
|
const selectedNodeId = ref<string | null>(null)
|
||||||
const startSceneId = ref('')
|
const startSceneId = ref('')
|
||||||
|
|
||||||
const selectedNode = computed(() => {
|
const sceneList = computed(() =>
|
||||||
|
Object.values(gameData.value.scenes).map((s) => ({ id: s.id, label: s.id })),
|
||||||
|
)
|
||||||
|
|
||||||
|
const sceneNodes = computed(() =>
|
||||||
|
Object.values(gameData.value.scenes).map((s) => ({ id: s.id, label: s.id })),
|
||||||
|
)
|
||||||
|
|
||||||
|
const sceneEdges = computed(() => {
|
||||||
|
const result: { id: string; source: string; target: string; label?: string }[] = []
|
||||||
|
for (const [id, scene] of Object.entries(gameData.value.scenes)) {
|
||||||
|
if (scene.choices) {
|
||||||
|
let ci = 0
|
||||||
|
for (const c of scene.choices) {
|
||||||
|
if (c.targetScene) {
|
||||||
|
result.push({ id: `${id}_choice_${ci}`, source: id, target: c.targetScene, label: c.text.slice(0, 10) })
|
||||||
|
}
|
||||||
|
ci++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (scene.nextScene) {
|
||||||
|
result.push({ id: `${id}_next`, source: id, target: scene.nextScene, label: '\u2192 \u9ed8\u8ba4' })
|
||||||
|
}
|
||||||
|
if (scene.qte) {
|
||||||
|
if (scene.qte.successScene)
|
||||||
|
result.push({ id: `${id}_qte_s`, source: id, target: scene.qte.successScene, label: 'QTE\u6210\u529f' })
|
||||||
|
if (scene.qte.failScene)
|
||||||
|
result.push({ id: `${id}_qte_f`, source: id, target: scene.qte.failScene, label: 'QTE\u5931\u8d25' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedScene = computed(() => {
|
||||||
if (!selectedNodeId.value) return null
|
if (!selectedNodeId.value) return null
|
||||||
return gameData.value.scenes[selectedNodeId.value] ?? null
|
return gameData.value.scenes[selectedNodeId.value] ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
const sceneList = computed(() => {
|
function trigger() {
|
||||||
return Object.values(gameData.value.scenes).map((s) => ({
|
triggerRef(gameData)
|
||||||
id: s.id,
|
}
|
||||||
label: s.id,
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
function loadJSON(json: GameData) {
|
function loadJSON(json: GameData) {
|
||||||
gameData.value = JSON.parse(JSON.stringify(json))
|
gameData.value = JSON.parse(JSON.stringify(json))
|
||||||
startSceneId.value = json.startScene
|
trigger()
|
||||||
|
startSceneId.value = json.startScene || ''
|
||||||
|
selectedNodeId.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportJSON(): GameData {
|
function exportJSON(): GameData {
|
||||||
@@ -46,76 +67,112 @@ export function useGraphEditor() {
|
|||||||
|
|
||||||
function addScene(): string {
|
function addScene(): string {
|
||||||
const id = generateId()
|
const id = generateId()
|
||||||
gameData.value.scenes[id] = {
|
gameData.value = {
|
||||||
id,
|
...gameData.value,
|
||||||
videoUrl: '',
|
scenes: {
|
||||||
choices: [],
|
...gameData.value.scenes,
|
||||||
nextScene: '',
|
[id]: {
|
||||||
subtitleUrl: '',
|
id,
|
||||||
onEnter: [],
|
videoUrl: '',
|
||||||
|
choices: [],
|
||||||
|
nextScene: '',
|
||||||
|
subtitleUrl: '',
|
||||||
|
onEnter: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
trigger()
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteScene(id: string) {
|
function deleteScene(id: string) {
|
||||||
if (startSceneId.value === id) return
|
if (startSceneId.value === id) return
|
||||||
delete gameData.value.scenes[id]
|
const nextScenes = { ...gameData.value.scenes }
|
||||||
for (const s of Object.values(gameData.value.scenes)) {
|
delete nextScenes[id]
|
||||||
s.choices = (s.choices || []).filter((c) => c.targetScene !== id)
|
for (const key of Object.keys(nextScenes)) {
|
||||||
if (s.nextScene === id) s.nextScene = ''
|
const s = nextScenes[key]
|
||||||
|
if (s.choices)
|
||||||
|
nextScenes[key] = { ...s, choices: s.choices.filter((c) => c.targetScene !== id) }
|
||||||
|
if (s.nextScene === id) nextScenes[key] = { ...nextScenes[key], nextScene: '' }
|
||||||
|
if (s.qte) {
|
||||||
|
const qte = { ...s.qte }
|
||||||
|
let changed = false
|
||||||
|
if (qte.successScene === id) { qte.successScene = ''; changed = true }
|
||||||
|
if (qte.failScene === id) { qte.failScene = ''; changed = true }
|
||||||
|
if (changed) nextScenes[key] = { ...nextScenes[key], qte }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
gameData.value = { ...gameData.value, scenes: nextScenes }
|
||||||
|
trigger()
|
||||||
if (selectedNodeId.value === id) selectedNodeId.value = null
|
if (selectedNodeId.value === id) selectedNodeId.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateScene(id: string, partial: Partial<EditorNode>) {
|
function updateScene(id: string, partial: Partial<SceneNode>) {
|
||||||
const scene = gameData.value.scenes[id]
|
const scene = gameData.value.scenes[id]
|
||||||
if (!scene) return
|
if (!scene) return
|
||||||
Object.assign(scene, partial)
|
gameData.value = {
|
||||||
gameData.value.scenes = { ...gameData.value.scenes }
|
...gameData.value,
|
||||||
|
scenes: { ...gameData.value.scenes, [id]: { ...scene, ...partial } },
|
||||||
|
}
|
||||||
|
trigger()
|
||||||
}
|
}
|
||||||
|
|
||||||
function addChoice(sourceId: string) {
|
function addChoice(sourceId: string) {
|
||||||
const scene = gameData.value.scenes[sourceId]
|
const scene = gameData.value.scenes[sourceId]
|
||||||
if (!scene) return
|
if (!scene) return
|
||||||
if (!scene.choices) scene.choices = []
|
gameData.value = {
|
||||||
scene.choices.push({
|
...gameData.value,
|
||||||
text: '新选项',
|
scenes: {
|
||||||
targetScene: '',
|
...gameData.value.scenes,
|
||||||
})
|
[sourceId]: {
|
||||||
gameData.value.scenes = { ...gameData.value.scenes }
|
...scene,
|
||||||
|
choices: [...(scene.choices || []), { text: '\u65b0\u9009\u9879', targetScene: '' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
trigger()
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateChoice(sourceId: string, index: number, partial: Partial<Choice>) {
|
function updateChoice(sourceId: string, index: number, partial: Partial<Choice>) {
|
||||||
const scene = gameData.value.scenes[sourceId]
|
const scene = gameData.value.scenes[sourceId]
|
||||||
if (!scene?.choices) return
|
if (!scene?.choices) return
|
||||||
Object.assign(scene.choices[index], partial)
|
const newChoices = scene.choices.map((c, i) => (i === index ? { ...c, ...partial } : c))
|
||||||
gameData.value.scenes = { ...gameData.value.scenes }
|
gameData.value = {
|
||||||
|
...gameData.value,
|
||||||
|
scenes: { ...gameData.value.scenes, [sourceId]: { ...scene, choices: newChoices } },
|
||||||
|
}
|
||||||
|
trigger()
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteChoice(sourceId: string, index: number) {
|
function deleteChoice(sourceId: string, index: number) {
|
||||||
const scene = gameData.value.scenes[sourceId]
|
const scene = gameData.value.scenes[sourceId]
|
||||||
if (!scene?.choices) return
|
if (!scene?.choices) return
|
||||||
scene.choices.splice(index, 1)
|
gameData.value = {
|
||||||
gameData.value.scenes = { ...gameData.value.scenes }
|
...gameData.value,
|
||||||
}
|
scenes: {
|
||||||
|
...gameData.value.scenes,
|
||||||
function newSceneData(): EditorNode {
|
[sourceId]: { ...scene, choices: scene.choices.filter((_, i) => i !== index) },
|
||||||
return {
|
},
|
||||||
id: '',
|
|
||||||
label: '',
|
|
||||||
videoUrl: '',
|
|
||||||
subtitleUrl: '',
|
|
||||||
choices: [],
|
|
||||||
nextScene: '',
|
|
||||||
onEnter: [],
|
|
||||||
qte: null,
|
|
||||||
}
|
}
|
||||||
|
trigger()
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
gameData, selectedNodeId, selectedNode, sceneList, startSceneId,
|
gameData,
|
||||||
loadJSON, exportJSON, addScene, deleteScene, updateScene,
|
selectedNodeId,
|
||||||
addChoice, updateChoice, deleteChoice,
|
selectedScene,
|
||||||
newSceneData, generateId,
|
sceneList,
|
||||||
|
sceneNodes,
|
||||||
|
sceneEdges,
|
||||||
|
startSceneId,
|
||||||
|
loadJSON,
|
||||||
|
exportJSON,
|
||||||
|
addScene,
|
||||||
|
deleteScene,
|
||||||
|
updateScene,
|
||||||
|
addChoice,
|
||||||
|
updateChoice,
|
||||||
|
deleteChoice,
|
||||||
|
generateId,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user