import { create } from 'zustand'; import { immer } from 'zustand/middleware/immer'; import { DialogueFile, Dialogue, DialogueNode, ValidationIssue, EndNode, } from '../types/dialogue'; import { computeLayout, PositionMap } from '../utils/layout'; import { validateDialogue } from '../utils/validation'; import { applyMobileTransforms } from '../utils/mobileMode'; import { serializeDialogueFile, triggerDownload } from '../utils/fileIO'; import { makeNodeId } from '../utils/idGen'; interface NodePosition { x: number; y: number } interface DialogueStore { file: DialogueFile | null; fileName: string; selectedDialogueId: string | null; selectedNodeId: string | null; positions: Record>; playModeActive: boolean; playState: { currentNodeId: string; flags: Record } | null; persistentFlags: Record; validationIssues: ValidationIssue[]; loadFile: (file: DialogueFile, name: string) => void; exportFile: () => void; selectDialogue: (id: string) => void; createDialogue: (id: string) => void; deleteDialogue: (id: string) => void; duplicateDialogue: (id: string) => void; setDialogueMobileMode: (id: string, value: boolean) => void; selectNode: (id: string | null) => void; updateNode: (dialogueId: string, nodeId: string, patch: Partial) => void; addNode: (dialogueId: string, node: DialogueNode, afterNodeId?: string) => void; deleteNode: (dialogueId: string, nodeId: string) => void; setNodePosition: (dialogueId: string, nodeId: string, pos: NodePosition) => void; applyAutoLayout: (dialogueId: string) => void; startPlay: () => void; stopPlay: () => void; advancePlay: (nextNodeId: string) => void; setPlayFlag: (flag: string, value: number | string) => void; resetFlags: () => void; } function getDialogue(file: DialogueFile | null, id: string | null): Dialogue | undefined { if (!file || !id) return undefined; return file.dialogues.find(d => d.id === id); } function revalidate(state: DialogueStore): void { const d = getDialogue(state.file, state.selectedDialogueId); state.validationIssues = d ? validateDialogue(d) : []; } export const useDialogueStore = create()( immer((set, get) => ({ file: null, fileName: 'dialogues.json', selectedDialogueId: null, selectedNodeId: null, positions: {}, playModeActive: false, playState: null, persistentFlags: {}, validationIssues: [], loadFile: (file, name) => set(state => { state.file = file; state.fileName = name; state.selectedDialogueId = file.dialogues[0]?.id ?? null; state.selectedNodeId = null; state.positions = {}; state.playModeActive = false; state.playState = null; revalidate(state); }), exportFile: () => { const { file, fileName } = get(); if (!file) return; const clone = structuredClone(file); clone.dialogues = clone.dialogues.map(d => d.mobileMode ? applyMobileTransforms(d) : d ); const json = serializeDialogueFile(clone); triggerDownload(json, fileName); }, selectDialogue: (id) => set(state => { state.selectedDialogueId = id; state.selectedNodeId = null; revalidate(state); }), createDialogue: (id) => set(state => { if (!state.file) return; const endNode: EndNode = { id: 'end_1', type: 'End' }; const newDialogue: Dialogue = { id, start: 'line_1', nodes: [ { id: 'line_1', type: 'Line', speaker: 'Бекзат', portrait: 'resources/dialogue/portrait_hero_neutral.png', text: '', next: 'end_1' }, endNode, ], }; state.file.dialogues.push(newDialogue); state.selectedDialogueId = id; state.selectedNodeId = null; revalidate(state); }), duplicateDialogue: (id) => { // Read raw data outside immer so structuredClone works on plain objects const { file, positions } = get(); if (!file) return; const source = file.dialogues.find(d => d.id === id); if (!source) return; const existingIds = new Set(file.dialogues.map(d => d.id)); const match = source.id.match(/^(.*?)(\d+)$/); let newId: string; if (match) { const base = match[1]; const numStr = match[2]; let n = parseInt(numStr, 10) + 1; const pad = numStr.length; while (existingIds.has(`${base}${String(n).padStart(pad, '0')}`)) n++; newId = `${base}${String(n).padStart(pad, '0')}`; } else { let n = 2; while (existingIds.has(`${source.id}_${n}`)) n++; newId = `${source.id}_${n}`; } const clonedDialogue = structuredClone(source); clonedDialogue.id = newId; const clonedPositions = positions[id] ? structuredClone(positions[id]) : undefined; set(state => { state.file!.dialogues.push(clonedDialogue); if (clonedPositions) state.positions[newId] = clonedPositions; state.selectedDialogueId = newId; state.selectedNodeId = null; revalidate(state); }); }, deleteDialogue: (id) => set(state => { if (!state.file) return; state.file.dialogues = state.file.dialogues.filter(d => d.id !== id); if (state.selectedDialogueId === id) { state.selectedDialogueId = state.file.dialogues[0]?.id ?? null; state.selectedNodeId = null; } delete state.positions[id]; revalidate(state); }), setDialogueMobileMode: (id, value) => set(state => { if (!state.file) return; const d = state.file.dialogues.find(x => x.id === id); if (d) d.mobileMode = value; }), selectNode: (id) => set(state => { state.selectedNodeId = id; }), updateNode: (dialogueId, nodeId, patch) => set(state => { if (!state.file) return; const d = state.file.dialogues.find(x => x.id === dialogueId); if (!d) return; const idx = d.nodes.findIndex(n => n.id === nodeId); if (idx === -1) return; Object.assign(d.nodes[idx], patch); revalidate(state); }), addNode: (dialogueId, node, afterNodeId) => set(state => { if (!state.file) return; const d = state.file.dialogues.find(x => x.id === dialogueId); if (!d) return; // Ensure unique ID const existingIds = new Set(d.nodes.map(n => n.id)); if (existingIds.has(node.id)) { node = { ...node, id: makeNodeId(node.type, existingIds) }; } if (afterNodeId) { const idx = d.nodes.findIndex(n => n.id === afterNodeId); if (idx !== -1) { const prev = d.nodes[idx]; // Wire: prev.next → new node → old prev.next if ( prev.type === 'Line' || prev.type === 'SetFlag' || prev.type === 'CutsceneStart' ) { const oldNext = prev.next; (d.nodes[idx] as typeof prev).next = node.id; if ( (node.type === 'Line' || node.type === 'SetFlag' || node.type === 'CutsceneStart') && oldNext ) { (node as typeof prev).next = oldNext; } } d.nodes.splice(idx + 1, 0, node); } else { d.nodes.push(node); } } else { d.nodes.push(node); } // Place new node just below the anchor node in the graph const anchorId = afterNodeId ?? null; if (anchorId) { const anchorPos = state.positions[dialogueId]?.[anchorId]; if (anchorPos) { if (!state.positions[dialogueId]) state.positions[dialogueId] = {}; state.positions[dialogueId][node.id] = { x: anchorPos.x, y: anchorPos.y + 200 }; } } revalidate(state); }), deleteNode: (dialogueId, nodeId) => set(state => { if (!state.file) return; const d = state.file.dialogues.find(x => x.id === dialogueId); if (!d) return; d.nodes = d.nodes.filter(n => n.id !== nodeId); if (state.selectedNodeId === nodeId) state.selectedNodeId = null; delete state.positions[dialogueId]?.[nodeId]; revalidate(state); }), setNodePosition: (dialogueId, nodeId, pos) => set(state => { if (!state.positions[dialogueId]) state.positions[dialogueId] = {}; state.positions[dialogueId][nodeId] = pos; }), applyAutoLayout: (dialogueId) => set(state => { if (!state.file) return; const d = state.file.dialogues.find(x => x.id === dialogueId); if (!d) return; const layout: PositionMap = computeLayout(d.nodes); if (!state.positions[dialogueId]) state.positions[dialogueId] = {}; Object.assign(state.positions[dialogueId], layout); }), startPlay: () => set(state => { const d = getDialogue(state.file, state.selectedDialogueId); if (!d) return; state.playModeActive = true; state.playState = { currentNodeId: d.start, flags: { ...state.persistentFlags } }; }), stopPlay: () => set(state => { state.playModeActive = false; state.playState = null; }), advancePlay: (nextNodeId) => set(state => { if (!state.playState) return; state.playState.currentNodeId = nextNodeId; }), setPlayFlag: (flag, value) => set(state => { if (!state.playState) return; state.playState.flags[flag] = value; state.persistentFlags[flag] = value; }), resetFlags: () => set(state => { state.persistentFlags = {}; if (state.playState) state.playState.flags = {}; }), })) );