Working on dialog editor, on dialogs, on phone
This commit is contained in:
1 parent
0e27521bea
commit
8f7797789d
63 files changed
+9502
-1459
No files matched your search
@@ -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 = {};
|
||||
}),
|
||||
}))
|
||||
);
|
||||
Reference in new issue
Block a user