Working on dialog editor, on dialogs, on phone

This commit is contained in:
Vladislav Khorev committed 2026-06-05 21:17:51 +03:00
1 parent 0e27521bea
commit 8f7797789d
63 files changed
+9502 -1459

No files matched your search

+291
View File
@@ -0,0 +1,291 @@
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 = {};
}),
}))
);