80 lines
2.8 KiB
TypeScript
80 lines
2.8 KiB
TypeScript
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))`,
|
||
};
|
||
}
|