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:
@@ -3,6 +3,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import { computeTimeSec } from '../../main/video/videoPlaybackStore';
|
||||
import type { SessionState } from '../../shared/ipc/contracts';
|
||||
|
||||
import { ExplosionVideoOverlay } from './effects/ExplosionVideoOverlay';
|
||||
import { PixiEffectsOverlay } from './effects/PxiEffectsOverlay';
|
||||
import { SceneDarknessOverlay } from './effects/SceneDarknessOverlay';
|
||||
import { useEffectsState } from './effects/useEffectsState';
|
||||
@@ -169,6 +170,9 @@ export function PresentationView({
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{showEffects && scene?.previewAssetType !== 'video' ? (
|
||||
<ExplosionVideoOverlay state={fxState} viewport={contentRect} />
|
||||
) : null}
|
||||
{scene?.previewAssetType === 'image' && contentRect ? (
|
||||
<SceneTrapsOverlay
|
||||
traps={scene.traps ?? []}
|
||||
@@ -186,9 +190,9 @@ export function PresentationView({
|
||||
embedded
|
||||
assetId={activeMaterial.assetId}
|
||||
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
|
||||
legendMarkers={
|
||||
activeMaterial.legend?.enabled ? (activeMaterial.legend.markers ?? []) : undefined
|
||||
}
|
||||
{...(activeMaterial.legend?.enabled
|
||||
? { legendMarkers: activeMaterial.legend.markers ?? [] }
|
||||
: {})}
|
||||
/>
|
||||
) : null}
|
||||
{activeMaterial?.legend?.enabled ? (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -98,3 +98,14 @@
|
||||
transform: scale(1.6);
|
||||
}
|
||||
}
|
||||
|
||||
/** Одноразовый VFX ловушки на слое оверлея (не внутри круглого маркера).
|
||||
* Якорь кадра задаётся inline transform из trapActivationAnchorTransform. */
|
||||
.trapMediaFx {
|
||||
position: absolute;
|
||||
height: auto;
|
||||
max-width: none;
|
||||
pointer-events: none;
|
||||
z-index: 6;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,14 @@ import { createPortal } from 'react-dom';
|
||||
import type { SceneTrap, SceneTrapsState } from '../../../shared/types';
|
||||
import { defaultTrapRuntime } from '../../../shared/types/sceneTraps';
|
||||
|
||||
import {
|
||||
isTrapMediaActivationKind,
|
||||
playTrapActivationSound,
|
||||
trapActivationAnchorTransform,
|
||||
trapActivationLifeMs,
|
||||
trapActivationVideoUrl,
|
||||
type TrapMediaActivationKind,
|
||||
} from './trapActivation';
|
||||
import { TrapGlyph } from './TrapGlyph';
|
||||
import styles from './SceneTrapsOverlay.module.css';
|
||||
|
||||
@@ -18,6 +26,12 @@ type Props = {
|
||||
onDisarm?: (trapId: string) => void;
|
||||
};
|
||||
|
||||
type ActivationFx = {
|
||||
trapId: string;
|
||||
token: number;
|
||||
kind: TrapMediaActivationKind | 'flash';
|
||||
};
|
||||
|
||||
function menuPosition(clientX: number, clientY: number): { x: number; y: number } {
|
||||
const menuW = 200;
|
||||
const menuH = 140;
|
||||
@@ -38,15 +52,28 @@ export function SceneTrapsOverlay({
|
||||
onDisarm,
|
||||
}: Props) {
|
||||
const [menu, setMenu] = useState<{ trapId: string; x: number; y: number } | null>(null);
|
||||
const [flashToken, setFlashToken] = useState<{ trapId: string; token: number } | null>(null);
|
||||
const [activationFx, setActivationFx] = useState<ActivationFx | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const act = session?.lastActivation;
|
||||
if (!act) return;
|
||||
setFlashToken(act);
|
||||
const t = window.setTimeout(() => setFlashToken(null), 750);
|
||||
const trap = traps.find((t) => t.id === act.trapId);
|
||||
// poison/explosion: VFX/SFX через effects store (ControlApp), без локального flash/video.
|
||||
if (trap?.type === 'poison' || trap?.type === 'explosion') {
|
||||
setActivationFx(null);
|
||||
return;
|
||||
}
|
||||
const kind: ActivationFx['kind'] =
|
||||
trap && isTrapMediaActivationKind(trap.type) ? trap.type : 'flash';
|
||||
setActivationFx({ trapId: act.trapId, token: act.token, kind });
|
||||
if (kind !== 'flash' && mode === 'control') {
|
||||
// SFX только на пульте — иначе при двух окнах звук удвоится (как у прочих эффектов).
|
||||
playTrapActivationSound(kind);
|
||||
}
|
||||
const ms = kind !== 'flash' ? trapActivationLifeMs(kind) : 750;
|
||||
const t = window.setTimeout(() => setActivationFx(null), ms);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [session?.lastActivation?.token, session?.lastActivation?.trapId]);
|
||||
}, [session?.lastActivation?.token, session?.lastActivation?.trapId, traps, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
@@ -62,6 +89,16 @@ export function SceneTrapsOverlay({
|
||||
if (!viewport || traps.length === 0) return null;
|
||||
const minDim = Math.min(viewport.w, viewport.h);
|
||||
|
||||
const mediaTrap =
|
||||
activationFx && activationFx.kind !== 'flash'
|
||||
? traps.find((t) => t.id === activationFx.trapId)
|
||||
: undefined;
|
||||
const mediaSizePx = mediaTrap ? Math.max(16, mediaTrap.sizeN * minDim) : 0;
|
||||
/** Видео 16:9, центрируем на ловушке; ширина ~4× маркера, не меньше 18% кадра. */
|
||||
const mediaFxW = mediaTrap ? Math.max(mediaSizePx * 4.2, minDim * 0.18) : 0;
|
||||
const mediaKind =
|
||||
activationFx && activationFx.kind !== 'flash' ? activationFx.kind : null;
|
||||
|
||||
return (
|
||||
<div className={styles.layer}>
|
||||
{traps.map((trap) => {
|
||||
@@ -96,10 +133,34 @@ export function SceneTrapsOverlay({
|
||||
>
|
||||
<TrapGlyph type={trap.type} status={rt.status} size={Math.max(14, sizePx * 0.55)} />
|
||||
{trap.label ? <div className={styles.label}>{trap.label}</div> : null}
|
||||
{flashToken?.trapId === trap.id ? <div className={styles.flash} /> : null}
|
||||
{activationFx?.trapId === trap.id && activationFx.kind === 'flash' ? (
|
||||
<div className={styles.flash} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{mediaTrap && activationFx && mediaKind ? (
|
||||
<video
|
||||
key={activationFx.token}
|
||||
className={styles.trapMediaFx}
|
||||
style={{
|
||||
left: viewport.x + mediaTrap.nx * viewport.w,
|
||||
top: viewport.y + mediaTrap.ny * viewport.h,
|
||||
width: mediaFxW,
|
||||
transform: trapActivationAnchorTransform(mediaKind),
|
||||
}}
|
||||
src={trapActivationVideoUrl(mediaKind)}
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
preload="auto"
|
||||
onEnded={() => {
|
||||
setActivationFx((cur) =>
|
||||
cur?.token === activationFx.token && cur.kind === mediaKind ? null : cur,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{menu && mode === 'control'
|
||||
? createPortal(
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Простые SVG-иконки ловушек (MVP). */
|
||||
|
||||
import type { SceneTrapStatus, SceneTrapType } from '../../shared/types';
|
||||
import type { SceneTrapStatus, SceneTrapType } from '../../../shared/types';
|
||||
|
||||
const TYPE_COLOR: Record<SceneTrapType, string> = {
|
||||
mimic: '#c4783a',
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Реэкспорт для совместимости; канонический модуль — `trapActivation.ts`. */
|
||||
export {
|
||||
MIMIC_ACTIVATION_MS,
|
||||
mimicActivationVideoUrl,
|
||||
playMimicActivationSound,
|
||||
} from './trapActivation';
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const publicRoot = path.resolve(here, '../../public');
|
||||
|
||||
void test('trap activation assets exist in public/ (mimic/pit/arrow/laser; explosion shared with effects)', () => {
|
||||
const video = path.join(publicRoot, 'vfx/mimic/tentacle-at-camera.webm');
|
||||
const sfx = path.join(publicRoot, 'mimic.mp3');
|
||||
assert.ok(fs.existsSync(video), `missing ${video}`);
|
||||
assert.ok(fs.existsSync(sfx), `missing ${sfx}`);
|
||||
assert.ok(fs.statSync(video).size > 10_000, 'mimic webm too small');
|
||||
assert.ok(fs.statSync(sfx).size > 1_000, 'mimic mp3 too small');
|
||||
|
||||
const pitVideo = path.join(publicRoot, 'vfx/pit/infinity-portal.webm');
|
||||
const pitSfx = path.join(publicRoot, 'pit.mp3');
|
||||
assert.ok(fs.existsSync(pitVideo), `missing ${pitVideo}`);
|
||||
assert.ok(fs.existsSync(pitSfx), `missing ${pitSfx}`);
|
||||
assert.ok(fs.statSync(pitVideo).size > 10_000, 'pit webm too small');
|
||||
assert.ok(fs.statSync(pitSfx).size > 1_000, 'pit mp3 too small');
|
||||
|
||||
const arrowVideo = path.join(publicRoot, 'vfx/arrow/arrows-grounding.webm');
|
||||
const arrowSfx = path.join(publicRoot, 'arrow.mp3');
|
||||
assert.ok(fs.existsSync(arrowVideo), `missing ${arrowVideo}`);
|
||||
assert.ok(fs.existsSync(arrowSfx), `missing ${arrowSfx}`);
|
||||
assert.ok(fs.statSync(arrowVideo).size > 10_000, 'arrow webm too small');
|
||||
assert.ok(fs.statSync(arrowSfx).size > 1_000, 'arrow mp3 too small');
|
||||
|
||||
const laserVideo = path.join(publicRoot, 'vfx/laser/eye-lasers.webm');
|
||||
const laserSfx = path.join(publicRoot, 'laser.mp3');
|
||||
assert.ok(fs.existsSync(laserVideo), `missing ${laserVideo}`);
|
||||
assert.ok(fs.existsSync(laserSfx), `missing ${laserSfx}`);
|
||||
assert.ok(fs.statSync(laserVideo).size > 10_000, 'laser webm too small');
|
||||
assert.ok(fs.statSync(laserSfx).size > 1_000, 'laser mp3 too small');
|
||||
|
||||
const exVideo = path.join(publicRoot, 'vfx/explosion/aerial-debris-smoke.webm');
|
||||
const exSfx = path.join(publicRoot, 'explosion.mp3');
|
||||
assert.ok(fs.existsSync(exVideo), `missing ${exVideo}`);
|
||||
assert.ok(fs.existsSync(exSfx), `missing ${exSfx}`);
|
||||
});
|
||||
|
||||
void test('trapActivation registry covers mimic + pit + arrow + laser (explosion via effects)', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'trapActivation.ts'), 'utf8');
|
||||
assert.match(src, /vfx\/mimic\/tentacle-at-camera\.webm/);
|
||||
assert.match(src, /mimic\.mp3/);
|
||||
assert.match(src, /vfx\/pit\/infinity-portal\.webm/);
|
||||
assert.match(src, /pit\.mp3/);
|
||||
assert.match(src, /vfx\/arrow\/arrows-grounding\.webm/);
|
||||
assert.match(src, /arrow\.mp3/);
|
||||
assert.match(src, /vfx\/laser\/eye-lasers\.webm/);
|
||||
assert.match(src, /laser\.mp3/);
|
||||
assert.match(src, /playTrapActivationSound/);
|
||||
assert.match(src, /isTrapMediaActivationKind/);
|
||||
assert.ok(!src.includes("videoPath: 'vfx/explosion"), 'explosion should not be local trap media');
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/** Одноразовая активация ловушек с собственным VFX/SFX (`public/vfx/<type>/…` + `public/<type>.mp3`).
|
||||
* Яд/взрыв идут через effects store (как инструменты пульта) — см. ControlApp.
|
||||
*/
|
||||
|
||||
import type { SceneTrapType } from '../../../shared/types';
|
||||
import { getEffectsSfxGain } from '../../control/effectsSfxGain';
|
||||
|
||||
export type TrapMediaActivationKind = 'mimic' | 'pit' | 'arrow' | 'laser';
|
||||
|
||||
type TrapMediaSpec = {
|
||||
videoPath: string;
|
||||
soundPath: string;
|
||||
/** Длительность видео + запас на старт (мс). */
|
||||
lifeMs: number;
|
||||
sfxVolume: number;
|
||||
/**
|
||||
* Точка кадра (0…1), которая совпадает с центром ловушки.
|
||||
* По умолчанию центр; у лазера — правый конец луча.
|
||||
*/
|
||||
anchorNx?: number;
|
||||
anchorNy?: number;
|
||||
};
|
||||
|
||||
const TRAP_MEDIA: Record<TrapMediaActivationKind, TrapMediaSpec> = {
|
||||
mimic: {
|
||||
videoPath: 'vfx/mimic/tentacle-at-camera.webm',
|
||||
soundPath: 'mimic.mp3',
|
||||
lifeMs: 4500,
|
||||
sfxVolume: 0.9,
|
||||
},
|
||||
pit: {
|
||||
videoPath: 'vfx/pit/infinity-portal.webm',
|
||||
soundPath: 'pit.mp3',
|
||||
lifeMs: 6000,
|
||||
sfxVolume: 0.9,
|
||||
},
|
||||
arrow: {
|
||||
videoPath: 'vfx/arrow/arrows-grounding.webm',
|
||||
soundPath: 'arrow.mp3',
|
||||
lifeMs: 2000,
|
||||
sfxVolume: 0.9,
|
||||
},
|
||||
laser: {
|
||||
videoPath: 'vfx/laser/eye-lasers.webm',
|
||||
soundPath: 'laser.mp3',
|
||||
lifeMs: 1800,
|
||||
sfxVolume: 0.9,
|
||||
// правый кончик луча в кадре ≈ (0.82, 0.39)
|
||||
anchorNx: 0.82,
|
||||
anchorNy: 0.39,
|
||||
},
|
||||
};
|
||||
|
||||
export function isTrapMediaActivationKind(type: SceneTrapType): type is TrapMediaActivationKind {
|
||||
return type === 'mimic' || type === 'pit' || type === 'arrow' || type === 'laser';
|
||||
}
|
||||
|
||||
export function trapActivationLifeMs(kind: TrapMediaActivationKind): number {
|
||||
return TRAP_MEDIA[kind].lifeMs;
|
||||
}
|
||||
|
||||
export function trapActivationVideoUrl(kind: TrapMediaActivationKind): string {
|
||||
return new URL(TRAP_MEDIA[kind].videoPath, window.location.href).href;
|
||||
}
|
||||
|
||||
/** CSS transform, чтобы `anchor` кадра оказался в точке позиционирования. */
|
||||
export function trapActivationAnchorTransform(kind: TrapMediaActivationKind): string {
|
||||
const spec = TRAP_MEDIA[kind];
|
||||
const ax = Math.max(0, Math.min(1, spec.anchorNx ?? 0.5));
|
||||
const ay = Math.max(0, Math.min(1, spec.anchorNy ?? 0.5));
|
||||
return `translate(${(-ax * 100).toFixed(2)}%, ${(-ay * 100).toFixed(2)}%)`;
|
||||
}
|
||||
|
||||
export function playTrapActivationSound(kind: TrapMediaActivationKind): void {
|
||||
try {
|
||||
const spec = TRAP_MEDIA[kind];
|
||||
const el = new Audio(new URL(spec.soundPath, window.location.href).href);
|
||||
el.volume = Math.max(0, Math.min(1, spec.sfxVolume * getEffectsSfxGain()));
|
||||
void el.play().catch(() => undefined);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated используй trapActivation* */
|
||||
export const MIMIC_ACTIVATION_MS = TRAP_MEDIA.mimic.lifeMs;
|
||||
export function mimicActivationVideoUrl(): string {
|
||||
return trapActivationVideoUrl('mimic');
|
||||
}
|
||||
export function playMimicActivationSound(): void {
|
||||
playTrapActivationSound('mimic');
|
||||
}
|
||||
Reference in New Issue
Block a user