171 lines
6.2 KiB
TypeScript
171 lines
6.2 KiB
TypeScript
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>
|
|
);
|
|
}
|