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

@@ -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;
}