267 lines
8.6 KiB
TypeScript
267 lines
8.6 KiB
TypeScript
import { defineStore } from 'pinia'
|
|
import { shallowRef, ref, computed, triggerRef } from 'vue'
|
|
import type { GameData, SceneNode, Choice } from '@engine/types'
|
|
import { putVersion, getVersions } from '../db/editorDB'
|
|
|
|
export interface AIDiff {
|
|
added: string[]
|
|
modified: string[]
|
|
deleted: string[]
|
|
globalFields: string[]
|
|
}
|
|
|
|
interface EditorVersion {
|
|
id?: number
|
|
sourcePath: string
|
|
timestamp: number
|
|
label: string
|
|
gameData: GameData
|
|
}
|
|
|
|
export const useEditorStore = defineStore('editor', () => {
|
|
const gameData = shallowRef<GameData>({ scenes: {}, startScene: '', variables: {} })
|
|
const selectedNodeId = ref<string | null>(null)
|
|
const startSceneId = ref('')
|
|
const dirty = ref(false)
|
|
const sourcePath = ref('/scenes/demo.json')
|
|
const deepseekKey = ref(localStorage.getItem('deepseek_key') || '')
|
|
const showAIPanel = ref(true)
|
|
const aiCollapsed = ref(true)
|
|
const nodeEditorCollapsed = ref(true)
|
|
const aiSessionId = ref('')
|
|
const aiChanges = ref<AIDiff | null>(null)
|
|
const versions = ref<EditorVersion[]>([])
|
|
|
|
const selectedScene = computed(() => {
|
|
if (!selectedNodeId.value) return null
|
|
return gameData.value.scenes[selectedNodeId.value] ?? null
|
|
})
|
|
|
|
function markDirty() { dirty.value = true }
|
|
|
|
function loadJSON(json: GameData) {
|
|
gameData.value = JSON.parse(JSON.stringify(json))
|
|
triggerRef(gameData)
|
|
startSceneId.value = json.startScene || ''
|
|
selectedNodeId.value = null
|
|
dirty.value = false
|
|
}
|
|
|
|
function exportJSON(): GameData {
|
|
return JSON.parse(JSON.stringify({ ...gameData.value, startScene: startSceneId.value }))
|
|
}
|
|
|
|
function generateId(): string {
|
|
let i = Object.keys(gameData.value.scenes).length + 1
|
|
while (gameData.value.scenes[`scene_${i}`]) i++
|
|
return `scene_${i}`
|
|
}
|
|
|
|
function addScene(): string {
|
|
const id = generateId()
|
|
gameData.value = {
|
|
...gameData.value,
|
|
scenes: {
|
|
...gameData.value.scenes,
|
|
[id]: { id, videoUrl: '', choices: [], nextScene: '', subtitleUrl: '', onEnter: [] },
|
|
},
|
|
}
|
|
triggerRef(gameData)
|
|
dirty.value = true
|
|
autoSave()
|
|
return id
|
|
}
|
|
|
|
function deleteScene(id: string) {
|
|
if (startSceneId.value === id) return
|
|
const nextScenes = { ...gameData.value.scenes }
|
|
delete nextScenes[id]
|
|
for (const key of Object.keys(nextScenes)) {
|
|
const s = nextScenes[key]
|
|
if (s.choices) nextScenes[key] = { ...s, choices: s.choices.filter((c) => c.targetScene !== id) }
|
|
if (Array.isArray(s.nextScene)) {
|
|
nextScenes[key] = { ...s, nextScene: s.nextScene.filter((r) => r.targetScene !== id) }
|
|
} else 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 }
|
|
triggerRef(gameData)
|
|
dirty.value = true
|
|
if (selectedNodeId.value === id) selectedNodeId.value = null
|
|
autoSave()
|
|
}
|
|
|
|
function updateScene(id: string, partial: Partial<SceneNode>) {
|
|
const scene = gameData.value.scenes[id]
|
|
if (!scene) return
|
|
gameData.value = {
|
|
...gameData.value,
|
|
scenes: { ...gameData.value.scenes, [id]: { ...scene, ...partial } },
|
|
}
|
|
triggerRef(gameData)
|
|
dirty.value = true
|
|
autoSave()
|
|
}
|
|
|
|
function addChoice(sourceId: string) {
|
|
const scene = gameData.value.scenes[sourceId]
|
|
if (!scene) return
|
|
gameData.value = {
|
|
...gameData.value,
|
|
scenes: {
|
|
...gameData.value.scenes,
|
|
[sourceId]: { ...scene, choices: [...(scene.choices || []), { text: '\u65b0\u9009\u9879', targetScene: '' }] },
|
|
},
|
|
}
|
|
triggerRef(gameData)
|
|
dirty.value = true
|
|
autoSave()
|
|
}
|
|
|
|
function updateChoice(sourceId: string, index: number, partial: Partial<Choice>) {
|
|
const scene = gameData.value.scenes[sourceId]
|
|
if (!scene?.choices) return
|
|
const newChoices = scene.choices.map((c, i) => (i === index ? { ...c, ...partial } : c))
|
|
gameData.value = {
|
|
...gameData.value,
|
|
scenes: { ...gameData.value.scenes, [sourceId]: { ...scene, choices: newChoices } },
|
|
}
|
|
triggerRef(gameData)
|
|
dirty.value = true
|
|
autoSave()
|
|
}
|
|
|
|
function deleteChoice(sourceId: string, index: number) {
|
|
const scene = gameData.value.scenes[sourceId]
|
|
if (!scene?.choices) return
|
|
gameData.value = {
|
|
...gameData.value,
|
|
scenes: {
|
|
...gameData.value.scenes,
|
|
[sourceId]: { ...scene, choices: scene.choices.filter((_, i) => i !== index) },
|
|
},
|
|
}
|
|
triggerRef(gameData)
|
|
dirty.value = true
|
|
}
|
|
|
|
function setSourcePath(p: string) { sourcePath.value = p; localStorage.setItem('editor_last_source', p) }
|
|
|
|
function setDeepseekKey(k: string) { deepseekKey.value = k; localStorage.setItem('deepseek_key', k) }
|
|
|
|
function setAISessionId(id: string) { aiSessionId.value = id; localStorage.setItem('editor_ai_session', id) }
|
|
|
|
function clearAISession() { aiSessionId.value = ''; localStorage.removeItem('editor_ai_session') }
|
|
|
|
let saveVersionTimer: ReturnType<typeof setTimeout> | null = null
|
|
let lastSavedContent = ''
|
|
|
|
function debouncedSaveVersion() {
|
|
const current = JSON.stringify(gameData.value)
|
|
if (current === lastSavedContent) {
|
|
console.log('[版本] 内容相同,跳过')
|
|
return
|
|
}
|
|
if (saveVersionTimer) { console.log('[版本] 重置 3s 计时器'); clearTimeout(saveVersionTimer) }
|
|
else console.log('[版本] 启动 3s 计时器')
|
|
saveVersionTimer = setTimeout(async () => {
|
|
console.log('[版本] 保存到 IndexedDB')
|
|
lastSavedContent = current
|
|
await saveVersion('手动编辑')
|
|
}, 3000)
|
|
}
|
|
|
|
async function autoSave() {
|
|
try {
|
|
await fetch('/api/save', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ path: sourcePath.value, data: gameData.value }),
|
|
})
|
|
} catch { /* dev server not running */ }
|
|
debouncedSaveVersion()
|
|
}
|
|
|
|
async function saveVersion(label: string) {
|
|
try {
|
|
await putVersion({
|
|
sourcePath: sourcePath.value,
|
|
timestamp: Date.now(),
|
|
label,
|
|
gameData: JSON.parse(JSON.stringify(gameData.value)),
|
|
})
|
|
await loadVersions()
|
|
} catch {}
|
|
}
|
|
|
|
async function restoreVersion(idx: number) {
|
|
const v = versions.value[idx]
|
|
if (!v) return
|
|
gameData.value = v.gameData
|
|
startSceneId.value = v.gameData.startScene || ''
|
|
selectedNodeId.value = null
|
|
aiChanges.value = null
|
|
dirty.value = false
|
|
lastSavedContent = JSON.stringify(v.gameData)
|
|
if (saveVersionTimer) { clearTimeout(saveVersionTimer); saveVersionTimer = null }
|
|
autoSave()
|
|
}
|
|
|
|
async function loadVersions() {
|
|
try {
|
|
versions.value = await getVersions(sourcePath.value)
|
|
} catch {
|
|
versions.value = []
|
|
}
|
|
}
|
|
|
|
function clearAIMarkers() {
|
|
aiChanges.value = null
|
|
}
|
|
|
|
async function reloadFromDisk(oldGameData?: GameData) {
|
|
try {
|
|
const resp = await fetch(sourcePath.value)
|
|
const newData = await resp.json()
|
|
if (oldGameData) {
|
|
const { scenes: newScenes, ...newGlobal } = newData
|
|
const { scenes: oldScenes, ...oldGlobal } = oldGameData
|
|
const diff: AIDiff = { added: [], modified: [], deleted: [], globalFields: [] }
|
|
for (const id of Object.keys(newScenes)) {
|
|
if (!oldScenes[id]) diff.added.push(id)
|
|
else if (JSON.stringify(oldScenes[id]) !== JSON.stringify(newScenes[id])) diff.modified.push(id)
|
|
}
|
|
for (const id of Object.keys(oldScenes)) {
|
|
if (!newScenes[id]) diff.deleted.push(id)
|
|
}
|
|
for (const key of Object.keys({ ...(oldGlobal as any), ...(newGlobal as any) })) {
|
|
if (JSON.stringify((oldGlobal as any)[key]) !== JSON.stringify((newGlobal as any)[key])) {
|
|
diff.globalFields.push(key)
|
|
}
|
|
}
|
|
aiChanges.value = diff
|
|
}
|
|
gameData.value = newData
|
|
selectedNodeId.value = null
|
|
clearAISession()
|
|
} catch { /* failed to reload */ }
|
|
}
|
|
|
|
return {
|
|
gameData, selectedNodeId, selectedScene, startSceneId, dirty, sourcePath,
|
|
deepseekKey, showAIPanel, aiSessionId, aiCollapsed, nodeEditorCollapsed, aiChanges, versions,
|
|
markDirty, loadJSON, exportJSON, addScene, deleteScene,
|
|
updateScene, addChoice, updateChoice, deleteChoice, generateId,
|
|
setSourcePath, setDeepseekKey, setAISessionId, clearAISession, autoSave, reloadFromDisk,
|
|
saveVersion, restoreVersion, loadVersions, clearAIMarkers,
|
|
}
|
|
})
|