feat(traps): activation VFX/SFX, explosion effect, and help section
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>
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
.layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video {
|
||||
position: absolute;
|
||||
height: auto;
|
||||
max-width: none;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
background: transparent;
|
||||
/* WebM VP8/VP9 с alpha_mode=1 — без чёрной подложки */
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/** 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>
|
||||
);
|
||||
}
|
||||
@@ -57,6 +57,9 @@ function vfxPacksForTool(tool: EffectToolType): readonly VfxFramePack[] {
|
||||
return ['sunbeam'];
|
||||
case 'poisonCloud':
|
||||
return ['poisonCloud'];
|
||||
case 'explosion':
|
||||
// Визуал — HTML `<video>` с альфой (ExplosionVideoOverlay), не кадровый Pixi-pack.
|
||||
return [];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
@@ -78,6 +81,8 @@ function vfxPacksForInstanceType(type: EffectInstanceType): readonly VfxFramePac
|
||||
return ['sunbeam'];
|
||||
case 'poisonCloud':
|
||||
return ['poisonCloud'];
|
||||
case 'explosion':
|
||||
return [];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
@@ -769,6 +774,13 @@ function createInstanceNode(
|
||||
redrawPoisonCloud(cont, inst, viewport, 0, Math.max(1, inst.lifetimeMs));
|
||||
return cont;
|
||||
}
|
||||
if (inst.type === 'explosion') {
|
||||
// Визуал рисует ExplosionVideoOverlay (WebM с альфой). Pixi-нода — только placeholder.
|
||||
const cont = new pixi.Container();
|
||||
cont.visible = false;
|
||||
(cont as any).__fx = { id: inst.id, type: inst.type };
|
||||
return cont;
|
||||
}
|
||||
if (inst.type === 'freeze') {
|
||||
const tex = getFreezeScreenTexture(pixi, inst.seed, viewport);
|
||||
const s = new pixi.Sprite(tex);
|
||||
@@ -988,6 +1000,12 @@ function animateNodes(
|
||||
redrawPoisonCloud(cont, inst, viewport, t, life);
|
||||
}
|
||||
|
||||
if (inst.type === 'explosion') {
|
||||
// Визуал — ExplosionVideoOverlay; Pixi-placeholder остаётся скрытым.
|
||||
node.visible = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inst.type === 'freeze') {
|
||||
const s = node;
|
||||
const life = Math.max(1, inst.lifetimeMs);
|
||||
@@ -1776,6 +1794,9 @@ function instanceContentSig(inst: EffectInstance): string {
|
||||
if (inst.type === 'poisonCloud') {
|
||||
return `pc:${Math.round(inst.at.x * 1000)}:${Math.round(inst.at.y * 1000)}:${Math.round(inst.radiusN * 1000)}`;
|
||||
}
|
||||
if (inst.type === 'explosion') {
|
||||
return `ex:${Math.round(inst.at.x * 1000)}:${Math.round(inst.at.y * 1000)}:${Math.round(inst.radiusN * 1000)}`;
|
||||
}
|
||||
if (inst.type === 'freeze') {
|
||||
return `fr:${Math.round(inst.at.x * 1000)}:${Math.round(inst.at.y * 1000)}:${Math.round(inst.intensity * 1000)}`;
|
||||
}
|
||||
@@ -1913,6 +1934,11 @@ function relayoutInstanceNode(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (inst.type === 'explosion') {
|
||||
node.visible = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (inst.type === 'freeze') {
|
||||
const fx = (node as any).__fx ?? {};
|
||||
if (fx.vw !== viewport.w || fx.vh !== viewport.h) {
|
||||
|
||||
Reference in New Issue
Block a user