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

+5
View File
@@ -0,0 +1,5 @@
.app {
display: flex;
height: 100%;
overflow: hidden;
}
+19
View File
@@ -0,0 +1,19 @@
import { LeftPanel } from './components/LeftPanel/LeftPanel';
import { GraphPanel } from './components/GraphPanel/GraphPanel';
import { RightPanel } from './components/RightPanel/RightPanel';
import { PlayModeOverlay } from './components/PlayMode/PlayModeOverlay';
import { useDialogueStore } from './store/dialogueStore';
import styles from './App.module.css';
export default function App() {
const playModeActive = useDialogueStore(s => s.playModeActive);
return (
<div className={styles.app}>
<LeftPanel />
<GraphPanel />
<RightPanel />
{playModeActive && <PlayModeOverlay />}
</div>
);
}
@@ -0,0 +1,26 @@
.container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: #1e1e2e;
}
.flow {
flex: 1;
overflow: hidden;
}
.empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #1e1e2e;
}
.emptyMsg {
color: #6c7086;
font-size: 14px;
text-align: center;
}
@@ -0,0 +1,185 @@
import { useCallback, useEffect, useMemo } from 'react';
import {
ReactFlow,
Background,
Controls,
MiniMap,
NodeChange,
Node,
Edge,
Connection,
MarkerType,
} from '@xyflow/react';
import { useDialogueStore } from '../../store/dialogueStore';
import { nodeTypes } from '../nodes/nodeTypes';
import { DialogueNode, ChoiceNode } from '../../types/dialogue';
import { GraphToolbar } from './GraphToolbar';
import styles from './GraphPanel.module.css';
function buildEdges(nodes: DialogueNode[]): Edge[] {
const edges: Edge[] = [];
for (const node of nodes) {
const base = {
markerEnd: { type: MarkerType.ArrowClosed, color: '#6c7086' },
style: { stroke: '#6c7086', strokeWidth: 1.5 },
animated: false,
};
if (node.type === 'Line' || node.type === 'SetFlag' || node.type === 'CutsceneStart') {
if (node.next) {
edges.push({ ...base, id: `${node.id}->source`, source: node.id, target: node.next, sourceHandle: 'source', targetHandle: 'target' });
}
} else if (node.type === 'Choice') {
for (const choice of node.choices) {
if (choice.next) {
edges.push({ ...base, id: `${node.id}->${choice.id}`, source: node.id, target: choice.next, sourceHandle: choice.id, targetHandle: 'target', label: choice.text.slice(0, 20), labelStyle: { fontSize: 10, fill: '#cdd6f4' }, labelBgStyle: { fill: '#181825' } });
}
}
} else if (node.type === 'Condition') {
if (node.trueNext) {
edges.push({ ...base, id: `${node.id}->true`, source: node.id, target: node.trueNext, sourceHandle: 'true', targetHandle: 'target', label: 'TRUE', labelStyle: { fontSize: 10, fill: '#a6e3a1' }, labelBgStyle: { fill: '#181825' }, style: { ...base.style, stroke: '#a6e3a1' }, markerEnd: { type: MarkerType.ArrowClosed, color: '#a6e3a1' } });
}
if (node.falseNext) {
edges.push({ ...base, id: `${node.id}->false`, source: node.id, target: node.falseNext, sourceHandle: 'false', targetHandle: 'target', label: 'FALSE', labelStyle: { fontSize: 10, fill: '#f38ba8' }, labelBgStyle: { fill: '#181825' }, style: { ...base.style, stroke: '#f38ba8' }, markerEnd: { type: MarkerType.ArrowClosed, color: '#f38ba8' } });
}
}
}
return edges;
}
export function GraphPanel() {
const {
file,
selectedDialogueId,
selectedNodeId,
positions,
selectNode,
setNodePosition,
applyAutoLayout,
updateNode,
} = useDialogueStore();
const dialogue = file?.dialogues.find(d => d.id === selectedDialogueId);
const dialoguePositions = positions[selectedDialogueId ?? ''] ?? {};
const rfNodes: Node[] = useMemo(() => {
if (!dialogue) return [];
return dialogue.nodes.map(node => ({
id: node.id,
type: node.type,
data: node as unknown as Record<string, unknown>,
position: dialoguePositions[node.id] ?? { x: 0, y: 0 },
selected: node.id === selectedNodeId,
}));
}, [dialogue, dialoguePositions, selectedNodeId]);
const rfEdges: Edge[] = useMemo(() => {
if (!dialogue) return [];
return buildEdges(dialogue.nodes);
}, [dialogue]);
const onNodesChange = useCallback((changes: NodeChange[]) => {
if (!selectedDialogueId) return;
for (const change of changes) {
if (change.type === 'position' && change.position) {
setNodePosition(selectedDialogueId, change.id, change.position);
}
}
}, [selectedDialogueId, setNodePosition]);
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
selectNode(node.id);
}, [selectNode]);
const onPaneClick = useCallback(() => {
selectNode(null);
}, [selectNode]);
const onConnect = useCallback((connection: Connection) => {
if (!selectedDialogueId || !connection.source || !connection.target) return;
const dialogue = file?.dialogues.find(d => d.id === selectedDialogueId);
if (!dialogue) return;
const sourceNode = dialogue.nodes.find(n => n.id === connection.source);
if (!sourceNode) return;
const handle = connection.sourceHandle;
if (sourceNode.type === 'Line' || sourceNode.type === 'SetFlag' || sourceNode.type === 'CutsceneStart') {
updateNode(selectedDialogueId, sourceNode.id, { next: connection.target } as Partial<DialogueNode>);
} else if (sourceNode.type === 'Condition') {
if (handle === 'true') {
updateNode(selectedDialogueId, sourceNode.id, { trueNext: connection.target } as Partial<DialogueNode>);
} else if (handle === 'false') {
updateNode(selectedDialogueId, sourceNode.id, { falseNext: connection.target } as Partial<DialogueNode>);
}
} else if (sourceNode.type === 'Choice') {
const choices = (sourceNode as ChoiceNode).choices.map(c =>
c.id === handle ? { ...c, next: connection.target! } : c
);
updateNode(selectedDialogueId, sourceNode.id, { choices } as Partial<DialogueNode>);
}
}, [selectedDialogueId, file, updateNode]);
// Trigger auto-layout when dialogue is first loaded with no positions
useEffect(() => {
if (dialogue && dialogue.nodes.length > 0 && !Object.keys(dialoguePositions).length && selectedDialogueId) {
applyAutoLayout(selectedDialogueId);
}
}, [selectedDialogueId]);
if (!file) {
return (
<div className={styles.empty}>
<div className={styles.emptyMsg}>Load a dialogue JSON file to get started</div>
</div>
);
}
if (!dialogue) {
return (
<div className={styles.empty}>
<div className={styles.emptyMsg}>Select a dialogue from the left panel</div>
</div>
);
}
return (
<div className={styles.container}>
<GraphToolbar />
<div className={styles.flow}>
<ReactFlow
key={selectedDialogueId}
nodes={rfNodes}
edges={rfEdges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
fitView
fitViewOptions={{ padding: 0.2 }}
deleteKeyCode={null}
proOptions={{ hideAttribution: true }}
>
<Background color="#313244" gap={20} />
<Controls />
<MiniMap
nodeColor={(n) => {
switch (n.type) {
case 'Line': return '#1e66f5';
case 'Choice': return '#df8e1d';
case 'Condition': return '#8839ef';
case 'SetFlag': return '#179299';
case 'CutsceneStart': return '#4a4a5a';
case 'End': return '#f38ba8';
default: return '#6c7086';
}
}}
style={{ background: '#181825', border: '1px solid #313244' }}
/>
</ReactFlow>
</div>
</div>
);
}
@@ -0,0 +1,101 @@
.toolbar {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: #181825;
border-bottom: 1px solid #313244;
flex-wrap: wrap;
min-height: 40px;
}
.group {
display: flex;
align-items: center;
gap: 4px;
}
.label {
font-size: 11px;
color: #6c7086;
margin-right: 2px;
}
.btn {
background: #313244;
color: #cdd6f4;
border: none;
border-radius: 4px;
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
}
.btn:hover {
background: #45475a;
}
.btnPlay {
background: #40a02b;
color: #fff;
}
.btnPlay:hover {
background: #37872b;
}
.separator {
width: 1px;
height: 20px;
background: #313244;
margin: 0 2px;
}
.toggle {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 12px;
color: #cdd6f4;
}
.toggle input {
cursor: pointer;
}
.mobileBanner {
background: #2a1f00;
color: #f9e2af;
font-size: 11px;
padding: 3px 10px;
border-radius: 4px;
border: 1px solid #f9e2af44;
}
.flagPill {
background: rgba(137, 180, 250, 0.15);
color: #89b4fa;
border-radius: 3px;
padding: 2px 6px;
font-size: 10px;
font-family: monospace;
white-space: nowrap;
}
.btnReset {
background: #45475a;
color: #f9e2af;
border: none;
border-radius: 4px;
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
white-space: nowrap;
}
.btnReset:hover {
background: #585b70;
}
@@ -0,0 +1,104 @@
import { useDialogueStore } from '../../store/dialogueStore';
import { makeNodeId } from '../../utils/idGen';
import { DialogueNode, NodeType } from '../../types/dialogue';
import { MAIN_CHARACTER } from '../../constants/characters';
import styles from './GraphToolbar.module.css';
const NODE_DEFAULTS: Record<string, () => Omit<DialogueNode, 'id'>> = {
Line: () => ({
type: 'Line',
speaker: MAIN_CHARACTER,
portrait: 'resources/dialogue/portrait_hero_neutral.png',
text: '',
next: '',
}),
Choice: () => ({
type: 'Choice',
speaker: MAIN_CHARACTER,
portrait: 'resources/dialogue/portrait_hero_neutral.png',
text: '',
choices: [],
}),
Condition: () => ({
type: 'Condition',
conditions: [],
trueNext: '',
falseNext: '',
}),
SetFlag: () => ({
type: 'SetFlag',
effects: [],
next: '',
}),
CutsceneStart: () => ({
type: 'CutsceneStart',
cutsceneId: '',
next: '',
}),
End: () => ({ type: 'End' }),
};
export function GraphToolbar() {
const { file, selectedDialogueId, selectedNodeId, addNode, applyAutoLayout, startPlay, setDialogueMobileMode, persistentFlags, resetFlags } = useDialogueStore();
const dialogue = file?.dialogues.find(d => d.id === selectedDialogueId);
if (!dialogue) return null;
function handleAddNode(type: NodeType) {
if (!selectedDialogueId || !dialogue) return;
const existingIds = new Set(dialogue.nodes.map(n => n.id));
const id = makeNodeId(type, existingIds);
const node = { id, ...NODE_DEFAULTS[type]() } as DialogueNode;
addNode(selectedDialogueId, node, selectedNodeId ?? undefined);
}
return (
<div className={styles.toolbar}>
<div className={styles.group}>
<span className={styles.label}>Add:</span>
{(['Line', 'Choice', 'Condition', 'SetFlag', 'CutsceneStart', 'End'] as NodeType[]).map(type => (
<button key={type} className={styles.btn} onClick={() => handleAddNode(type)}>
{type}
</button>
))}
</div>
<div className={styles.separator} />
<div className={styles.group}>
<button className={styles.btn} onClick={() => applyAutoLayout(selectedDialogueId!)}>
⬡ Auto Layout
</button>
<button className={[styles.btn, styles.btnPlay].join(' ')} onClick={startPlay}>
▶ Play
</button>
</div>
<div className={styles.separator} />
<div className={styles.group}>
<label className={styles.toggle}>
<input
type="checkbox"
checked={!!dialogue.mobileMode}
onChange={e => setDialogueMobileMode(selectedDialogueId!, e.target.checked)}
/>
<span>📱 Mobile</span>
</label>
</div>
{dialogue.mobileMode && (
<div className={styles.mobileBanner}>
Mobile mode — portraits will be overridden on export
</div>
)}
{Object.keys(persistentFlags).length > 0 && (
<>
<div className={styles.separator} />
<div className={styles.group}>
<span className={styles.label}>🚩 Flags:</span>
{Object.entries(persistentFlags).map(([k, v]) => (
<span key={k} className={styles.flagPill}>{k}={v}</span>
))}
<button className={styles.btnReset} onClick={resetFlags}>Reset</button>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,185 @@
.panel {
width: 220px;
min-width: 220px;
background: #181825;
border-right: 1px solid #313244;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.header {
padding: 12px 12px 6px;
border-bottom: 1px solid #313244;
}
.title {
font-size: 13px;
font-weight: 700;
color: #cdd6f4;
display: block;
}
.fileName {
font-size: 10px;
color: #6c7086;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fileButtons {
display: flex;
gap: 6px;
padding: 8px 10px;
border-bottom: 1px solid #313244;
}
.btn {
flex: 1;
background: #313244;
color: #cdd6f4;
border: none;
border-radius: 4px;
padding: 5px 8px;
font-size: 12px;
cursor: pointer;
transition: background 0.15s;
}
.btn:hover {
background: #45475a;
}
.btnSave {
background: #1e66f5;
color: #fff;
}
.btnSave:hover {
background: #2779e4;
}
.btnNew {
background: #40a02b;
color: #fff;
}
.btnNew:hover {
background: #37872b;
}
.errorMsg {
background: #2a0e14;
color: #f38ba8;
font-size: 11px;
padding: 6px 10px;
border-top: 1px solid #f38ba8;
}
.list {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.emptyMsg {
padding: 16px 12px;
color: #6c7086;
font-size: 12px;
text-align: center;
}
.dialogueItem {
display: flex;
align-items: center;
padding: 6px 10px;
cursor: pointer;
border-radius: 4px;
margin: 1px 4px;
transition: background 0.1s;
}
.dialogueItem:hover {
background: #313244;
}
.dialogueItem.active {
background: #1e3a5f;
}
.dialogueId {
flex: 1;
font-size: 11px;
font-family: monospace;
color: #cdd6f4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: flex;
align-items: center;
gap: 4px;
}
.errDot { color: #f38ba8; font-size: 9px; }
.warnDot { color: #f9e2af; font-size: 9px; }
.mobileDot { font-size: 10px; }
.copyBtn {
background: none;
border: none;
color: #6c7086;
cursor: pointer;
font-size: 13px;
line-height: 1;
padding: 0 2px;
flex-shrink: 0;
}
.copyBtn:hover {
color: #89b4fa;
}
.deleteBtn {
background: none;
border: none;
color: #6c7086;
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 0 2px;
flex-shrink: 0;
}
.deleteBtn:hover {
color: #f38ba8;
}
.footer {
padding: 8px;
border-top: 1px solid #313244;
}
.createForm {
display: flex;
gap: 4px;
}
.createInput {
flex: 1;
background: #313244;
border: 1px solid #45475a;
border-radius: 4px;
color: #cdd6f4;
font-size: 11px;
padding: 4px 6px;
min-width: 0;
font-family: monospace;
}
.createInput:focus {
outline: none;
border-color: #89b4fa;
}
@@ -0,0 +1,156 @@
import { useRef, useState } from 'react';
import { useDialogueStore } from '../../store/dialogueStore';
import { parseDialogueFile } from '../../utils/fileIO';
import { useValidation } from '../../hooks/useValidation';
import styles from './LeftPanel.module.css';
export function LeftPanel() {
const { file, fileName, selectedDialogueId, loadFile, selectDialogue, createDialogue, deleteDialogue, duplicateDialogue, exportFile } = useDialogueStore();
const { issuesByNodeId } = useValidation();
const fileInputRef = useRef<HTMLInputElement>(null);
const [newId, setNewId] = useState('');
const [creating, setCreating] = useState(false);
const [error, setError] = useState('');
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0];
if (!f) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const parsed = parseDialogueFile(ev.target?.result as string);
loadFile(parsed, f.name);
setError('');
} catch (err) {
setError(String(err));
}
};
reader.readAsText(f);
e.target.value = '';
}
function handleCreate() {
if (!newId.trim()) return;
if (file?.dialogues.some(d => d.id === newId.trim())) {
setError(`ID "${newId.trim()}" already exists`);
return;
}
createDialogue(newId.trim());
setNewId('');
setCreating(false);
setError('');
}
function dialogueHasIssues(dialogueId: string) {
// Simple check: are there any issues for nodes in this dialogue?
// We only have current dialogue issues so we check if this is selected
return selectedDialogueId === dialogueId && Object.keys(issuesByNodeId).length > 0;
}
function dialogueHasErrors(dialogueId: string) {
return selectedDialogueId === dialogueId &&
Object.values(issuesByNodeId).some(list => list.some(i => i.severity === 'error'));
}
return (
<div className={styles.panel}>
<div className={styles.header}>
<span className={styles.title}>Dialogues</span>
{file && <span className={styles.fileName}>{fileName}</span>}
</div>
<div className={styles.fileButtons}>
<button className={styles.btn} onClick={() => fileInputRef.current?.click()}>
📂 Load
</button>
<input
ref={fileInputRef}
type="file"
accept=".json"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
{file && (
<button className={[styles.btn, styles.btnSave].join(' ')} onClick={exportFile}>
💾 Save
</button>
)}
</div>
{error && <div className={styles.errorMsg}>{error}</div>}
<div className={styles.list}>
{!file && (
<div className={styles.emptyMsg}>Load a JSON file to start</div>
)}
{file?.dialogues.map(d => {
const hasErr = dialogueHasErrors(d.id);
const hasWarn = !hasErr && dialogueHasIssues(d.id);
return (
<div
key={d.id}
className={[
styles.dialogueItem,
d.id === selectedDialogueId ? styles.active : '',
].join(' ')}
onClick={() => selectDialogue(d.id)}
>
<span className={styles.dialogueId}>
{hasErr && <span className={styles.errDot} title="Has errors">●</span>}
{hasWarn && <span className={styles.warnDot} title="Has warnings">●</span>}
{d.mobileMode && <span className={styles.mobileDot} title="Mobile mode">📱</span>}
{d.id}
</span>
<button
className={styles.copyBtn}
title="Duplicate dialogue"
onClick={(e) => {
e.stopPropagation();
duplicateDialogue(d.id);
}}
>
⧉
</button>
<button
className={styles.deleteBtn}
title="Delete dialogue"
onClick={(e) => {
e.stopPropagation();
if (confirm(`Delete "${d.id}"?`)) deleteDialogue(d.id);
}}
>
×
</button>
</div>
);
})}
</div>
<div className={styles.footer}>
{creating ? (
<div className={styles.createForm}>
<input
className={styles.createInput}
value={newId}
onChange={e => setNewId(e.target.value)}
placeholder="dialogue_id"
onKeyDown={e => {
if (e.key === 'Enter') handleCreate();
if (e.key === 'Escape') { setCreating(false); setNewId(''); }
}}
autoFocus
/>
<button className={[styles.btn, styles.btnSave].join(' ')} onClick={handleCreate}>✓</button>
<button className={styles.btn} onClick={() => { setCreating(false); setNewId(''); }}>✗</button>
</div>
) : (
file && (
<button className={[styles.btn, styles.btnNew].join(' ')} onClick={() => setCreating(true)}>
+ New Dialogue
</button>
)
)}
</div>
</div>
);
}
@@ -0,0 +1,244 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.dialog {
background: #1e1e2e;
border: 1px solid #313244;
border-radius: 12px;
width: 520px;
max-width: 90vw;
padding: 24px;
position: relative;
box-shadow: 0 20px 60px rgba(0,0,0,0.7);
display: flex;
flex-direction: column;
gap: 16px;
}
.closeBtn {
position: absolute;
top: 12px;
right: 14px;
background: none;
border: none;
color: #6c7086;
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 4px;
}
.closeBtn:hover {
color: #f38ba8;
}
.speakerRow {
display: flex;
align-items: center;
gap: 12px;
}
.portraitBox {
width: 52px;
height: 52px;
border-radius: 8px;
background: #313244;
overflow: hidden;
position: relative;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.portrait {
width: 100%;
height: 100%;
object-fit: cover;
position: absolute;
inset: 0;
}
.portraitFallback {
font-size: 22px;
font-weight: 700;
color: #6c7086;
text-transform: uppercase;
}
.speakerName {
font-size: 15px;
font-weight: 700;
color: #89b4fa;
}
/* Main character (Бекзат) — blue tones */
.speakerRowMain { border-left: 3px solid #1e66f5; padding-left: 10px; }
.portraitMain { border: 2px solid #1e66f5; }
.speakerMain { color: #89b4fa; }
.textBoxMain { border-left: 3px solid #1e66f5; }
/* Other speakers — green tones */
.speakerRowOther { border-left: 3px solid #40a02b; padding-left: 10px; }
.portraitOther { border: 2px solid #40a02b; }
.speakerOther { color: #a6e3a1; }
.textBoxOther { border-left: 3px solid #40a02b; }
.textBox {
background: #11111b;
border-radius: 8px;
padding: 14px 16px;
font-size: 14px;
line-height: 1.6;
color: #cdd6f4;
min-height: 60px;
}
.nextBtn {
background: #1e66f5;
color: #fff;
border: none;
border-radius: 8px;
padding: 10px 24px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
align-self: flex-end;
transition: background 0.15s;
}
.nextBtn:hover {
background: #2779e4;
}
.noNextWarning {
color: #f9e2af;
font-size: 12px;
text-align: right;
}
.choiceList {
display: flex;
flex-direction: column;
gap: 8px;
}
.choiceBtn {
border: none;
border-radius: 8px;
padding: 11px 16px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
text-align: left;
transition: opacity 0.15s;
}
.choiceBtn:hover {
opacity: 0.85;
}
.choiceMain {
background: #1e66f5;
color: #fff;
}
.choiceOptional {
background: #313244;
color: #cdd6f4;
border: 1px solid #45475a;
}
.autoAdvance {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 16px 0;
}
.conditionInfo {
color: #cba6f7;
font-size: 13px;
}
.conditionClauses {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.clausePill {
background: #313244;
color: #a6adc8;
font-size: 11px;
padding: 3px 8px;
border-radius: 4px;
font-family: monospace;
}
.cutsceneBox {
background: #11111b;
border-radius: 8px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 6px;
}
.cutsceneLabel {
font-size: 11px;
color: #6c7086;
text-transform: uppercase;
font-weight: 700;
letter-spacing: 0.5px;
}
.cutsceneId {
font-size: 15px;
color: #a6adc8;
font-family: monospace;
}
.endBox {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 16px 0;
}
.endMsg {
font-size: 16px;
color: #6c7086;
}
.debugBar {
border-top: 1px solid #313244;
padding-top: 8px;
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.debugLabel {
font-size: 10px;
color: #45475a;
font-family: monospace;
}
.debugFlagPill {
background: rgba(137, 180, 250, 0.12);
color: #6c7086;
border-radius: 3px;
padding: 1px 5px;
font-size: 10px;
font-family: monospace;
}
@@ -0,0 +1,170 @@
import { useEffect } from 'react';
import { useDialogueStore } from '../../store/dialogueStore';
import { ConditionClause } from '../../types/dialogue';
import { MAIN_CHARACTER } from '../../constants/characters';
import styles from './PlayModeOverlay.module.css';
function evalConditions(clauses: ConditionClause[], flags: Record<string, number | string>): boolean {
return clauses.every(c => {
const actual = flags[c.flag] ?? 0;
switch (c.op) {
case 'Equals': return actual == c.value;
case 'NotEquals': return actual != c.value;
case 'GreaterThan': return Number(actual) > Number(c.value);
case 'LessThan': return Number(actual) < Number(c.value);
}
});
}
export function PlayModeOverlay() {
const { file, selectedDialogueId, playState, advancePlay, setPlayFlag, stopPlay } = useDialogueStore();
const dialogue = file?.dialogues.find(d => d.id === selectedDialogueId);
const node = dialogue?.nodes.find(n => n.id === playState?.currentNodeId);
// Auto-advance for Condition and SetFlag
useEffect(() => {
if (!node || !playState) return;
if (node.type === 'Condition') {
const timer = setTimeout(() => {
const passes = evalConditions(node.conditions, playState.flags);
advancePlay(passes ? node.trueNext : node.falseNext);
}, 300);
return () => clearTimeout(timer);
}
if (node.type === 'SetFlag') {
const timer = setTimeout(() => {
for (const effect of node.effects) {
setPlayFlag(effect.flag, effect.value);
}
advancePlay(node.next);
}, 100);
return () => clearTimeout(timer);
}
}, [node?.id, node?.type]);
if (!playState || !node) return null;
const portraitPath = node.type === 'Line' || node.type === 'Choice'
? `../${node.portrait}`
: null;
return (
<div className={styles.overlay} onClick={e => e.stopPropagation()}>
<div className={styles.dialog}>
<button className={styles.closeBtn} onClick={stopPlay} title="Exit play mode">✕</button>
{node.type === 'Line' && (
<>
<div className={[
styles.speakerRow,
node.speaker === MAIN_CHARACTER ? styles.speakerRowMain : styles.speakerRowOther,
].join(' ')}>
<div className={[
styles.portraitBox,
node.speaker === MAIN_CHARACTER ? styles.portraitMain : styles.portraitOther,
].join(' ')}>
<img
src={portraitPath!}
alt={node.speaker}
className={styles.portrait}
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
<span className={styles.portraitFallback}>{node.speaker[0]}</span>
</div>
<span className={[
styles.speakerName,
node.speaker === MAIN_CHARACTER ? styles.speakerMain : styles.speakerOther,
].join(' ')}>{node.speaker}</span>
</div>
<div className={[
styles.textBox,
node.speaker === MAIN_CHARACTER ? styles.textBoxMain : styles.textBoxOther,
].join(' ')}>{node.text}</div>
{node.next ? (
<button className={styles.nextBtn} onClick={() => advancePlay(node.next)}>
Next →
</button>
) : (
<div className={styles.noNextWarning}>⚠ No next node set</div>
)}
</>
)}
{node.type === 'Choice' && (
<>
<div className={styles.speakerRow}>
<div className={styles.portraitBox}>
<img
src={portraitPath!}
alt={node.speaker}
className={styles.portrait}
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
<span className={styles.portraitFallback}>{node.speaker[0]}</span>
</div>
<span className={styles.speakerName}>{node.speaker}</span>
</div>
{node.text && <div className={styles.textBox}>{node.text}</div>}
<div className={styles.choiceList}>
{node.choices.map(choice => (
<button
key={choice.id}
className={[styles.choiceBtn, choice.kind === 'Main' ? styles.choiceMain : styles.choiceOptional].join(' ')}
onClick={() => advancePlay(choice.next)}
>
{choice.text || '(empty choice)'}
</button>
))}
</div>
</>
)}
{node.type === 'Condition' && (
<div className={styles.autoAdvance}>
<div className={styles.conditionInfo}>Evaluating condition...</div>
<div className={styles.conditionClauses}>
{node.conditions.map((c, i) => (
<span key={i} className={styles.clausePill}>{c.flag} {c.op} {c.value}</span>
))}
</div>
</div>
)}
{node.type === 'SetFlag' && (
<div className={styles.autoAdvance}>
<div className={styles.conditionInfo}>Setting flags...</div>
</div>
)}
{node.type === 'CutsceneStart' && (
<>
<div className={styles.cutsceneBox}>
<span className={styles.cutsceneLabel}>Cutscene:</span>
<span className={styles.cutsceneId}>{node.cutsceneId}</span>
</div>
<button className={styles.nextBtn} onClick={() => advancePlay(node.next)}>
Continue →
</button>
</>
)}
{node.type === 'End' && (
<div className={styles.endBox}>
<div className={styles.endMsg}>Dialogue ended.</div>
<button className={styles.nextBtn} onClick={stopPlay}>Close</button>
</div>
)}
<div className={styles.debugBar}>
<span className={styles.debugLabel}>node: {node.id}</span>
{Object.entries(playState.flags).map(([k, v]) => (
<span key={k} className={styles.debugFlagPill}>{k}={v}</span>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,313 @@
.panel {
width: 300px;
min-width: 300px;
background: #181825;
border-left: 1px solid #313244;
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
}
.emptyMsg {
padding: 24px 16px;
color: #6c7086;
font-size: 13px;
text-align: center;
}
.inspector {
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.inspectorHeader {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.nodeTypeBadge {
font-size: 11px;
font-weight: 700;
padding: 3px 8px;
border-radius: 4px;
color: #fff;
}
.nodeIdLabel {
flex: 1;
font-size: 11px;
font-family: monospace;
color: #6c7086;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.deleteNodeBtn {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
padding: 2px;
color: #6c7086;
transition: color 0.15s;
}
.deleteNodeBtn:hover {
color: #f38ba8;
}
.label {
display: block;
font-size: 10px;
font-weight: 600;
color: #6c7086;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 3px;
}
.input {
width: 100%;
background: #1e1e2e;
border: 1px solid #313244;
border-radius: 4px;
color: #cdd6f4;
font-size: 12px;
padding: 5px 8px;
box-sizing: border-box;
font-family: inherit;
transition: border-color 0.15s;
}
.input:focus {
outline: none;
border-color: #89b4fa;
}
.textarea {
width: 100%;
background: #1e1e2e;
border: 1px solid #313244;
border-radius: 4px;
color: #cdd6f4;
font-size: 12px;
padding: 5px 8px;
box-sizing: border-box;
resize: vertical;
font-family: inherit;
line-height: 1.5;
transition: border-color 0.15s;
}
.textarea:focus {
outline: none;
border-color: #89b4fa;
}
.select {
width: 100%;
background: #1e1e2e;
border: 1px solid #313244;
border-radius: 4px;
color: #cdd6f4;
font-size: 12px;
padding: 5px 8px;
box-sizing: border-box;
cursor: pointer;
}
.select:focus {
outline: none;
border-color: #89b4fa;
}
.selectSmall {
background: #1e1e2e;
border: 1px solid #313244;
border-radius: 4px;
color: #cdd6f4;
font-size: 11px;
padding: 3px 6px;
cursor: pointer;
flex-shrink: 0;
}
.inputSmall {
flex: 1;
min-width: 0;
background: #1e1e2e;
border: 1px solid #313244;
border-radius: 4px;
color: #cdd6f4;
font-size: 11px;
padding: 3px 6px;
font-family: monospace;
}
.inputSmall:focus {
outline: none;
border-color: #89b4fa;
}
.advancedToggle {
background: none;
border: none;
color: #6c7086;
font-size: 11px;
cursor: pointer;
padding: 4px 0;
text-align: left;
}
.advancedToggle:hover {
color: #cdd6f4;
}
.advancedSection {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
background: #1e1e2e;
border-radius: 4px;
border: 1px solid #313244;
}
.issueList {
display: flex;
flex-direction: column;
gap: 3px;
}
.issueError {
background: #2a0e14;
color: #f38ba8;
font-size: 11px;
padding: 5px 8px;
border-radius: 4px;
border-left: 3px solid #f38ba8;
}
.issueWarning {
background: #2a1f00;
color: #f9e2af;
font-size: 11px;
padding: 5px 8px;
border-radius: 4px;
border-left: 3px solid #f9e2af;
}
/* Choice editor */
.choiceEditorRow {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
background: #1e1e2e;
border-radius: 4px;
border: 1px solid #313244;
margin-top: 4px;
}
.choiceEditorHeader {
display: flex;
align-items: center;
gap: 6px;
}
.choiceIdLabel {
flex: 1;
font-size: 10px;
color: #6c7086;
}
.sectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.addBtn {
background: #313244;
color: #cdd6f4;
border: none;
border-radius: 4px;
padding: 3px 8px;
font-size: 11px;
cursor: pointer;
}
.addBtn:hover {
background: #45475a;
}
.removeBtn {
background: none;
border: none;
color: #6c7086;
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 0 2px;
}
.removeBtn:hover {
color: #f38ba8;
}
/* Clause / effect rows */
.clauseRow {
display: flex;
align-items: center;
gap: 4px;
margin-top: 4px;
}
.eqLabel {
color: #cba6f7;
font-weight: 700;
font-size: 13px;
}
.emptyHint {
color: #6c7086;
font-size: 11px;
font-style: italic;
padding: 4px 0;
}
/* Dialogue meta panel */
.dialogueMeta {
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.metaHeader {
font-size: 12px;
font-weight: 700;
color: #cdd6f4;
margin-bottom: 4px;
}
.statRow {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.statPill {
background: #313244;
color: #a6adc8;
font-size: 10px;
padding: 2px 7px;
border-radius: 10px;
}
@@ -0,0 +1,102 @@
import { useDialogueStore } from '../../store/dialogueStore';
import { LineInspector } from './inspectors/LineInspector';
import { ChoiceInspector } from './inspectors/ChoiceInspector';
import { ConditionInspector } from './inspectors/ConditionInspector';
import { SetFlagInspector } from './inspectors/SetFlagInspector';
import { CutsceneStartInspector, EndInspector } from './inspectors/OtherInspectors';
import { DialogueNode } from '../../types/dialogue';
import styles from './RightPanel.module.css';
// Helper to update start node ID
function useUpdateStart() {
const store = useDialogueStore;
return (dialogueId: string, start: string) => {
store.setState(state => {
const d = state.file?.dialogues.find(x => x.id === dialogueId);
if (d) d.start = start;
});
};
}
export function RightPanel() {
const { file, selectedDialogueId, selectedNodeId } = useDialogueStore();
const updateStart = useUpdateStart();
const dialogue = file?.dialogues.find(d => d.id === selectedDialogueId);
if (!dialogue) {
return (
<div className={styles.panel}>
<div className={styles.emptyMsg}>No dialogue selected</div>
</div>
);
}
if (!selectedNodeId) {
return (
<div className={styles.panel}>
<div className={styles.dialogueMeta}>
<div className={styles.metaHeader}>Dialogue Properties</div>
<div>
<label className={styles.label}>ID</label>
<input className={styles.input} value={dialogue.id} readOnly />
</div>
<div style={{ marginTop: 8 }}>
<label className={styles.label}>Start node</label>
<input
className={styles.input}
value={dialogue.start}
onChange={e => {
if (selectedDialogueId) updateStart(selectedDialogueId, e.target.value);
}}
list="startNodeList"
/>
<datalist id="startNodeList">
{dialogue.nodes.map(n => <option key={n.id} value={n.id} />)}
</datalist>
</div>
<div className={styles.emptyHint} style={{ marginTop: 16 }}>
Click a node in the graph to inspect it.
</div>
<div style={{ marginTop: 12 }}>
<div className={styles.metaHeader}>Node Count</div>
<div className={styles.statRow}>
{(['Line', 'Choice', 'Condition', 'SetFlag', 'CutsceneStart', 'End'] as DialogueNode['type'][]).map(t => {
const count = dialogue.nodes.filter(n => n.type === t).length;
return count > 0 ? (
<span key={t} className={styles.statPill}>{t}: {count}</span>
) : null;
})}
</div>
</div>
</div>
</div>
);
}
const node = dialogue.nodes.find(n => n.id === selectedNodeId);
if (!node) return null;
return (
<div className={styles.panel}>
{node.type === 'Line' && (
<LineInspector node={node} dialogueId={dialogue.id} />
)}
{node.type === 'Choice' && (
<ChoiceInspector node={node} dialogueId={dialogue.id} />
)}
{node.type === 'Condition' && (
<ConditionInspector node={node} dialogueId={dialogue.id} />
)}
{node.type === 'SetFlag' && (
<SetFlagInspector node={node} dialogueId={dialogue.id} />
)}
{node.type === 'CutsceneStart' && (
<CutsceneStartInspector node={node} dialogueId={dialogue.id} />
)}
{node.type === 'End' && (
<EndInspector node={node} dialogueId={dialogue.id} />
)}
</div>
);
}
@@ -0,0 +1,110 @@
import { ChoiceNode, ChoiceOption } from '../../../types/dialogue';
import { useDialogueStore } from '../../../store/dialogueStore';
import { SpeakerField } from '../shared/SpeakerField';
import { useValidation } from '../../../hooks/useValidation';
import styles from '../RightPanel.module.css';
interface Props {
node: ChoiceNode;
dialogueId: string;
}
export function ChoiceInspector({ node, dialogueId }: Props) {
const { updateNode, deleteNode, file } = useDialogueStore();
const { issuesByNodeId } = useValidation();
const issues = issuesByNodeId[node.id] ?? [];
const dialogue = file?.dialogues.find(d => d.id === dialogueId);
const nodeIds = dialogue?.nodes.map(n => n.id) ?? [];
function updateChoice(idx: number, patch: Partial<ChoiceOption>) {
const choices = node.choices.map((c, i) => i === idx ? { ...c, ...patch } : c);
updateNode(dialogueId, node.id, { choices });
}
function addChoice() {
const newId = `choice_${Date.now()}`;
const choices: ChoiceOption[] = [...node.choices, { id: newId, kind: 'Optional', text: '', next: '' }];
updateNode(dialogueId, node.id, { choices });
}
function removeChoice(idx: number) {
const choices = node.choices.filter((_, i) => i !== idx);
updateNode(dialogueId, node.id, { choices });
}
return (
<div className={styles.inspector}>
<div className={styles.inspectorHeader}>
<span className={styles.nodeTypeBadge} style={{ background: '#df8e1d' }}>Choice</span>
<span className={styles.nodeIdLabel}>{node.id}</span>
<button className={styles.deleteNodeBtn} onClick={() => deleteNode(dialogueId, node.id)} title="Delete node">🗑</button>
</div>
{issues.length > 0 && (
<div className={styles.issueList}>
{issues.map((issue, i) => (
<div key={i} className={issue.severity === 'error' ? styles.issueError : styles.issueWarning}>
{issue.severity === 'error' ? '⚠' : '!'} {issue.message}
</div>
))}
</div>
)}
<div>
<label className={styles.label}>Node ID</label>
<input className={styles.input} value={node.id} readOnly />
</div>
<SpeakerField
speaker={node.speaker}
portrait={node.portrait}
onSpeakerChange={(s, p) => updateNode(dialogueId, node.id, { speaker: s, portrait: p })}
/>
<div style={{ marginTop: 12 }}>
<div className={styles.sectionHeader}>
<span className={styles.label}>Choices</span>
<button className={styles.addBtn} onClick={addChoice}>+ Add</button>
</div>
{node.choices.map((choice, idx) => (
<div key={choice.id} className={styles.choiceEditorRow}>
<div className={styles.choiceEditorHeader}>
<select
className={styles.selectSmall}
value={choice.kind}
onChange={e => updateChoice(idx, { kind: e.target.value as 'Main' | 'Optional' })}
>
<option value="Main">Main</option>
<option value="Optional">Optional</option>
</select>
<span className={styles.choiceIdLabel}>#{idx + 1}</span>
<button className={styles.removeBtn} onClick={() => removeChoice(idx)}>×</button>
</div>
<input
className={styles.input}
value={choice.text}
placeholder="Choice text..."
onChange={e => updateChoice(idx, { text: e.target.value })}
/>
<input
className={styles.input}
value={choice.next}
placeholder="Next node ID..."
onChange={e => updateChoice(idx, { next: e.target.value })}
list={`nodelist-choice-${choice.id}`}
/>
<datalist id={`nodelist-choice-${choice.id}`}>
{nodeIds.map(id => <option key={id} value={id} />)}
</datalist>
</div>
))}
{node.choices.length === 0 && (
<div className={styles.emptyHint}>No choices yet. Click "+ Add".</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,120 @@
import { ConditionNode, ConditionClause, ConditionOp } from '../../../types/dialogue';
import { useDialogueStore } from '../../../store/dialogueStore';
import { useValidation } from '../../../hooks/useValidation';
import styles from '../RightPanel.module.css';
const OPS: ConditionOp[] = ['Equals', 'NotEquals', 'GreaterThan', 'LessThan'];
interface Props {
node: ConditionNode;
dialogueId: string;
}
export function ConditionInspector({ node, dialogueId }: Props) {
const { updateNode, deleteNode, file } = useDialogueStore();
const { issuesByNodeId } = useValidation();
const issues = issuesByNodeId[node.id] ?? [];
const dialogue = file?.dialogues.find(d => d.id === dialogueId);
const nodeIds = dialogue?.nodes.map(n => n.id) ?? [];
function updateClause(idx: number, patch: Partial<ConditionClause>) {
const conditions = node.conditions.map((c, i) => i === idx ? { ...c, ...patch } : c);
updateNode(dialogueId, node.id, { conditions });
}
function addClause() {
const conditions: ConditionClause[] = [...node.conditions, { flag: '', op: 'Equals', value: 1 }];
updateNode(dialogueId, node.id, { conditions });
}
function removeClause(idx: number) {
const conditions = node.conditions.filter((_, i) => i !== idx);
updateNode(dialogueId, node.id, { conditions });
}
return (
<div className={styles.inspector}>
<div className={styles.inspectorHeader}>
<span className={styles.nodeTypeBadge} style={{ background: '#8839ef' }}>Condition</span>
<span className={styles.nodeIdLabel}>{node.id}</span>
<button className={styles.deleteNodeBtn} onClick={() => deleteNode(dialogueId, node.id)} title="Delete node">🗑</button>
</div>
{issues.length > 0 && (
<div className={styles.issueList}>
{issues.map((issue, i) => (
<div key={i} className={issue.severity === 'error' ? styles.issueError : styles.issueWarning}>
{issue.severity === 'error' ? '⚠' : '!'} {issue.message}
</div>
))}
</div>
)}
<div>
<label className={styles.label}>Node ID</label>
<input className={styles.input} value={node.id} readOnly />
</div>
<div style={{ marginTop: 8 }}>
<div className={styles.sectionHeader}>
<span className={styles.label}>Conditions (ALL must pass)</span>
<button className={styles.addBtn} onClick={addClause}>+ Add</button>
</div>
{node.conditions.map((c, idx) => (
<div key={idx} className={styles.clauseRow}>
<input
className={styles.inputSmall}
value={c.flag}
placeholder="flag"
onChange={e => updateClause(idx, { flag: e.target.value })}
/>
<select
className={styles.selectSmall}
value={c.op}
onChange={e => updateClause(idx, { op: e.target.value as ConditionOp })}
>
{OPS.map(op => <option key={op} value={op}>{op}</option>)}
</select>
<input
className={styles.inputSmall}
value={String(c.value)}
placeholder="value"
onChange={e => updateClause(idx, { value: isNaN(Number(e.target.value)) ? e.target.value : Number(e.target.value) })}
/>
<button className={styles.removeBtn} onClick={() => removeClause(idx)}>×</button>
</div>
))}
{node.conditions.length === 0 && <div className={styles.emptyHint}>No conditions. Always true.</div>}
</div>
<div style={{ marginTop: 8 }}>
<label className={styles.label}>True → next node</label>
<input
className={styles.input}
value={node.trueNext}
placeholder="node_id"
onChange={e => updateNode(dialogueId, node.id, { trueNext: e.target.value })}
list={`nodelist-true-${node.id}`}
/>
<datalist id={`nodelist-true-${node.id}`}>
{nodeIds.map(id => <option key={id} value={id} />)}
</datalist>
</div>
<div style={{ marginTop: 8 }}>
<label className={styles.label}>False → next node</label>
<input
className={styles.input}
value={node.falseNext}
placeholder="node_id"
onChange={e => updateNode(dialogueId, node.id, { falseNext: e.target.value })}
list={`nodelist-false-${node.id}`}
/>
<datalist id={`nodelist-false-${node.id}`}>
{nodeIds.map(id => <option key={id} value={id} />)}
</datalist>
</div>
</div>
);
}
@@ -0,0 +1,114 @@
import { useState } from 'react';
import { LineNode } from '../../../types/dialogue';
import { useDialogueStore } from '../../../store/dialogueStore';
import { SpeakerField } from '../shared/SpeakerField';
import { AutoSaveTextArea } from '../shared/TextArea';
import { useValidation } from '../../../hooks/useValidation';
import styles from '../RightPanel.module.css';
interface Props {
node: LineNode;
dialogueId: string;
}
export function LineInspector({ node, dialogueId }: Props) {
const { updateNode, deleteNode, file } = useDialogueStore();
const { issuesByNodeId } = useValidation();
const [showAdvanced, setShowAdvanced] = useState(false);
const issues = issuesByNodeId[node.id] ?? [];
const dialogue = file?.dialogues.find(d => d.id === dialogueId);
const nodeIds = dialogue?.nodes.map(n => n.id) ?? [];
function update(patch: Partial<LineNode>) {
updateNode(dialogueId, node.id, patch);
}
function field(label: string, key: keyof LineNode, placeholder?: string) {
const val = (node[key] as string) ?? '';
return (
<div>
<label className={styles.label}>{label}</label>
<input
className={styles.input}
value={val}
placeholder={placeholder}
onChange={e => update({ [key]: e.target.value } as Partial<LineNode>)}
list={key === 'next' ? `nodelist-${node.id}` : undefined}
/>
{key === 'next' && (
<datalist id={`nodelist-${node.id}`}>
{nodeIds.map(id => <option key={id} value={id} />)}
</datalist>
)}
</div>
);
}
return (
<div className={styles.inspector}>
<div className={styles.inspectorHeader}>
<span className={styles.nodeTypeBadge} style={{ background: '#1e66f5' }}>Line</span>
<span className={styles.nodeIdLabel}>{node.id}</span>
<button className={styles.deleteNodeBtn} onClick={() => deleteNode(dialogueId, node.id)} title="Delete node">🗑</button>
</div>
{issues.length > 0 && (
<div className={styles.issueList}>
{issues.map((issue, i) => (
<div key={i} className={issue.severity === 'error' ? styles.issueError : styles.issueWarning}>
{issue.severity === 'error' ? '⚠' : '!'} {issue.message}
</div>
))}
</div>
)}
{field('Node ID', 'id')}
<SpeakerField
speaker={node.speaker}
portrait={node.portrait}
onSpeakerChange={(s, p) => update({ speaker: s, portrait: p })}
/>
<AutoSaveTextArea
label="Text"
value={node.text}
placeholder="Dialogue line text..."
onSave={v => update({ text: v })}
/>
{field('Next node', 'next', 'node_id')}
{dialogue?.mobileMode && (
<div>
<label className={styles.label}>Chat Bubble</label>
<select
className={styles.select}
value={node.chatBubble ?? ''}
onChange={e => update({ chatBubble: e.target.value as 'in' | 'out' | undefined || undefined })}
>
<option value="">(auto)</option>
<option value="in">in</option>
<option value="out">out</option>
</select>
</div>
)}
<button
className={styles.advancedToggle}
onClick={() => setShowAdvanced(v => !v)}
>
{showAdvanced ? '▼' : '▶'} Advanced triggers
</button>
{showAdvanced && (
<div className={styles.advancedSection}>
{field('questUnlock', 'questUnlock', 'quest_id')}
{field('objectiveComplete', 'objectiveComplete', 'group.objective')}
{field('objectiveVisible', 'objectiveVisible', 'group.objective')}
{field('questFail', 'questFail', 'quest_id')}
{field('questComplete', 'questComplete', 'quest_id')}
{field('luaCallback', 'luaCallback', 'on_event_name')}
</div>
)}
</div>
);
}
@@ -0,0 +1,76 @@
import { CutsceneStartNode, EndNode } from '../../../types/dialogue';
import { useDialogueStore } from '../../../store/dialogueStore';
import styles from '../RightPanel.module.css';
interface CutsceneProps {
node: CutsceneStartNode;
dialogueId: string;
}
export function CutsceneStartInspector({ node, dialogueId }: CutsceneProps) {
const { updateNode, deleteNode, file } = useDialogueStore();
const dialogue = file?.dialogues.find(d => d.id === dialogueId);
const nodeIds = dialogue?.nodes.map(n => n.id) ?? [];
return (
<div className={styles.inspector}>
<div className={styles.inspectorHeader}>
<span className={styles.nodeTypeBadge} style={{ background: '#4a4a5a' }}>CutsceneStart</span>
<span className={styles.nodeIdLabel}>{node.id}</span>
<button className={styles.deleteNodeBtn} onClick={() => deleteNode(dialogueId, node.id)} title="Delete node">🗑</button>
</div>
<div>
<label className={styles.label}>Node ID</label>
<input className={styles.input} value={node.id} readOnly />
</div>
<div style={{ marginTop: 8 }}>
<label className={styles.label}>Cutscene ID</label>
<input
className={styles.input}
value={node.cutsceneId}
placeholder="cutscene_id"
onChange={e => updateNode(dialogueId, node.id, { cutsceneId: e.target.value })}
/>
</div>
<div style={{ marginTop: 8 }}>
<label className={styles.label}>Next node</label>
<input
className={styles.input}
value={node.next}
placeholder="node_id"
onChange={e => updateNode(dialogueId, node.id, { next: e.target.value })}
list={`nodelist-cs-${node.id}`}
/>
<datalist id={`nodelist-cs-${node.id}`}>
{nodeIds.map(id => <option key={id} value={id} />)}
</datalist>
</div>
</div>
);
}
interface EndProps {
node: EndNode;
dialogueId: string;
}
export function EndInspector({ node, dialogueId }: EndProps) {
const { deleteNode } = useDialogueStore();
return (
<div className={styles.inspector}>
<div className={styles.inspectorHeader}>
<span className={styles.nodeTypeBadge} style={{ background: '#f38ba8', color: '#1e1e2e' }}>End</span>
<span className={styles.nodeIdLabel}>{node.id}</span>
<button className={styles.deleteNodeBtn} onClick={() => deleteNode(dialogueId, node.id)} title="Delete node">🗑</button>
</div>
<div>
<label className={styles.label}>Node ID</label>
<input className={styles.input} value={node.id} readOnly />
</div>
<div className={styles.emptyHint} style={{ marginTop: 12 }}>
This is a terminal node. Dialogue ends here.
</div>
</div>
);
}
@@ -0,0 +1,98 @@
import { SetFlagNode, FlagEffect } from '../../../types/dialogue';
import { useDialogueStore } from '../../../store/dialogueStore';
import { useValidation } from '../../../hooks/useValidation';
import styles from '../RightPanel.module.css';
interface Props {
node: SetFlagNode;
dialogueId: string;
}
export function SetFlagInspector({ node, dialogueId }: Props) {
const { updateNode, deleteNode, file } = useDialogueStore();
const { issuesByNodeId } = useValidation();
const issues = issuesByNodeId[node.id] ?? [];
const dialogue = file?.dialogues.find(d => d.id === dialogueId);
const nodeIds = dialogue?.nodes.map(n => n.id) ?? [];
function updateEffect(idx: number, patch: Partial<FlagEffect>) {
const effects = node.effects.map((e, i) => i === idx ? { ...e, ...patch } : e);
updateNode(dialogueId, node.id, { effects });
}
function addEffect() {
const effects: FlagEffect[] = [...node.effects, { flag: '', value: 1 }];
updateNode(dialogueId, node.id, { effects });
}
function removeEffect(idx: number) {
const effects = node.effects.filter((_, i) => i !== idx);
updateNode(dialogueId, node.id, { effects });
}
return (
<div className={styles.inspector}>
<div className={styles.inspectorHeader}>
<span className={styles.nodeTypeBadge} style={{ background: '#179299' }}>SetFlag</span>
<span className={styles.nodeIdLabel}>{node.id}</span>
<button className={styles.deleteNodeBtn} onClick={() => deleteNode(dialogueId, node.id)} title="Delete node">🗑</button>
</div>
{issues.length > 0 && (
<div className={styles.issueList}>
{issues.map((issue, i) => (
<div key={i} className={issue.severity === 'error' ? styles.issueError : styles.issueWarning}>
{issue.severity === 'error' ? '⚠' : '!'} {issue.message}
</div>
))}
</div>
)}
<div>
<label className={styles.label}>Node ID</label>
<input className={styles.input} value={node.id} readOnly />
</div>
<div style={{ marginTop: 8 }}>
<div className={styles.sectionHeader}>
<span className={styles.label}>Flag Effects</span>
<button className={styles.addBtn} onClick={addEffect}>+ Add</button>
</div>
{node.effects.map((e, idx) => (
<div key={idx} className={styles.clauseRow}>
<input
className={styles.inputSmall}
value={e.flag}
placeholder="flag_name"
onChange={ev => updateEffect(idx, { flag: ev.target.value })}
/>
<span className={styles.eqLabel}>=</span>
<input
className={styles.inputSmall}
value={String(e.value)}
placeholder="value"
onChange={ev => updateEffect(idx, { value: isNaN(Number(ev.target.value)) ? ev.target.value : Number(ev.target.value) })}
/>
<button className={styles.removeBtn} onClick={() => removeEffect(idx)}>×</button>
</div>
))}
{node.effects.length === 0 && <div className={styles.emptyHint}>No effects defined.</div>}
</div>
<div style={{ marginTop: 8 }}>
<label className={styles.label}>Next node</label>
<input
className={styles.input}
value={node.next}
placeholder="node_id"
onChange={e => updateNode(dialogueId, node.id, { next: e.target.value })}
list={`nodelist-sf-${node.id}`}
/>
<datalist id={`nodelist-sf-${node.id}`}>
{nodeIds.map(id => <option key={id} value={id} />)}
</datalist>
</div>
</div>
);
}
@@ -0,0 +1,58 @@
import { useState } from 'react';
import { CHARACTER_PRESETS, CUSTOM_CHARACTER_LABEL } from '../../../constants/characters';
import styles from '../RightPanel.module.css';
interface Props {
speaker: string;
portrait: string;
onSpeakerChange: (speaker: string, portrait: string) => void;
}
export function SpeakerField({ speaker, portrait, onSpeakerChange }: Props) {
const isPreset = CHARACTER_PRESETS.some(p => p.label === speaker);
const [isCustom, setIsCustom] = useState(!isPreset);
function handleSelect(e: React.ChangeEvent<HTMLSelectElement>) {
const val = e.target.value;
if (val === CUSTOM_CHARACTER_LABEL) {
setIsCustom(true);
onSpeakerChange(speaker, portrait);
} else {
setIsCustom(false);
const preset = CHARACTER_PRESETS.find(p => p.label === val);
onSpeakerChange(val, preset?.portrait ?? '');
}
}
return (
<div>
<label className={styles.label}>Speaker</label>
<select
className={styles.select}
value={isCustom ? CUSTOM_CHARACTER_LABEL : speaker}
onChange={handleSelect}
>
{CHARACTER_PRESETS.map(p => (
<option key={p.label} value={p.label}>{p.label}</option>
))}
<option value={CUSTOM_CHARACTER_LABEL}>{CUSTOM_CHARACTER_LABEL}</option>
</select>
{isCustom && (
<input
className={styles.input}
style={{ marginTop: 4 }}
value={speaker}
placeholder="Speaker name"
onChange={e => onSpeakerChange(e.target.value, portrait)}
/>
)}
<label className={styles.label} style={{ marginTop: 8 }}>Portrait path</label>
<input
className={styles.input}
value={portrait}
placeholder="resources/dialogue/portrait_..."
onChange={e => onSpeakerChange(speaker, e.target.value)}
/>
</div>
);
}
@@ -0,0 +1,26 @@
import { useAutoSave } from '../../../hooks/useAutoSave';
import styles from '../RightPanel.module.css';
interface Props {
value: string;
label: string;
placeholder?: string;
onSave: (value: string) => void;
rows?: number;
}
export function AutoSaveTextArea({ value, label, placeholder, onSave, rows = 4 }: Props) {
const [local, handleChange] = useAutoSave(value, onSave);
return (
<div>
<label className={styles.label}>{label}</label>
<textarea
className={styles.textarea}
value={local}
placeholder={placeholder}
rows={rows}
onChange={e => handleChange(e.target.value)}
/>
</div>
);
}
@@ -0,0 +1,65 @@
import { memo } from 'react';
import { Handle, Position, NodeProps } from '@xyflow/react';
import { ChoiceNode as ChoiceNodeData } from '../../types/dialogue';
import { useValidation } from '../../hooks/useValidation';
import { useDialogueStore } from '../../store/dialogueStore';
import styles from './nodes.module.css';
export const ChoiceNode = memo(({ data, selected }: NodeProps & { data: ChoiceNodeData }) => {
const { hasError, hasWarning } = useValidation();
const startId = useDialogueStore(s => {
const id = s.selectedDialogueId;
return s.file?.dialogues.find(d => d.id === id)?.start;
});
const isStart = data.id === startId;
const error = hasError(data.id);
const warning = !error && hasWarning(data.id);
return (
<div
className={[
styles.node,
styles.choiceNode,
selected ? styles.selected : '',
error ? styles.hasError : '',
warning ? styles.hasWarning : '',
].join(' ')}
>
{isStart && <div className={styles.startBadge}>▶ START</div>}
<Handle type="target" position={Position.Top} id="target" className={styles.handle} />
<div className={styles.nodeHeader}>
<span className={styles.speakerName}>{data.speaker || '(no speaker)'}</span>
<span className={styles.nodeType}>Choice</span>
</div>
<div className={styles.nodeId}>{data.id}</div>
<div className={styles.nodeBody}>
{data.choices.length === 0 ? (
<span className={styles.emptyText}>(no choices)</span>
) : (
data.choices.map((choice, i) => (
<div key={choice.id} className={styles.choiceRow} style={{ position: 'relative' }}>
<span className={[
styles.choiceKindBadge,
choice.kind === 'Main' ? styles.kindMain : styles.kindOptional,
].join(' ')}>
{choice.kind}
</span>
<span className={styles.choiceText}>{choice.text || '(empty)'}</span>
<Handle
type="source"
position={Position.Bottom}
id={choice.id}
className={styles.choiceHandle}
style={{
left: `${((i + 1) / (data.choices.length + 1)) * 100}%`,
bottom: -8,
}}
/>
</div>
))
)}
</div>
</div>
);
});
@@ -0,0 +1,60 @@
import { memo } from 'react';
import { Handle, Position, NodeProps } from '@xyflow/react';
import { ConditionNode as ConditionNodeData } from '../../types/dialogue';
import { useValidation } from '../../hooks/useValidation';
import styles from './nodes.module.css';
export const ConditionNode = memo(({ data, selected }: NodeProps & { data: ConditionNodeData }) => {
const { hasError, hasWarning } = useValidation();
const error = hasError(data.id);
const warning = !error && hasWarning(data.id);
return (
<div
className={[
styles.node,
styles.conditionNode,
selected ? styles.selected : '',
error ? styles.hasError : '',
warning ? styles.hasWarning : '',
].join(' ')}
>
<Handle type="target" position={Position.Top} id="target" className={styles.handle} />
<div className={styles.nodeHeader}>
<span className={styles.speakerName}>Condition</span>
<span className={styles.nodeType}>If</span>
</div>
<div className={styles.nodeId}>{data.id}</div>
<div className={styles.nodeBody}>
{data.conditions.map((c, i) => (
<div key={i} className={styles.conditionRow}>
<span className={styles.flagPill}>{c.flag}</span>
<span className={styles.opBadge}>{c.op}</span>
<span className={styles.valuePill}>{String(c.value)}</span>
</div>
))}
{data.conditions.length === 0 && (
<span className={styles.emptyText}>(no conditions)</span>
)}
</div>
<div className={styles.conditionHandles}>
<span className={styles.trueLabel}>TRUE</span>
<span className={styles.falseLabel}>FALSE</span>
</div>
<Handle
type="source"
position={Position.Bottom}
id="true"
className={[styles.handle, styles.trueHandle].join(' ')}
style={{ left: '30%' }}
/>
<Handle
type="source"
position={Position.Bottom}
id="false"
className={[styles.handle, styles.falseHandle].join(' ')}
style={{ left: '70%' }}
/>
</div>
);
});
@@ -0,0 +1,34 @@
import { memo } from 'react';
import { Handle, Position, NodeProps } from '@xyflow/react';
import { CutsceneStartNode as CutsceneStartNodeData } from '../../types/dialogue';
import { useValidation } from '../../hooks/useValidation';
import styles from './nodes.module.css';
export const CutsceneStartNode = memo(({ data, selected }: NodeProps & { data: CutsceneStartNodeData }) => {
const { hasError, hasWarning } = useValidation();
const error = hasError(data.id);
const warning = !error && hasWarning(data.id);
return (
<div
className={[
styles.node,
styles.cutsceneNode,
selected ? styles.selected : '',
error ? styles.hasError : '',
warning ? styles.hasWarning : '',
].join(' ')}
>
<Handle type="target" position={Position.Top} id="target" className={styles.handle} />
<div className={styles.nodeHeader}>
<span className={styles.speakerName}>Cutscene</span>
<span className={styles.nodeType}>Scene</span>
</div>
<div className={styles.nodeId}>{data.id}</div>
<div className={styles.nodeBody}>
<span className={styles.textSnippet}>{data.cutsceneId || '(no cutscene ID)'}</span>
</div>
<Handle type="source" position={Position.Bottom} id="source" className={styles.handle} />
</div>
);
});
@@ -0,0 +1,20 @@
import { memo } from 'react';
import { Handle, Position, NodeProps } from '@xyflow/react';
import { EndNode as EndNodeData } from '../../types/dialogue';
import styles from './nodes.module.css';
export const EndNode = memo(({ data, selected }: NodeProps & { data: EndNodeData }) => {
return (
<div
className={[
styles.node,
styles.endNode,
selected ? styles.selected : '',
].join(' ')}
>
<Handle type="target" position={Position.Top} id="target" className={styles.handle} />
<div className={styles.endLabel}>END</div>
<div className={styles.nodeId}>{data.id}</div>
</div>
);
});
@@ -0,0 +1,66 @@
import { memo } from 'react';
import { Handle, Position, NodeProps } from '@xyflow/react';
import { LineNode as LineNodeData } from '../../types/dialogue';
import { useValidation } from '../../hooks/useValidation';
import { useDialogueStore } from '../../store/dialogueStore';
import { MAIN_CHARACTER } from '../../constants/characters';
import styles from './nodes.module.css';
export const LineNode = memo(({ data, selected }: NodeProps & { data: LineNodeData }) => {
const { hasError, hasWarning } = useValidation();
const startId = useDialogueStore(s => {
const id = s.selectedDialogueId;
return s.file?.dialogues.find(d => d.id === id)?.start;
});
const isMain = data.speaker === MAIN_CHARACTER;
const isStart = data.id === startId;
const error = hasError(data.id);
const warning = !error && hasWarning(data.id);
const triggers = [
data.chatBubble && `💬 ${data.chatBubble}`,
data.questUnlock && `🔓 quest`,
data.objectiveComplete && `✅ obj`,
data.objectiveVisible && `👁 obj`,
data.questFail && `❌ quest`,
data.questComplete && `🏁 quest`,
data.luaCallback && `⚡ lua`,
].filter(Boolean);
return (
<div
className={[
styles.node,
styles.lineNode,
isMain ? styles.mainChar : styles.otherChar,
selected ? styles.selected : '',
error ? styles.hasError : '',
warning ? styles.hasWarning : '',
].join(' ')}
>
{isStart && <div className={styles.startBadge}>▶ START</div>}
<Handle type="target" position={Position.Top} id="target" className={styles.handle} />
<div className={styles.nodeHeader}>
<span className={styles.speakerName}>{data.speaker || '(no speaker)'}</span>
<span className={styles.nodeType}>Line</span>
</div>
<div className={styles.nodeId}>{data.id}</div>
<div className={styles.nodeBody}>
{data.text ? (
<span className={styles.textSnippet}>{data.text}</span>
) : (
<span className={styles.emptyText}>(empty)</span>
)}
</div>
{triggers.length > 0 && (
<div className={styles.triggerRow}>
{triggers.map((t, i) => (
<span key={i} className={styles.triggerPill}>{t}</span>
))}
</div>
)}
<Handle type="source" position={Position.Bottom} id="source" className={styles.handle} />
</div>
);
});
@@ -0,0 +1,43 @@
import { memo } from 'react';
import { Handle, Position, NodeProps } from '@xyflow/react';
import { SetFlagNode as SetFlagNodeData } from '../../types/dialogue';
import { useValidation } from '../../hooks/useValidation';
import styles from './nodes.module.css';
export const SetFlagNode = memo(({ data, selected }: NodeProps & { data: SetFlagNodeData }) => {
const { hasError, hasWarning } = useValidation();
const error = hasError(data.id);
const warning = !error && hasWarning(data.id);
return (
<div
className={[
styles.node,
styles.setFlagNode,
selected ? styles.selected : '',
error ? styles.hasError : '',
warning ? styles.hasWarning : '',
].join(' ')}
>
<Handle type="target" position={Position.Top} id="target" className={styles.handle} />
<div className={styles.nodeHeader}>
<span className={styles.speakerName}>Set Flag</span>
<span className={styles.nodeType}>Flag</span>
</div>
<div className={styles.nodeId}>{data.id}</div>
<div className={styles.nodeBody}>
{data.effects.map((e, i) => (
<div key={i} className={styles.conditionRow}>
<span className={styles.flagPill}>{e.flag}</span>
<span className={styles.opBadge}>=</span>
<span className={styles.valuePill}>{String(e.value)}</span>
</div>
))}
{data.effects.length === 0 && (
<span className={styles.emptyText}>(no effects)</span>
)}
</div>
<Handle type="source" position={Position.Bottom} id="source" className={styles.handle} />
</div>
);
});
@@ -0,0 +1,15 @@
import { LineNode } from './LineNode';
import { ChoiceNode } from './ChoiceNode';
import { ConditionNode } from './ConditionNode';
import { SetFlagNode } from './SetFlagNode';
import { CutsceneStartNode } from './CutsceneStartNode';
import { EndNode } from './EndNode';
export const nodeTypes = {
Line: LineNode,
Choice: ChoiceNode,
Condition: ConditionNode,
SetFlag: SetFlagNode,
CutsceneStart: CutsceneStartNode,
End: EndNode,
} as const;
@@ -0,0 +1,253 @@
.node {
border-radius: 6px;
border: 2px solid transparent;
background: #1e1e2e;
color: #cdd6f4;
font-family: 'Segoe UI', system-ui, sans-serif;
font-size: 12px;
min-width: 220px;
max-width: 260px;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
position: relative;
cursor: pointer;
}
.node.selected {
border-color: #89b4fa;
box-shadow: 0 0 0 3px rgba(137,180,250,0.25);
}
.node.hasError {
border-left: 4px solid #f38ba8;
}
.node.hasWarning {
border-left: 4px solid #f9e2af;
}
/* ---- Header colors ---- */
.nodeHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
border-radius: 4px 4px 0 0;
}
.lineNode.mainChar .nodeHeader { background: #1e66f5; }
.lineNode.otherChar .nodeHeader { background: #40a02b; }
.choiceNode .nodeHeader { background: #df8e1d; }
.conditionNode .nodeHeader { background: #8839ef; }
.setFlagNode .nodeHeader { background: #179299; }
.cutsceneNode .nodeHeader { background: #4a4a5a; }
.endNode .nodeHeader { display: none; }
.speakerName {
font-weight: 600;
font-size: 12px;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 160px;
}
.nodeType {
font-size: 10px;
color: rgba(255,255,255,0.7);
font-weight: 500;
flex-shrink: 0;
}
.nodeId {
font-size: 10px;
color: #6c7086;
padding: 2px 10px 0;
font-family: monospace;
}
/* ---- Body ---- */
.nodeBody {
padding: 6px 10px 8px;
min-height: 32px;
}
.textSnippet {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
color: #cdd6f4;
}
.emptyText {
color: #6c7086;
font-style: italic;
}
/* ---- Trigger pills ---- */
.triggerRow {
display: flex;
flex-wrap: wrap;
gap: 3px;
padding: 0 8px 7px;
}
.triggerPill {
background: rgba(255,255,255,0.08);
border-radius: 3px;
padding: 1px 5px;
font-size: 10px;
color: #a6adc8;
}
/* ---- Choice rows ---- */
.choiceRow {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 0;
border-top: 1px solid rgba(255,255,255,0.06);
}
.choiceKindBadge {
font-size: 9px;
font-weight: 700;
padding: 1px 5px;
border-radius: 3px;
flex-shrink: 0;
text-transform: uppercase;
}
.kindMain {
background: #fe640b;
color: #fff;
}
.kindOptional {
background: transparent;
border: 1px solid #fe640b;
color: #fe640b;
}
.choiceText {
font-size: 11px;
color: #cdd6f4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
/* ---- Condition rows ---- */
.conditionRow {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 0;
}
.flagPill {
background: rgba(137,180,250,0.15);
color: #89b4fa;
border-radius: 3px;
padding: 1px 5px;
font-size: 10px;
font-family: monospace;
}
.opBadge {
color: #cba6f7;
font-size: 10px;
font-weight: 600;
}
.valuePill {
background: rgba(166,227,161,0.15);
color: #a6e3a1;
border-radius: 3px;
padding: 1px 5px;
font-size: 10px;
font-family: monospace;
}
/* ---- Condition handles ---- */
.conditionHandles {
display: flex;
justify-content: space-between;
padding: 2px 14px 6px;
}
.trueLabel {
font-size: 9px;
font-weight: 700;
color: #a6e3a1;
text-transform: uppercase;
margin-left: 12%;
}
.falseLabel {
font-size: 9px;
font-weight: 700;
color: #f38ba8;
text-transform: uppercase;
margin-right: 12%;
}
/* ---- End node ---- */
.endNode {
min-width: 100px;
max-width: 120px;
display: flex;
flex-direction: column;
align-items: center;
background: #2a0e14;
border: 2px solid #f38ba8;
border-radius: 50px;
padding: 10px 16px;
}
.endLabel {
font-size: 14px;
font-weight: 700;
color: #f38ba8;
letter-spacing: 2px;
}
/* ---- Start badge ---- */
.startBadge {
position: absolute;
top: -22px;
left: 10px;
background: #a6e3a1;
color: #1e1e2e;
font-size: 10px;
font-weight: 700;
padding: 2px 8px;
border-radius: 4px 4px 0 0;
}
/* ---- Handles ---- */
.handle {
width: 10px;
height: 10px;
background: #6c7086;
border: 2px solid #1e1e2e;
}
.trueHandle {
background: #a6e3a1 !important;
}
.falseHandle {
background: #f38ba8 !important;
}
.choiceHandle {
width: 8px;
height: 8px;
background: #fab387 !important;
border: 2px solid #1e1e2e;
position: absolute !important;
}
+23
View File
@@ -0,0 +1,23 @@
import { CharacterPreset } from '../types/dialogue';
export const MAIN_CHARACTER = 'Бекзат';
export const PHONE_PORTRAIT = 'resources/dialogue/portrait_phone.png';
export const CHARACTER_PRESETS: CharacterPreset[] = [
{ label: 'Бекзат', portrait: 'resources/dialogue/portrait_hero_neutral.png' },
{ label: 'Аида Джаныбекова', portrait: 'resources/dialogue/portrait_teacher.png' },
{ label: 'Айпери', portrait: 'resources/dialogue/portrait_aiperi.png' },
{ label: 'Призрак', portrait: 'resources/dialogue/portrait_ghost.png' },
{ label: 'Алик', portrait: 'resources/dialogue/portrait_student_boy.png' },
{ label: 'Студент', portrait: 'resources/dialogue/portrait_student_boy.png' },
{ label: 'Студентка', portrait: 'resources/dialogue/portrait_student_girl.png' },
{ label: 'Бермет', portrait: 'resources/dialogue/portrait_student_girl.png' },
{ label: 'Алтынай', portrait: 'resources/dialogue/portrait_student_girl.png' },
];
export const CUSTOM_CHARACTER_LABEL = '(custom)';
export function getPortraitForSpeaker(speaker: string): string {
const preset = CHARACTER_PRESETS.find(p => p.label === speaker);
return preset?.portrait ?? '';
}
+31
View File
@@ -0,0 +1,31 @@
import { useState, useEffect, useRef } from 'react';
export function useAutoSave(
value: string,
onSave: (value: string) => void,
delay = 600
): [string, (v: string) => void] {
const [local, setLocal] = useState(value);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const savedValueRef = useRef(value);
useEffect(() => {
if (value !== savedValueRef.current) {
savedValueRef.current = value;
setLocal(value);
}
}, [value]);
const handleChange = (v: string) => {
setLocal(v);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
savedValueRef.current = v;
onSave(v);
}, delay);
};
useEffect(() => () => clearTimeout(timerRef.current), []);
return [local, handleChange];
}
+28
View File
@@ -0,0 +1,28 @@
import { useMemo } from 'react';
import { useDialogueStore } from '../store/dialogueStore';
import { ValidationIssue } from '../types/dialogue';
export function useValidation(): {
issuesByNodeId: Record<string, ValidationIssue[]>;
hasError: (nodeId: string) => boolean;
hasWarning: (nodeId: string) => boolean;
} {
const issues = useDialogueStore(s => s.validationIssues);
const issuesByNodeId = useMemo(() => {
const map: Record<string, ValidationIssue[]> = {};
for (const issue of issues) {
if (!map[issue.nodeId]) map[issue.nodeId] = [];
map[issue.nodeId].push(issue);
}
return map;
}, [issues]);
const hasError = (nodeId: string) =>
issuesByNodeId[nodeId]?.some(i => i.severity === 'error') ?? false;
const hasWarning = (nodeId: string) =>
issuesByNodeId[nodeId]?.some(i => i.severity === 'warning') ?? false;
return { issuesByNodeId, hasError, hasWarning };
}
+31
View File
@@ -0,0 +1,31 @@
*, *::before, *::after {
box-sizing: border-box;
}
html, body, #root {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: #1e1e2e;
color: #cdd6f4;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #181825;
}
::-webkit-scrollbar-thumb {
background: #45475a;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #585b70;
}
+11
View File
@@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import '@xyflow/react/dist/style.css';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+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 = {};
}),
}))
);
+107
View File
@@ -0,0 +1,107 @@
export type ChatBubble = 'in' | 'out';
export type ConditionOp = 'Equals' | 'NotEquals' | 'GreaterThan' | 'LessThan';
export type ChoiceKind = 'Main' | 'Optional';
export type ValidationSeverity = 'error' | 'warning';
export interface ChoiceOption {
id: string;
kind: ChoiceKind;
text: string;
next: string;
}
export interface ConditionClause {
flag: string;
op: ConditionOp;
value: number | string;
}
export interface FlagEffect {
flag: string;
value: number | string;
}
export interface LineNode {
id: string;
type: 'Line';
speaker: string;
portrait: string;
text: string;
next: string;
chatBubble?: ChatBubble;
questUnlock?: string;
objectiveComplete?: string;
objectiveVisible?: string;
questFail?: string;
questComplete?: string;
luaCallback?: string;
}
export interface ChoiceNode {
id: string;
type: 'Choice';
speaker: string;
portrait: string;
text: string;
choices: ChoiceOption[];
}
export interface ConditionNode {
id: string;
type: 'Condition';
conditions: ConditionClause[];
trueNext: string;
falseNext: string;
}
export interface SetFlagNode {
id: string;
type: 'SetFlag';
effects: FlagEffect[];
next: string;
}
export interface CutsceneStartNode {
id: string;
type: 'CutsceneStart';
cutsceneId: string;
next: string;
}
export interface EndNode {
id: string;
type: 'End';
}
export type DialogueNode =
| LineNode
| ChoiceNode
| ConditionNode
| SetFlagNode
| CutsceneStartNode
| EndNode;
export type NodeType = DialogueNode['type'];
export interface Dialogue {
id: string;
start: string;
nodes: DialogueNode[];
mobileMode?: boolean;
}
export interface DialogueFile {
dialogues: Dialogue[];
cutscenes: unknown[];
}
export interface ValidationIssue {
nodeId: string;
severity: ValidationSeverity;
message: string;
}
export interface CharacterPreset {
label: string;
portrait: string;
}
+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;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />