243 lines
7.4 KiB
TypeScript
243 lines
7.4 KiB
TypeScript
import { create } from 'zustand';
|
|
import { immer } from 'zustand/middleware/immer';
|
|
import type { CutsceneFile, Cutscene, ImageSegment, SubtitleLine, ImagePose } from '../types/cutscene';
|
|
|
|
export type PlayState = 'stopped' | 'playing' | 'paused';
|
|
|
|
interface CutsceneStore {
|
|
file: CutsceneFile | null;
|
|
selectedCutsceneId: string | null;
|
|
selectedLayerIndex: number | null;
|
|
playState: PlayState;
|
|
currentTimeMs: number;
|
|
|
|
// File I/O
|
|
loadFile: (file: CutsceneFile) => void;
|
|
getExportData: () => CutsceneFile | null;
|
|
|
|
// Cutscene CRUD
|
|
addCutscene: () => void;
|
|
deleteCutscene: (id: string) => void;
|
|
selectCutscene: (id: string | null) => void;
|
|
updateCutscene: (id: string, patch: Partial<Omit<Cutscene, 'imageSegments' | 'lines'>>) => void;
|
|
|
|
// Layer CRUD
|
|
selectLayer: (index: number | null) => void;
|
|
addLayer: () => void;
|
|
removeLayer: (index: number) => void;
|
|
moveLayerUp: (index: number) => void;
|
|
moveLayerDown: (index: number) => void;
|
|
updateLayer: (index: number, patch: Partial<ImageSegment>) => void;
|
|
updateLayerFrom: (index: number, patch: Partial<ImagePose>) => void;
|
|
updateLayerTo: (index: number, patch: Partial<ImagePose>) => void;
|
|
|
|
// Subtitle lines
|
|
addLine: () => void;
|
|
removeLine: (index: number) => void;
|
|
moveLineUp: (index: number) => void;
|
|
moveLineDown: (index: number) => void;
|
|
updateLine: (index: number, patch: Partial<SubtitleLine>) => void;
|
|
|
|
// Playback
|
|
setPlayState: (state: PlayState) => void;
|
|
setCurrentTime: (ms: number) => void;
|
|
}
|
|
|
|
function newCutscene(id: string): Cutscene {
|
|
return {
|
|
id,
|
|
skippable: true,
|
|
durationMs: 5000,
|
|
fadeOutMs: 500,
|
|
fadeInMs: 500,
|
|
endFadeOutMs: 500,
|
|
endFadeInMs: 500,
|
|
onFadeInCallback: '',
|
|
imageSegments: [],
|
|
lines: [],
|
|
};
|
|
}
|
|
|
|
function newSegment(): ImageSegment {
|
|
return {
|
|
path: '',
|
|
width: 1280,
|
|
height: 720,
|
|
startMs: 0,
|
|
endMs: 5000,
|
|
fadeInMs: 0,
|
|
fadeOutMs: 0,
|
|
easing: 'Linear',
|
|
from: { centerX: 0.5, centerY: 0.5, scale: 1.0 },
|
|
to: { centerX: 0.5, centerY: 0.5, scale: 1.0 },
|
|
};
|
|
}
|
|
|
|
function newLine(): SubtitleLine {
|
|
return {
|
|
speaker: '',
|
|
text: '',
|
|
durationMs: 3000,
|
|
waitForConfirm: false,
|
|
luaCallback: '',
|
|
};
|
|
}
|
|
|
|
function getSelected(file: CutsceneFile | null, id: string | null): Cutscene | null {
|
|
if (!file || !id) return null;
|
|
return file.cutscenes.find(c => c.id === id) ?? null;
|
|
}
|
|
|
|
export const useCutsceneStore = create<CutsceneStore>()(
|
|
immer((set, get) => ({
|
|
file: null,
|
|
selectedCutsceneId: null,
|
|
selectedLayerIndex: null,
|
|
playState: 'stopped',
|
|
currentTimeMs: 0,
|
|
|
|
loadFile: (file) => set(s => {
|
|
s.file = file;
|
|
s.selectedCutsceneId = file.cutscenes[0]?.id ?? null;
|
|
s.selectedLayerIndex = null;
|
|
s.playState = 'stopped';
|
|
s.currentTimeMs = 0;
|
|
}),
|
|
|
|
getExportData: () => get().file,
|
|
|
|
addCutscene: () => set(s => {
|
|
if (!s.file) s.file = { cutscenes: [] };
|
|
let base = 'cutscene_new';
|
|
let n = 1;
|
|
const ids = new Set(s.file.cutscenes.map(c => c.id));
|
|
while (ids.has(`${base}_${n}`)) n++;
|
|
const id = `${base}_${n}`;
|
|
s.file.cutscenes.push(newCutscene(id));
|
|
s.selectedCutsceneId = id;
|
|
s.selectedLayerIndex = null;
|
|
}),
|
|
|
|
deleteCutscene: (id) => set(s => {
|
|
if (!s.file) return;
|
|
const idx = s.file.cutscenes.findIndex(c => c.id === id);
|
|
if (idx === -1) return;
|
|
s.file.cutscenes.splice(idx, 1);
|
|
if (s.selectedCutsceneId === id) {
|
|
s.selectedCutsceneId = s.file.cutscenes[0]?.id ?? null;
|
|
s.selectedLayerIndex = null;
|
|
}
|
|
}),
|
|
|
|
selectCutscene: (id) => set(s => {
|
|
s.selectedCutsceneId = id;
|
|
s.selectedLayerIndex = null;
|
|
s.playState = 'stopped';
|
|
s.currentTimeMs = 0;
|
|
}),
|
|
|
|
updateCutscene: (id, patch) => set(s => {
|
|
if (!s.file) return;
|
|
const c = s.file.cutscenes.find(c => c.id === id);
|
|
if (!c) return;
|
|
Object.assign(c, patch);
|
|
}),
|
|
|
|
selectLayer: (index) => set(s => { s.selectedLayerIndex = index; }),
|
|
|
|
addLayer: () => set(s => {
|
|
const cutscene = getSelected(s.file, s.selectedCutsceneId);
|
|
if (!cutscene) return;
|
|
cutscene.imageSegments.push(newSegment());
|
|
s.selectedLayerIndex = cutscene.imageSegments.length - 1;
|
|
}),
|
|
|
|
removeLayer: (index) => set(s => {
|
|
const cutscene = getSelected(s.file, s.selectedCutsceneId);
|
|
if (!cutscene) return;
|
|
cutscene.imageSegments.splice(index, 1);
|
|
if (s.selectedLayerIndex === index) s.selectedLayerIndex = null;
|
|
else if (s.selectedLayerIndex !== null && s.selectedLayerIndex > index) s.selectedLayerIndex--;
|
|
}),
|
|
|
|
moveLayerUp: (index) => set(s => {
|
|
const cutscene = getSelected(s.file, s.selectedCutsceneId);
|
|
if (!cutscene || index <= 0) return;
|
|
const segs = cutscene.imageSegments;
|
|
[segs[index - 1], segs[index]] = [segs[index], segs[index - 1]];
|
|
if (s.selectedLayerIndex === index) s.selectedLayerIndex = index - 1;
|
|
else if (s.selectedLayerIndex === index - 1) s.selectedLayerIndex = index;
|
|
}),
|
|
|
|
moveLayerDown: (index) => set(s => {
|
|
const cutscene = getSelected(s.file, s.selectedCutsceneId);
|
|
if (!cutscene) return;
|
|
const segs = cutscene.imageSegments;
|
|
if (index >= segs.length - 1) return;
|
|
[segs[index], segs[index + 1]] = [segs[index + 1], segs[index]];
|
|
if (s.selectedLayerIndex === index) s.selectedLayerIndex = index + 1;
|
|
else if (s.selectedLayerIndex === index + 1) s.selectedLayerIndex = index;
|
|
}),
|
|
|
|
updateLayer: (index, patch) => set(s => {
|
|
const cutscene = getSelected(s.file, s.selectedCutsceneId);
|
|
if (!cutscene) return;
|
|
Object.assign(cutscene.imageSegments[index], patch);
|
|
}),
|
|
|
|
updateLayerFrom: (index, patch) => set(s => {
|
|
const cutscene = getSelected(s.file, s.selectedCutsceneId);
|
|
if (!cutscene) return;
|
|
Object.assign(cutscene.imageSegments[index].from, patch);
|
|
}),
|
|
|
|
updateLayerTo: (index, patch) => set(s => {
|
|
const cutscene = getSelected(s.file, s.selectedCutsceneId);
|
|
if (!cutscene) return;
|
|
Object.assign(cutscene.imageSegments[index].to, patch);
|
|
}),
|
|
|
|
addLine: () => set(s => {
|
|
const cutscene = getSelected(s.file, s.selectedCutsceneId);
|
|
if (!cutscene) return;
|
|
cutscene.lines.push(newLine());
|
|
}),
|
|
|
|
removeLine: (index) => set(s => {
|
|
const cutscene = getSelected(s.file, s.selectedCutsceneId);
|
|
if (!cutscene) return;
|
|
cutscene.lines.splice(index, 1);
|
|
}),
|
|
|
|
moveLineUp: (index) => set(s => {
|
|
const cutscene = getSelected(s.file, s.selectedCutsceneId);
|
|
if (!cutscene || index <= 0) return;
|
|
const lines = cutscene.lines;
|
|
[lines[index - 1], lines[index]] = [lines[index], lines[index - 1]];
|
|
}),
|
|
|
|
moveLineDown: (index) => set(s => {
|
|
const cutscene = getSelected(s.file, s.selectedCutsceneId);
|
|
if (!cutscene) return;
|
|
const lines = cutscene.lines;
|
|
if (index >= lines.length - 1) return;
|
|
[lines[index], lines[index + 1]] = [lines[index + 1], lines[index]];
|
|
}),
|
|
|
|
updateLine: (index, patch) => set(s => {
|
|
const cutscene = getSelected(s.file, s.selectedCutsceneId);
|
|
if (!cutscene) return;
|
|
Object.assign(cutscene.lines[index], patch);
|
|
}),
|
|
|
|
setPlayState: (state) => set(s => { s.playState = state; }),
|
|
setCurrentTime: (ms) => set(s => { s.currentTimeMs = ms; }),
|
|
}))
|
|
);
|
|
|
|
export function useSelectedCutscene() {
|
|
return useCutsceneStore(s =>
|
|
s.file?.cutscenes.find(c => c.id === s.selectedCutsceneId) ?? null
|
|
);
|
|
}
|