292 lines
9.5 KiB
TypeScript
292 lines
9.5 KiB
TypeScript
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<string, Record<string, NodePosition>>;
|
|
playModeActive: boolean;
|
|
playState: { currentNodeId: string; flags: Record<string, number | string> } | null;
|
|
persistentFlags: Record<string, number | string>;
|
|
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<DialogueNode>) => 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<DialogueStore>()(
|
|
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 = {};
|
|
}),
|
|
}))
|
|
);
|