fed5674468
Wire mimic/pit/arrow/laser media on activate, poison/explosion via effects, and document the scene editor and traps in Instructions. Co-authored-by: Cursor <cursoragent@cursor.com>
96 lines
3.1 KiB
TypeScript
96 lines
3.1 KiB
TypeScript
/** HTML `<video>` для эффекта «Взрыв» — WebM с альфой (Pixi video/webp давали чёрный фон). */
|
|
|
|
import React, { useEffect, useMemo, useState } from 'react';
|
|
|
|
import type { EffectInstance, EffectsState, ExplosionInstance } from '../../../shared/types/effects';
|
|
|
|
import styles from './ExplosionVideoOverlay.module.css';
|
|
|
|
function explosionEffectVideoUrl(): string {
|
|
return new URL('vfx/explosion/aerial-debris-smoke.webm', window.location.href).href;
|
|
}
|
|
|
|
type Viewport = { x: number; y: number; w: number; h: number };
|
|
|
|
type Props = {
|
|
state: EffectsState | null;
|
|
viewport: Viewport | null | undefined;
|
|
/** Draft с пульта (пока ведём кисть) — тоже через video с альфой. */
|
|
draft?: ExplosionInstance | null;
|
|
};
|
|
|
|
function isLiveExplosion(inst: EffectInstance, nowMs: number): inst is ExplosionInstance {
|
|
if (inst.type !== 'explosion') return false;
|
|
return nowMs - inst.createdAtMs < inst.lifetimeMs;
|
|
}
|
|
|
|
export function ExplosionVideoOverlay({ state, viewport, draft = null }: Props) {
|
|
const [nowMs, setNowMs] = useState(() => state?.serverNowMs ?? Date.now());
|
|
|
|
const explosions = useMemo(() => {
|
|
const list = (state?.instances ?? []).filter((i): i is ExplosionInstance =>
|
|
isLiveExplosion(i, nowMs),
|
|
);
|
|
if (draft && draft.type === 'explosion') {
|
|
return [...list.filter((i) => i.id !== '__draft__'), draft];
|
|
}
|
|
return list;
|
|
}, [draft, nowMs, state?.instances]);
|
|
|
|
useEffect(() => {
|
|
if (explosions.length === 0) return;
|
|
const id = window.setInterval(() => {
|
|
setNowMs(state?.serverNowMs ?? Date.now());
|
|
}, 100);
|
|
return () => window.clearInterval(id);
|
|
}, [explosions.length, state?.serverNowMs]);
|
|
|
|
useEffect(() => {
|
|
setNowMs(state?.serverNowMs ?? Date.now());
|
|
}, [state?.revision, state?.serverNowMs]);
|
|
|
|
if (!viewport || explosions.length === 0) return null;
|
|
const minDim = Math.min(viewport.w, viewport.h);
|
|
|
|
return (
|
|
<div className={styles.layer} aria-hidden>
|
|
{explosions.map((inst) => {
|
|
const sizePx = Math.max(16, inst.radiusN * minDim);
|
|
const width = Math.max(sizePx * 4.2, minDim * 0.18);
|
|
const isDraft = inst.id === '__draft__';
|
|
return (
|
|
<video
|
|
key={inst.id}
|
|
className={styles.video}
|
|
style={{
|
|
left: viewport.x + inst.at.x * viewport.w,
|
|
top: viewport.y + inst.at.y * viewport.h,
|
|
width,
|
|
opacity: Math.max(0.35, Math.min(1, inst.intensity)),
|
|
}}
|
|
src={explosionEffectVideoUrl()}
|
|
autoPlay={!isDraft}
|
|
muted
|
|
playsInline
|
|
loop={false}
|
|
preload="auto"
|
|
ref={
|
|
isDraft
|
|
? (el) => {
|
|
if (!el) return;
|
|
el.pause();
|
|
try {
|
|
el.currentTime = 0.05;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
: undefined
|
|
}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|