working on cutscenes

This commit is contained in:
Vladislav Khorev committed 2026-06-07 14:40:15 +03:00
1 parent 54cc118df7
commit 6349859e66
47 files changed
+6234 -21

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
import type { CutsceneFile, Cutscene, ImageSegment, SubtitleLine } from '../types/cutscene';
function parseSegment(raw: Record<string, unknown>): ImageSegment {
const from = (raw.from as Record<string, number> | undefined) ?? {};
const to = (raw.to as Record<string, number> | undefined) ?? {};
return {
path: String(raw.path ?? ''),
width: Number(raw.width ?? 1280),
height: Number(raw.height ?? 720),
startMs: Number(raw.startMs ?? 0),
endMs: Number(raw.endMs ?? 5000),
fadeInMs: Number(raw.fadeInMs ?? 0),
fadeOutMs: Number(raw.fadeOutMs ?? 0),
easing: String(raw.easing ?? 'Linear') as ImageSegment['easing'],
from: {
centerX: Number(from.centerX ?? 0.5),
centerY: Number(from.centerY ?? 0.5),
scale: Number(from.scale ?? 1.0),
},
to: {
centerX: Number(to.centerX ?? from.centerX ?? 0.5),
centerY: Number(to.centerY ?? from.centerY ?? 0.5),
scale: Number(to.scale ?? from.scale ?? 1.0),
},
};
}
function parseLine(raw: Record<string, unknown>): SubtitleLine {
return {
speaker: String(raw.speaker ?? ''),
text: String(raw.text ?? ''),
durationMs: Number(raw.durationMs ?? 0),
waitForConfirm: Boolean(raw.waitForConfirm ?? false),
luaCallback: String(raw.luaCallback ?? ''),
};
}
function parseCutscene(raw: Record<string, unknown>): Cutscene {
const segments = Array.isArray(raw.imageSegments)
? (raw.imageSegments as Record<string, unknown>[]).map(parseSegment)
: [];
const lines = Array.isArray(raw.lines)
? (raw.lines as Record<string, unknown>[]).map(parseLine)
: [];
return {
id: String(raw.id ?? 'untitled'),
skippable: raw.skippable !== false,
durationMs: Number(raw.durationMs ?? 0),
fadeOutMs: Number(raw.fadeOutMs ?? 0),
fadeInMs: Number(raw.fadeInMs ?? 0),
endFadeOutMs: Number(raw.endFadeOutMs ?? 0),
endFadeInMs: Number(raw.endFadeInMs ?? 0),
onFadeInCallback: String(raw.onFadeInCallback ?? ''),
imageSegments: segments,
lines,
};
}
export function parseFile(json: unknown): CutsceneFile {
const raw = json as Record<string, unknown>;
const cutscenes = Array.isArray(raw.cutscenes)
? (raw.cutscenes as Record<string, unknown>[]).map(parseCutscene)
: [];
return { cutscenes };
}
export function triggerDownload(file: CutsceneFile, filename = 'cutscenes.json') {
const json = JSON.stringify(file, null, 4);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
+79
View File
@@ -0,0 +1,79 @@
import type { ImageSegment, ImagePose } from '../types/cutscene';
import { applyEasing } from '../constants/easings';
function clamp(v: number, min: number, max: number) {
return Math.max(min, Math.min(max, v));
}
function lerp(a: number, b: number, t: number) {
return a + (b - a) * t;
}
export interface SegmentRenderState {
alpha: number;
pose: ImagePose;
}
export function computeSegmentState(seg: ImageSegment, currentMs: number): SegmentRenderState | null {
if (currentMs < seg.startMs || currentMs > seg.endMs) return null;
const duration = seg.endMs - seg.startMs;
const localMs = currentMs - seg.startMs;
const t = duration > 0 ? clamp(localMs / duration, 0, 1) : 1;
const tEased = applyEasing(t, seg.easing);
const pose: ImagePose = {
centerX: lerp(seg.from.centerX, seg.to.centerX, tEased),
centerY: lerp(seg.from.centerY, seg.to.centerY, tEased),
scale: lerp(seg.from.scale, seg.to.scale, tEased),
};
let alpha = 1;
if (seg.fadeInMs > 0 && localMs < seg.fadeInMs) {
alpha = localMs / seg.fadeInMs;
} else if (seg.fadeOutMs > 0 && localMs > duration - seg.fadeOutMs) {
alpha = (duration - localMs) / seg.fadeOutMs;
}
return { alpha: clamp(alpha, 0, 1), pose };
}
/**
* Returns CSS for an absolutely-positioned <img> inside the viewport div.
*
* The image is stretched (object-fit: fill) to exactly logicalW × logicalH
* logical pixels on screen — matching how the game engine maps the full
* texture quad to those dimensions, regardless of the file's natural size.
*
* At scale=1 the logical area fills the viewport (aspect-ratio corrected).
* The viewport's own overflow:hidden clips anything that extends beyond.
*/
export function poseToStyle(
pose: ImagePose,
logicalW: number, // segment.width (e.g. 1280)
logicalH: number, // segment.height (e.g. 720)
containerW: number,
containerH: number,
): React.CSSProperties {
const baseScale = Math.max(containerW / logicalW, containerH / logicalH);
const renderW = logicalW * baseScale * pose.scale;
const renderH = logicalH * baseScale * pose.scale;
const maxOffsetX = Math.max(0, (renderW - containerW) / 2);
const maxOffsetY = Math.max(0, (renderH - containerH) / 2);
const offsetX = clamp((0.5 - pose.centerX) * renderW, -maxOffsetX, maxOffsetX);
// Y axis is inverted: centerY=0 → bottom of image, centerY=1 → top (Y-up convention)
const offsetY = clamp((pose.centerY - 0.5) * renderH, -maxOffsetY, maxOffsetY);
return {
position: 'absolute',
top: '50%',
left: '50%',
width: renderW,
height: renderH,
// Default object-fit (fill) stretches the full texture to these logical
// dimensions, exactly as the game engine renders the quad.
objectFit: 'fill',
transform: `translate(calc(-50% + ${offsetX}px), calc(-50% + ${offsetY}px))`,
};
}