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