working on cutscenes
This commit is contained in:
1 parent
54cc118df7
commit
6349859e66
47 files changed
+6234
-21
No files matched your search
@@ -0,0 +1,72 @@
|
||||
import { useCutsceneStore, useSelectedCutscene } from '../../store/cutsceneStore';
|
||||
import type { Cutscene } from '../../types/cutscene';
|
||||
import styles from './RightPanel.module.css';
|
||||
|
||||
type CutscenePatch = Partial<Omit<Cutscene, 'imageSegments' | 'lines'>>;
|
||||
|
||||
function NumField({ label, value, onChange, min }: {
|
||||
label: string; value: number; onChange: (v: number) => void; min?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<label>{label}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
min={min}
|
||||
onChange={e => onChange(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CutsceneProperties() {
|
||||
const cutscene = useSelectedCutscene();
|
||||
const updateCutscene = useCutsceneStore(s => s.updateCutscene);
|
||||
|
||||
if (!cutscene) return <div className={styles.empty}>No cutscene selected</div>;
|
||||
|
||||
function upd(patch: CutscenePatch) {
|
||||
updateCutscene(cutscene!.id, patch);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>Cutscene</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={cutscene.id}
|
||||
onChange={e => upd({ id: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>Skippable</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cutscene.skippable}
|
||||
onChange={e => upd({ skippable: e.target.checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NumField label="Duration (ms)" value={cutscene.durationMs} onChange={v => upd({ durationMs: v })} min={0} />
|
||||
<NumField label="Fade out (ms)" value={cutscene.fadeOutMs} onChange={v => upd({ fadeOutMs: v })} min={0} />
|
||||
<NumField label="Fade in (ms)" value={cutscene.fadeInMs} onChange={v => upd({ fadeInMs: v })} min={0} />
|
||||
<NumField label="End fade out (ms)" value={cutscene.endFadeOutMs} onChange={v => upd({ endFadeOutMs: v })} min={0} />
|
||||
<NumField label="End fade in (ms)" value={cutscene.endFadeInMs} onChange={v => upd({ endFadeInMs: v })} min={0} />
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>onFadeIn callback</label>
|
||||
<input
|
||||
type="text"
|
||||
value={cutscene.onFadeInCallback}
|
||||
onChange={e => upd({ onFadeInCallback: e.target.value })}
|
||||
placeholder="lua function name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useCutsceneStore, useSelectedCutscene } from '../../store/cutsceneStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { AVAILABLE_IMAGES } from '../../constants/images';
|
||||
import { EASING_OPTIONS } from '../../constants/easings';
|
||||
import type { ImagePose } from '../../types/cutscene';
|
||||
import styles from './RightPanel.module.css';
|
||||
|
||||
function NumField({ label, value, onChange, min, step }: {
|
||||
label: string; value: number; onChange: (v: number) => void; min?: number; step?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<label>{label}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
min={min}
|
||||
step={step ?? 1}
|
||||
onChange={e => onChange(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PoseFields({ label, pose, onChange }: {
|
||||
label: string;
|
||||
pose: ImagePose;
|
||||
onChange: (patch: Partial<ImagePose>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.poseGroup}>
|
||||
<div className={styles.poseTitle}>{label}</div>
|
||||
<div className={styles.poseRow}>
|
||||
<div className={styles.field}>
|
||||
<label>centerX</label>
|
||||
<input type="number" value={pose.centerX} step={0.01} min={0} max={1}
|
||||
onChange={e => onChange({ centerX: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label>centerY</label>
|
||||
<input type="number" value={pose.centerY} step={0.01} min={0} max={1}
|
||||
onChange={e => onChange({ centerY: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label>scale</label>
|
||||
<input type="number" value={pose.scale} step={0.05} min={0.1}
|
||||
onChange={e => onChange({ scale: Number(e.target.value) })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LayerProperties() {
|
||||
const cutscene = useSelectedCutscene();
|
||||
const { selectedLayerIndex, updateLayer, updateLayerFrom, updateLayerTo } = useCutsceneStore(useShallow(s => ({
|
||||
selectedLayerIndex: s.selectedLayerIndex,
|
||||
updateLayer: s.updateLayer,
|
||||
updateLayerFrom: s.updateLayerFrom,
|
||||
updateLayerTo: s.updateLayerTo,
|
||||
})));
|
||||
|
||||
if (!cutscene || selectedLayerIndex === null) return null;
|
||||
const seg = cutscene.imageSegments[selectedLayerIndex];
|
||||
if (!seg) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>Layer {selectedLayerIndex + 1}</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>Image</label>
|
||||
<select
|
||||
value={AVAILABLE_IMAGES.includes(seg.path) ? seg.path : '__custom__'}
|
||||
onChange={e => {
|
||||
if (e.target.value !== '__custom__') updateLayer(selectedLayerIndex, { path: e.target.value });
|
||||
}}
|
||||
>
|
||||
{AVAILABLE_IMAGES.map(img => (
|
||||
<option key={img} value={img}>{img.split('/').pop()}</option>
|
||||
))}
|
||||
{!AVAILABLE_IMAGES.includes(seg.path) && (
|
||||
<option value="__custom__">(custom)</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>Path (manual)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={seg.path}
|
||||
onChange={e => updateLayer(selectedLayerIndex, { path: e.target.value })}
|
||||
placeholder="resources/..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.row2}>
|
||||
<NumField label="Width" value={seg.width} onChange={v => updateLayer(selectedLayerIndex, { width: v })} min={1} />
|
||||
<NumField label="Height" value={seg.height} onChange={v => updateLayer(selectedLayerIndex, { height: v })} min={1} />
|
||||
</div>
|
||||
|
||||
<div className={styles.row2}>
|
||||
<NumField label="Start (ms)" value={seg.startMs} onChange={v => updateLayer(selectedLayerIndex, { startMs: v })} min={0} />
|
||||
<NumField label="End (ms)" value={seg.endMs} onChange={v => updateLayer(selectedLayerIndex, { endMs: v })} min={0} />
|
||||
</div>
|
||||
|
||||
<div className={styles.row2}>
|
||||
<NumField label="Fade in (ms)" value={seg.fadeInMs} onChange={v => updateLayer(selectedLayerIndex, { fadeInMs: v })} min={0} />
|
||||
<NumField label="Fade out (ms)" value={seg.fadeOutMs} onChange={v => updateLayer(selectedLayerIndex, { fadeOutMs: v })} min={0} />
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>Easing</label>
|
||||
<select value={seg.easing} onChange={e => updateLayer(selectedLayerIndex, { easing: e.target.value as typeof seg.easing })}>
|
||||
{EASING_OPTIONS.map(e => <option key={e} value={e}>{e}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<PoseFields
|
||||
label="From"
|
||||
pose={seg.from}
|
||||
onChange={patch => updateLayerFrom(selectedLayerIndex, patch)}
|
||||
/>
|
||||
<PoseFields
|
||||
label="To"
|
||||
pose={seg.to}
|
||||
onChange={patch => updateLayerTo(selectedLayerIndex, patch)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
.panel {
|
||||
width: 260px;
|
||||
min-width: 240px;
|
||||
background: #252525;
|
||||
border-left: 1px solid #333;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #333;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 8px 6px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #777;
|
||||
font-size: 11px;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.1s;
|
||||
}
|
||||
.tab:hover { color: #bbb; }
|
||||
.tabActive {
|
||||
color: #5b9bd5;
|
||||
border-bottom-color: #5b9bd5;
|
||||
}
|
||||
|
||||
.scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ── Sections ──────────────────────────────────────────────────── */
|
||||
.section {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #2e2e2e;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.addBtn {
|
||||
background: #2a4a6e;
|
||||
border: 1px solid #3a6090;
|
||||
color: #a8d0f0;
|
||||
border-radius: 3px;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.addBtn:hover { background: #2e5480; }
|
||||
|
||||
/* ── Fields ────────────────────────────────────────────────────── */
|
||||
.field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 5px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
flex-shrink: 0;
|
||||
width: 100px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.field input[type="text"],
|
||||
.field input[type="number"],
|
||||
.field select,
|
||||
.field textarea {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field textarea {
|
||||
resize: vertical;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.field input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.row2 {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.row2 .field {
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.row2 .field label {
|
||||
width: auto;
|
||||
text-align: left;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* ── Pose groups ───────────────────────────────────────────────── */
|
||||
.poseGroup {
|
||||
margin-top: 8px;
|
||||
background: #1e1e1e;
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.poseTitle {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.poseRow {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.poseRow .field {
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.poseRow .field label {
|
||||
width: auto;
|
||||
text-align: left;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* ── Subtitle line cards ───────────────────────────────────────── */
|
||||
.lineCard {
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #2e2e2e;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.lineHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.lineIndex {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.iconBtn {
|
||||
background: none;
|
||||
border: 1px solid #3a3a3a;
|
||||
color: #888;
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.iconBtn:hover:not(:disabled) { background: #333; color: #ccc; }
|
||||
.iconBtn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.iconBtnDanger {
|
||||
composes: iconBtn;
|
||||
color: #a05050;
|
||||
border-color: #5a2a2a;
|
||||
}
|
||||
.iconBtnDanger:hover { background: #3a2020; color: #e06c6c; }
|
||||
|
||||
.emptyLines {
|
||||
color: #555;
|
||||
font-size: 11px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 20px;
|
||||
color: #555;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useCutsceneStore } from '../../store/cutsceneStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import CutsceneProperties from './CutsceneProperties';
|
||||
import LayerProperties from './LayerProperties';
|
||||
import SubtitleLines from './SubtitleLines';
|
||||
import styles from './RightPanel.module.css';
|
||||
|
||||
export default function RightPanel() {
|
||||
const { selectedLayerIndex, selectLayer } = useCutsceneStore(useShallow(s => ({
|
||||
selectedLayerIndex: s.selectedLayerIndex,
|
||||
selectLayer: s.selectLayer,
|
||||
})));
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
{/* Tab strip */}
|
||||
<div className={styles.tabs}>
|
||||
<button
|
||||
className={`${styles.tab} ${selectedLayerIndex === null ? styles.tabActive : ''}`}
|
||||
onClick={() => selectLayer(null)}
|
||||
>
|
||||
Cutscene
|
||||
</button>
|
||||
{selectedLayerIndex !== null && (
|
||||
<button className={`${styles.tab} ${styles.tabActive}`}>
|
||||
Layer {selectedLayerIndex + 1}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.scroll}>
|
||||
{selectedLayerIndex !== null ? (
|
||||
<LayerProperties />
|
||||
) : (
|
||||
<CutsceneProperties />
|
||||
)}
|
||||
<SubtitleLines />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useCutsceneStore, useSelectedCutscene } from '../../store/cutsceneStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import styles from './RightPanel.module.css';
|
||||
|
||||
export default function SubtitleLines() {
|
||||
const cutscene = useSelectedCutscene();
|
||||
const { addLine, removeLine, moveLineUp, moveLineDown, updateLine } = useCutsceneStore(useShallow(s => ({
|
||||
addLine: s.addLine,
|
||||
removeLine: s.removeLine,
|
||||
moveLineUp: s.moveLineUp,
|
||||
moveLineDown: s.moveLineDown,
|
||||
updateLine: s.updateLine,
|
||||
})));
|
||||
|
||||
if (!cutscene) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div className={styles.sectionTitle}>Subtitle Lines</div>
|
||||
<button className={styles.addBtn} onClick={addLine}>+ Add</button>
|
||||
</div>
|
||||
|
||||
{cutscene.lines.length === 0 && (
|
||||
<div className={styles.emptyLines}>No lines. Click + Add.</div>
|
||||
)}
|
||||
|
||||
{cutscene.lines.map((line, i) => (
|
||||
<div key={i} className={styles.lineCard}>
|
||||
<div className={styles.lineHeader}>
|
||||
<span className={styles.lineIndex}>#{i + 1}</span>
|
||||
<button className={styles.iconBtn} onClick={() => moveLineUp(i)} disabled={i === 0} title="Move up">↑</button>
|
||||
<button className={styles.iconBtn} onClick={() => moveLineDown(i)} disabled={i === cutscene.lines.length - 1} title="Move down">↓</button>
|
||||
<button className={styles.iconBtnDanger} onClick={() => removeLine(i)} title="Remove">✕</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>Speaker</label>
|
||||
<input
|
||||
type="text"
|
||||
value={line.speaker}
|
||||
onChange={e => updateLine(i, { speaker: e.target.value })}
|
||||
placeholder="(none)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>Text</label>
|
||||
<textarea
|
||||
value={line.text}
|
||||
rows={2}
|
||||
onChange={e => updateLine(i, { text: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.row2}>
|
||||
<div className={styles.field}>
|
||||
<label>Duration (ms)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={line.durationMs}
|
||||
min={0}
|
||||
onChange={e => updateLine(i, { durationMs: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label>Wait confirm</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={line.waitForConfirm}
|
||||
onChange={e => updateLine(i, { waitForConfirm: e.target.checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>Lua callback</label>
|
||||
<input
|
||||
type="text"
|
||||
value={line.luaCallback}
|
||||
onChange={e => updateLine(i, { luaCallback: e.target.value })}
|
||||
placeholder="function name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user