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({ scenes: {}, startScene: '', variables: {} }) const selectedNodeId = ref(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(null) const versions = ref([]) 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) { 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) { 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 | null = null let lastSavedContent = '' function debouncedSaveVersion() { const current = JSON.stringify(gameData.value) if (current === lastSavedContent) return if (saveVersionTimer) clearTimeout(saveVersionTimer) saveVersionTimer = setTimeout(async () => { 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 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, } })