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