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

+32
View File
@@ -0,0 +1,32 @@
import { DialogueFile, Dialogue } from '../types/dialogue';
export function parseDialogueFile(json: string): DialogueFile {
const parsed = JSON.parse(json);
if (!Array.isArray(parsed.dialogues)) {
throw new Error('Invalid file: missing dialogues array');
}
if (!Array.isArray(parsed.cutscenes)) {
parsed.cutscenes = [];
}
return parsed as DialogueFile;
}
export function serializeDialogueFile(file: DialogueFile): string {
const clone = structuredClone(file);
clone.dialogues.forEach((d: Dialogue & { mobileMode?: boolean }) => {
delete d.mobileMode;
});
return JSON.stringify(clone, null, '\t');
}
export function triggerDownload(content: string, filename: string): void {
const blob = new Blob([content], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
+14
View File
@@ -0,0 +1,14 @@
import { NodeType } from '../types/dialogue';
export function makeNodeId(type: NodeType, existingIds: Set<string>): string {
const base = type.toLowerCase();
let n = 1;
while (existingIds.has(`${base}_${n}`)) n++;
return `${base}_${n}`;
}
export function makeDialogueId(existingIds: Set<string>): string {
let n = 1;
while (existingIds.has(`dialog_new${n}`)) n++;
return `dialog_new${n}`;
}
+53
View File
@@ -0,0 +1,53 @@
import dagre from '@dagrejs/dagre';
import { DialogueNode } from '../types/dialogue';
export type PositionMap = Record<string, { x: number; y: number }>;
const NODE_WIDTH = 260;
const NODE_HEIGHT = 130;
export function computeLayout(nodes: DialogueNode[]): PositionMap {
const g = new dagre.graphlib.Graph();
g.setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir: 'TB', ranksep: 60, nodesep: 50 });
nodes.forEach(n => {
const h = n.type === 'Choice'
? NODE_HEIGHT + Math.max(0, n.choices.length - 1) * 28
: NODE_HEIGHT;
g.setNode(n.id, { width: NODE_WIDTH, height: h });
});
nodes.forEach(n => {
switch (n.type) {
case 'Line':
case 'SetFlag':
case 'CutsceneStart':
if (n.next) g.setEdge(n.id, n.next);
break;
case 'Choice':
n.choices.forEach(c => { if (c.next) g.setEdge(n.id, c.next); });
break;
case 'Condition':
if (n.trueNext) g.setEdge(n.id, n.trueNext);
if (n.falseNext) g.setEdge(n.id, n.falseNext);
break;
}
});
dagre.layout(g);
const positions: PositionMap = {};
nodes.forEach(n => {
const pos = g.node(n.id);
if (pos) {
const h = n.type === 'Choice'
? NODE_HEIGHT + Math.max(0, n.choices.length - 1) * 28
: NODE_HEIGHT;
positions[n.id] = { x: pos.x - NODE_WIDTH / 2, y: pos.y - h / 2 };
} else {
positions[n.id] = { x: 0, y: 0 };
}
});
return positions;
}
+20
View File
@@ -0,0 +1,20 @@
import { Dialogue, DialogueNode } from '../types/dialogue';
import { MAIN_CHARACTER, PHONE_PORTRAIT } from '../constants/characters';
export function applyMobileTransforms(dialogue: Dialogue): Dialogue {
const clone = structuredClone(dialogue);
clone.nodes = clone.nodes.map((node): DialogueNode => {
if (node.type === 'Line') {
return {
...node,
portrait: PHONE_PORTRAIT,
chatBubble: node.chatBubble ?? (node.speaker === MAIN_CHARACTER ? 'out' : 'in'),
};
}
if (node.type === 'Choice') {
return { ...node, portrait: PHONE_PORTRAIT };
}
return node;
});
return clone;
}
+108
View File
@@ -0,0 +1,108 @@
import { Dialogue, DialogueNode, ValidationIssue } from '../types/dialogue';
function getOutgoingNodeIds(node: DialogueNode): string[] {
switch (node.type) {
case 'Line':
case 'SetFlag':
case 'CutsceneStart':
return node.next ? [node.next] : [];
case 'Choice':
return node.choices.map(c => c.next).filter(Boolean);
case 'Condition':
return [node.trueNext, node.falseNext].filter(Boolean);
case 'End':
return [];
}
}
function reachableFrom(startId: string, nodeMap: Map<string, DialogueNode>): Set<string> {
const visited = new Set<string>();
const stack = [startId];
while (stack.length > 0) {
const id = stack.pop()!;
if (visited.has(id)) continue;
visited.add(id);
const node = nodeMap.get(id);
if (!node) continue;
for (const next of getOutgoingNodeIds(node)) {
if (!visited.has(next)) stack.push(next);
}
}
return visited;
}
export function validateDialogue(dialogue: Dialogue): ValidationIssue[] {
const issues: ValidationIssue[] = [];
const nodeMap = new Map<string, DialogueNode>();
const duplicates = new Set<string>();
// 1. Duplicate IDs
for (const node of dialogue.nodes) {
if (nodeMap.has(node.id)) {
duplicates.add(node.id);
issues.push({ nodeId: node.id, severity: 'error', message: `Duplicate node ID: "${node.id}"` });
} else {
nodeMap.set(node.id, node);
}
}
// 2. Missing start node
if (dialogue.start && !nodeMap.has(dialogue.start)) {
issues.push({ nodeId: '__dialogue__', severity: 'error', message: `Start node "${dialogue.start}" does not exist` });
}
// 3. Broken references
for (const node of dialogue.nodes) {
if (duplicates.has(node.id)) continue;
if ((node.type === 'Line' || node.type === 'SetFlag' || node.type === 'CutsceneStart') && node.next) {
if (!nodeMap.has(node.next)) {
issues.push({ nodeId: node.id, severity: 'error', message: `"next" points to missing node "${node.next}"` });
}
}
if (node.type === 'Choice') {
for (const choice of node.choices) {
if (choice.next && !nodeMap.has(choice.next)) {
issues.push({ nodeId: node.id, severity: 'error', message: `Choice "${choice.text}" points to missing node "${choice.next}"` });
}
}
}
if (node.type === 'Condition') {
if (node.trueNext && !nodeMap.has(node.trueNext)) {
issues.push({ nodeId: node.id, severity: 'error', message: `"trueNext" points to missing node "${node.trueNext}"` });
}
if (node.falseNext && !nodeMap.has(node.falseNext)) {
issues.push({ nodeId: node.id, severity: 'error', message: `"falseNext" points to missing node "${node.falseNext}"` });
}
}
}
// 4. Reachability from start
if (dialogue.start && nodeMap.has(dialogue.start)) {
const reachable = reachableFrom(dialogue.start, nodeMap);
// No End reachable
const hasEnd = [...reachable].some(id => nodeMap.get(id)?.type === 'End');
if (!hasEnd) {
issues.push({ nodeId: dialogue.start, severity: 'warning', message: 'No End node is reachable from the start' });
}
// Orphaned nodes
for (const node of dialogue.nodes) {
if (!reachable.has(node.id) && !duplicates.has(node.id)) {
issues.push({ nodeId: node.id, severity: 'warning', message: 'Node is not reachable from the start' });
}
}
}
// 5. Empty text warnings
for (const node of dialogue.nodes) {
if ((node.type === 'Line' || node.type === 'Choice') && !node.text.trim()) {
issues.push({ nodeId: node.id, severity: 'warning', message: 'Node has empty text' });
}
}
return issues;
}