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