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,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);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user