Files
DndGamePlayer/app/renderer/shared/effects/PxiEffectsOverlay.tsx
T
Ivan Fontosh fed5674468 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>
2026-07-25 12:35:22 +08:00

2466 lines
84 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react';
import type {
EffectInstance,
EffectInstanceType,
EffectsState,
EffectToolType,
} from '../../../shared/types/effects';
import styles from './PxiEffectsOverlay.module.css';
export type PixiEffectsOverlayHandle = {
/** Draft-штрих без React re-render родителя; null — сброс. */
setDraft: (instance: EffectInstance | null) => void;
};
/** Наборы кадровых VFX — подгружаем только по tool / живым instances. */
type VfxFramePack =
| 'fog'
| 'fire'
| 'rain'
| 'water'
| 'lightning'
| 'sunbeam'
| 'poisonCloud';
function mergeDraftState(
state: EffectsState | null,
draft: EffectInstance | null,
): EffectsState | null {
if (!draft) return state;
if (!state) {
return {
revision: 0,
serverNowMs: Date.now(),
tool: { tool: 'fog', radiusN: 0.05, intensity: 1 },
instances: [draft],
};
}
const rest = state.instances.filter((i) => i.id !== '__draft__');
return { ...state, instances: [...rest, draft] };
}
function vfxPacksForTool(tool: EffectToolType): readonly VfxFramePack[] {
switch (tool) {
case 'fog':
return ['fog'];
case 'fire':
return ['fire'];
case 'rain':
return ['rain'];
case 'water':
return ['water'];
case 'lightning':
return ['lightning'];
case 'sunbeam':
return ['sunbeam'];
case 'poisonCloud':
return ['poisonCloud'];
case 'explosion':
// Визуал — HTML `<video>` с альфой (ExplosionVideoOverlay), не кадровый Pixi-pack.
return [];
default:
return [];
}
}
function vfxPacksForInstanceType(type: EffectInstanceType): readonly VfxFramePack[] {
switch (type) {
case 'fog':
return ['fog'];
case 'fire':
return ['fire'];
case 'rain':
return ['rain'];
case 'water':
return ['water'];
case 'lightning':
return ['lightning'];
case 'sunbeam':
return ['sunbeam'];
case 'poisonCloud':
return ['poisonCloud'];
case 'explosion':
return [];
default:
return [];
}
}
function collectNeededVfxPacks(state: EffectsState | null): VfxFramePack[] {
if (!state) return [];
const packs = new Set<VfxFramePack>();
for (const p of vfxPacksForTool(state.tool.tool)) packs.add(p);
for (const inst of state.instances) {
for (const p of vfxPacksForInstanceType(inst.type)) packs.add(p);
}
return [...packs];
}
function preloadVfxFramePack(pixi: any, pack: VfxFramePack): void {
switch (pack) {
case 'fog':
void preloadFogVfxFrameTextures(pixi);
break;
case 'fire':
void preloadGroundFireVfxFrameTextures(pixi);
break;
case 'rain':
void preloadRainVfxFrameTextures(pixi);
break;
case 'water':
void preloadWaterVfxFrameTextures(pixi);
break;
case 'lightning':
void preloadLightningVfxFrameTextures(pixi);
void preloadElectricAccentFrameTextures(pixi);
break;
case 'sunbeam':
void preloadPulseDischargeFrameTextures(pixi);
break;
case 'poisonCloud':
void preloadDustBurstFrameTextures(pixi);
break;
default: {
const _exhaustive: never = pack;
void _exhaustive;
}
}
}
function ensureVfxPacksForState(pixi: any, state: EffectsState | null): void {
if (!pixi) return;
for (const pack of collectNeededVfxPacks(state)) {
preloadVfxFramePack(pixi, pack);
}
}
function effectsHaveWork(state: EffectsState | null): boolean {
return Boolean(state && state.instances.length > 0);
}
const LIGHTNING_VFX_FRAME_COUNT = 19;
const LIGHTNING_VFX_FRAME_ASPECT = 420 / 473;
const LIGHTNING_VFX_STRIKE_MS = 320;
const LIGHTNING_VFX_IMPACT_ANCHOR_Y = 437 / 473;
const ELECTRIC_ACCENT_START_MS = 8;
const ELECTRIC_ACCENT_DURATION_MS = 740;
const ELECTRIC_ACCENT_FRAME_COUNT = 10;
const ELECTRIC_ACCENT_FRAME_ASPECT = 480 / 360;
const FOG_VFX_FRAME_COUNT = 40;
const FOG_VFX_LOOP_MS = 5000;
const GROUND_FIRE_VFX_FRAME_COUNT = 40;
const GROUND_FIRE_VFX_FRAME_ASPECT = 872 / 480;
const GROUND_FIRE_VFX_LOOP_MS = 3333;
const RAIN_VFX_FRAME_COUNT = 45;
const RAIN_VFX_FRAME_ASPECT = 640 / 360;
const RAIN_VFX_LOOP_MS = 3000;
const WATER_VFX_FRAME_COUNT = 48;
const WATER_VFX_LOOP_MS = 2400;
const PULSE_DISCHARGE_VFX_FRAME_COUNT = 44;
const PULSE_DISCHARGE_VFX_FRAME_ASPECT = 960 / 540;
const PULSE_DISCHARGE_VFX_DURATION_MS = 2933;
const PULSE_DISCHARGE_IMPACT_ANCHOR_X = 0.885;
const PULSE_DISCHARGE_IMPACT_ANCHOR_Y = 0.49;
const DUST_BURST_VFX_FRAME_COUNT = 76;
const DUST_BURST_VFX_FRAME_ASPECT = 480 / 270;
const DUST_BURST_VFX_DURATION_MS = 5067;
const lightningVfxFrameTextureCache = new WeakMap<object, { textures: any[]; loading?: Promise<any[]> }>();
const electricAccentFrameTextureCache = new WeakMap<object, { textures: any[]; loading?: Promise<any[]> }>();
const fogVfxFrameTextureCache = new WeakMap<object, { textures: any[]; loading?: Promise<any[]> }>();
const groundFireVfxFrameTextureCache = new WeakMap<object, { textures: any[]; loading?: Promise<any[]> }>();
const rainVfxFrameTextureCache = new WeakMap<object, { textures: any[]; loading?: Promise<any[]> }>();
const waterVfxFrameTextureCache = new WeakMap<object, { textures: any[]; loading?: Promise<any[]> }>();
const pulseDischargeFrameTextureCache = new WeakMap<object, { textures: any[]; loading?: Promise<any[]> }>();
const dustBurstFrameTextureCache = new WeakMap<object, { textures: any[]; loading?: Promise<any[]> }>();
type Props = {
state: EffectsState | null;
interactive?: boolean;
style?: React.CSSProperties;
/** Область “контента” внутри контейнера (например при object-fit: contain). */
viewport?: { x: number; y: number; w: number; h: number } | undefined;
};
/**
* Учебная идея:
* - Pixi `Application` — это WebGL-рендерер + тикер.
* - Мы держим один `Application` на компонент, и при изменении `state` просто перерисовываем сцену.
* - Вариант A: рисуем "инстансы эффектов" (данные), а не пиксели.
* - Draft кисти идёт через `setDraft` (imperative), без re-render ControlApp.
*/
export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(function PixiEffectsOverlay(
{ state, interactive = false, style, viewport },
ref,
) {
const hostRef = useRef<HTMLDivElement | null>(null);
const appRef = useRef<any>(null);
const rootRef = useRef<any>(null);
const pixiRef = useRef<any>(null);
const nodesRef = useRef<Map<string, any>>(new Map());
const committedStateRef = useRef<EffectsState | null>(null);
const draftRef = useRef<EffectInstance | null>(null);
const stateRef = useRef<EffectsState | null>(null);
const timeOffsetRef = useRef(0);
const sizeRef = useRef<{ w: number; h: number }>({ w: 1, h: 1 });
const viewportRef = useRef<{ x: number; y: number; w: number; h: number }>({ x: 0, y: 0, w: 1, h: 1 });
const viewportProvidedRef = useRef(false);
/** null = ещё не синхронизировали с Pixi (ticker по умолчанию бежит). */
const tickerWantedRef = useRef<boolean | null>(null);
const syncTickerForState = (merged: EffectsState | null): void => {
const app = appRef.current;
if (!app?.ticker) return;
const want = effectsHaveWork(merged);
if (tickerWantedRef.current === want) return;
tickerWantedRef.current = want;
if (want) {
try {
app.ticker.start();
} catch {
/* ignore */
}
return;
}
const root = rootRef.current;
if (root) {
root.x = 0;
root.y = 0;
}
// Без этого кадра после syncNodes (clear) canvas остаётся со старым кадром:
// анимация «встаёт», но спрайты визуально не исчезают.
try {
app.render?.();
} catch {
/* ignore */
}
try {
app.ticker.stop();
} catch {
/* ignore */
}
};
const applyMergedState = (committed: EffectsState | null, draft: EffectInstance | null): void => {
committedStateRef.current = committed;
draftRef.current = draft;
const merged = mergeDraftState(committed, draft);
stateRef.current = merged;
if (merged) {
timeOffsetRef.current = merged.serverNowMs - Date.now();
}
const pixi = pixiRef.current;
const root = rootRef.current;
if (!pixi || !root) {
syncTickerForState(merged);
return;
}
ensureVfxPacksForState(pixi, merged);
syncNodes(pixi, root, nodesRef.current, merged, sizeRef.current, viewportRef.current);
syncTickerForState(merged);
};
useImperativeHandle(
ref,
() => ({
setDraft: (instance) => {
applyMergedState(committedStateRef.current, instance);
},
}),
[],
);
/** Снижаем resolution на HiDPI — меньше пикселей в WebGL, визуально ок для оверлея эффектов. */
const dpr = useMemo(() => Math.min(1.5, window.devicePixelRatio || 1), []);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
let destroyed = false;
let resizeRaf = 0;
let app: any = null;
let cleanup: (() => void) | null = null;
void (async () => {
try {
const pixi = await import('pixi.js');
pixiRef.current = pixi;
if (destroyed) return;
app = new pixi.Application();
await app.init({
backgroundAlpha: 0,
antialias: false,
powerPreference: 'high-performance',
resolution: dpr,
autoDensity: true,
preference: 'webgl',
});
// Меньше кадров — меньше CPU/GPU; анимации эффектов остаются плавными.
app.ticker.maxFPS = 32;
if (destroyed) return;
host.appendChild(app.canvas);
// Canvas по умолчанию перехватывает hit-test; оставляем клики «сквозь» оверлей для слоя кисти сверху.
app.canvas.style.pointerEvents = interactive ? 'auto' : 'none';
appRef.current = app;
const root = new pixi.Container();
rootRef.current = root;
app.stage.addChild(root);
const ro = new ResizeObserver(() => {
cancelAnimationFrame(resizeRaf);
resizeRaf = requestAnimationFrame(() => {
const r = host.getBoundingClientRect();
app.renderer.resize(Math.max(1, Math.floor(r.width)), Math.max(1, Math.floor(r.height)));
sizeRef.current = { w: app.renderer.width, h: app.renderer.height };
if (!viewportProvidedRef.current) {
viewportRef.current = { x: 0, y: 0, w: sizeRef.current.w, h: sizeRef.current.h };
}
syncNodes(pixi, root, nodesRef.current, stateRef.current, sizeRef.current, viewportRef.current);
});
});
ro.observe(host);
const r = host.getBoundingClientRect();
app.renderer.resize(Math.max(1, Math.floor(r.width)), Math.max(1, Math.floor(r.height)));
sizeRef.current = { w: app.renderer.width, h: app.renderer.height };
if (!viewportProvidedRef.current) {
viewportRef.current = { x: 0, y: 0, w: sizeRef.current.w, h: sizeRef.current.h };
}
// Lazy VFX: только pack'и для текущего tool / уже размещённых instances (не все наборы сразу).
ensureVfxPacksForState(pixi, stateRef.current);
syncNodes(pixi, root, nodesRef.current, stateRef.current, sizeRef.current, viewportRef.current);
// Animation loop: на каждом кадре обновляем свойства инстансов (alpha/дрейф/фликер).
// В idle (нет instances/draft) ticker останавливается — см. syncTickerForState.
app.ticker.add(() => {
const s = stateRef.current;
if (!s || s.instances.length === 0) return;
const nowMs = Date.now() + timeOffsetRef.current;
animateNodes(pixi, nodesRef.current, s, nowMs, sizeRef.current, viewportRef.current);
// Лёгкое “потряхивание” сцены в момент удара молнии.
// Делаем через смещение корневого контейнера, чтобы не вмешиваться в рендерер/камера-логику.
const rootNode = rootRef.current;
if (rootNode) {
const { x, y } = computeSceneShake(s, nowMs, sizeRef.current);
rootNode.x = x;
rootNode.y = y;
}
});
syncTickerForState(stateRef.current);
cleanup = () => ro.disconnect();
} catch (e) {
// Если Pixi/WebGL не поднялись, не ломаем всё приложение.
// Эффекты просто не будут отображаться, но UI останется живым.
console.error('[effects] Pixi init failed', e);
}
})();
return () => {
destroyed = true;
cancelAnimationFrame(resizeRaf);
cleanup?.();
const a = appRef.current;
appRef.current = null;
rootRef.current = null;
if (a) {
a.destroy(true, { children: true });
}
host.replaceChildren();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const canvas = host.querySelector('canvas');
if (canvas instanceof HTMLCanvasElement) {
canvas.style.pointerEvents = interactive ? 'auto' : 'none';
}
}, [interactive]);
useEffect(() => {
applyMergedState(state, draftRef.current);
}, [state]);
useEffect(() => {
// Если viewport не задан — используем “весь холст”.
viewportProvidedRef.current = Boolean(viewport);
if (viewport) viewportRef.current = viewport;
else viewportRef.current = { x: 0, y: 0, w: sizeRef.current.w, h: sizeRef.current.h };
const pixi = pixiRef.current;
const root = rootRef.current;
const app = appRef.current;
if (!pixi || !root) return;
syncNodes(pixi, root, nodesRef.current, stateRef.current, sizeRef.current, viewportRef.current);
// При остановленном ticker (или между кадрами) иначе зум/пан не отрисуется.
try {
app?.render?.();
} catch {
/* ignore */
}
}, [viewport]);
const hostClass = [styles.host, interactive ? styles.hostInteractive : styles.hostPassthrough].join(' ');
return <div ref={hostRef} className={hostClass} style={style} />;
});
function syncNodes(
pixi: any,
root: any,
nodes: Map<string, any>,
state: EffectsState | null,
size: { w: number; h: number },
viewport: { x: number; y: number; w: number; h: number },
) {
const nowMs = state?.serverNowMs ?? Date.now();
const liveInstances = (state?.instances ?? []).filter((i) => isRenderableInstance(i, nowMs));
const desired = new Set<string>(liveInstances.map((i) => i.id));
for (const [id, node] of nodes.entries()) {
if (desired.has(id)) continue;
const fx = (node as any).__fx;
fx?.video?.pause?.();
if (fx?.video) {
fx.video.src = '';
}
root.removeChild(node);
node.destroy?.({ children: true });
nodes.delete(id);
}
if (!state) return;
for (const inst of liveInstances) {
const contentSig = instanceContentSig(inst);
const sig = `${contentSig}@${viewportSig(viewport)}`;
const existing = nodes.get(inst.id);
if (existing && (existing as any).__sig === sig) continue;
// Water draft: перерисовываем Graphics in-place (без destroy/create на каждую точку).
if (
existing &&
inst.id === '__draft__' &&
inst.type === 'water' &&
(existing as any).__fx?.kind === 'waterDraft'
) {
const halfW = Math.max(1.5, inst.radiusN * Math.min(viewport.w, viewport.h));
redrawWaterDraft((existing as any).__fx.g, inst, viewport, halfW);
existing.alpha = Math.max(0.35, Math.min(0.95, inst.opacity * 1.1));
(existing as any).__contentSig = contentSig;
(existing as any).__sig = sig;
continue;
}
// Тот же инстанс, сменился только viewport (зум/пан) — двигаем in-place.
if (
existing &&
(existing as any).__contentSig === contentSig &&
relayoutInstanceNode(pixi, existing, inst, viewport)
) {
(existing as any).__sig = sig;
continue;
}
if (existing) {
const fx = (existing as any).__fx;
fx?.video?.pause?.();
if (fx?.video) {
fx.video.src = '';
}
root.removeChild(existing);
existing.destroy?.({ children: true });
nodes.delete(inst.id);
}
const node = createInstanceNode(pixi, inst, size, viewport);
if (!node) continue;
(node as any).__contentSig = contentSig;
(node as any).__sig = sig;
nodes.set(inst.id, node);
root.addChild(node);
}
}
function isRenderableInstance(inst: EffectInstance, nowMs: number): boolean {
if (!('lifetimeMs' in inst) || inst.lifetimeMs === null) return true;
return nowMs - inst.createdAtMs < inst.lifetimeMs;
}
function createInstanceNode(
pixi: any,
inst: EffectInstance,
_size: { w: number; h: number },
viewport: { x: number; y: number; w: number; h: number },
): any | null {
const { x: vx, y: vy, w, h } = viewport;
if (inst.type === 'fog') {
const base = inst.opacity;
const minDim = Math.min(w, h);
const r = inst.radiusN * minDim;
const textures = getFogVfxFrameTextures(pixi);
const c = new pixi.Container();
for (let i = 0; i < inst.points.length; i += 1) {
const p = inst.points[i];
if (!p) continue;
const s = new pixi.Sprite(textures[0] ?? pixi.Texture.EMPTY);
s.anchor?.set?.(0.5, 0.5);
s.x = vx + p.x * w;
s.y = vy + p.y * h;
const fogSize = Math.max(4, r * 2);
s.width = fogSize;
s.height = fogSize;
s.alpha = base;
s.tint = 0xd8d8d8;
s.blendMode = pixi.BLEND_MODES?.NORMAL ?? 0;
(s as any).__fx = {
phase: hash01(inst.seed, i),
w0: s.width,
h0: s.height,
};
c.addChild(s);
}
(c as any).__fx = { id: inst.id, type: inst.type, textures };
c.alpha = 1;
void preloadFogVfxFrameTextures(pixi).then((loadedTextures) => {
const fx = (c as any).__fx;
if (fx?.type !== 'fog') return;
fx.textures = loadedTextures;
for (const child of c.children ?? []) {
if (loadedTextures[0] && child.texture === pixi.Texture.EMPTY) {
child.texture = loadedTextures[0];
}
}
});
return c;
}
if (inst.type === 'fire') {
const base = 1;
const minDim = Math.min(w, h);
const r = inst.radiusN * minDim;
const textures = getGroundFireVfxFrameTextures(pixi);
const c = new pixi.Container();
for (let i = 0; i < inst.points.length; i += 1) {
const p = inst.points[i];
if (!p) continue;
const s = new pixi.Sprite(textures[0] ?? pixi.Texture.EMPTY);
s.anchor?.set?.(0.5, 0.62);
s.x = vx + p.x * w;
s.y = vy + p.y * h;
const flameWidth = Math.max(r * 3.2, minDim * 0.05);
s.width = flameWidth;
s.height = flameWidth / GROUND_FIRE_VFX_FRAME_ASPECT;
s.rotation = (hash01(inst.seed ^ 0x2f171717, i) - 0.5) * 0.18;
s.alpha = base;
s.blendMode = pixi.BLEND_MODES?.ADD ?? 1;
(s as any).__fx = {
bx: s.x,
by: s.y,
phase: hash01(inst.seed ^ 0x5317f00d, i),
wobble: 1 + 4 * hash01(inst.seed ^ 0x17171717, i),
w0: s.width,
h0: s.height,
};
c.addChild(s);
}
(c as any).__fx = { id: inst.id, type: inst.type, textures };
c.alpha = 1;
void preloadGroundFireVfxFrameTextures(pixi).then((loadedTextures) => {
const fx = (c as any).__fx;
if (fx?.type !== 'fire') return;
fx.textures = loadedTextures;
for (const child of c.children ?? []) {
if (loadedTextures[0] && child.texture === pixi.Texture.EMPTY) {
child.texture = loadedTextures[0];
}
}
});
return c;
}
if (inst.type === 'rain') {
const base = inst.opacity;
const minDim = Math.min(w, h);
const r = inst.radiusN * minDim;
const textures = getRainVfxFrameTextures(pixi);
const c = new pixi.Container();
for (let i = 0; i < inst.points.length; i += 1) {
const p = inst.points[i];
if (!p) continue;
const s = new pixi.Sprite(textures[0] ?? pixi.Texture.EMPTY);
s.anchor?.set?.(0.5, 0.5);
s.x = vx + p.x * w;
s.y = vy + p.y * h;
const rainWidth = Math.max(r * 3.8, minDim * 0.08);
s.width = rainWidth;
s.height = rainWidth / RAIN_VFX_FRAME_ASPECT;
s.rotation = (hash01(inst.seed ^ 0x2a1f6a11, i) - 0.5) * 0.12;
s.alpha = base;
s.tint = 0xffffff;
s.blendMode = pixi.BLEND_MODES?.NORMAL ?? 0;
(s as any).__fx = {
bx: s.x,
by: s.y,
phase: hash01(inst.seed ^ 0x55aa55aa, i),
drift: 2 + 8 * hash01(inst.seed ^ 0x9e3779b9, i),
alphaMul: 0.9 + 0.45 * hash01(inst.seed ^ 0x3040506, i),
w0: s.width,
h0: s.height,
};
c.addChild(s);
}
(c as any).__fx = { id: inst.id, type: inst.type, textures };
c.alpha = 1;
void preloadRainVfxFrameTextures(pixi).then((loadedTextures) => {
const fx = (c as any).__fx;
if (fx?.type !== 'rain') return;
fx.textures = loadedTextures;
for (const child of c.children ?? []) {
if (loadedTextures[0] && child.texture === pixi.Texture.EMPTY) {
child.texture = loadedTextures[0];
}
}
});
return c;
}
if (inst.type === 'water') {
const c = new pixi.Container();
const halfW = Math.max(1.5, inst.radiusN * Math.min(w, h));
if (inst.id === '__draft__') {
const g = new pixi.Graphics();
c.addChild(g);
redrawWaterDraft(g, inst, viewport, halfW);
(c as any).__fx = { kind: 'waterDraft', g };
c.alpha = Math.max(0.35, Math.min(0.95, inst.opacity * 1.1));
c.blendMode = pixi.BLEND_MODES?.NORMAL ?? 0;
return c;
}
const built = buildWaterFillSprite(pixi, inst, viewport, halfW);
if (!built) return null;
c.addChild(built.sprite);
c.addChild(built.mask);
(c as any).__fx = {
kind: 'waterVfx',
sprite: built.sprite,
textures: built.textures,
phase: hash01(inst.seed ^ 0x5ea0001, 0),
};
c.alpha = Math.max(0, Math.min(1, inst.opacity));
c.blendMode = pixi.BLEND_MODES?.NORMAL ?? 0;
void preloadWaterVfxFrameTextures(pixi).then((loadedTextures) => {
const fx = (c as any).__fx;
if (fx?.kind !== 'waterVfx') return;
fx.textures = loadedTextures;
if (loadedTextures[0] && built.sprite.texture === pixi.Texture.EMPTY) {
built.sprite.texture = loadedTextures[0];
}
});
return c;
}
if (inst.type === 'darkness') {
const tex = getDarknessScreenTexture(pixi, inst.seed, viewport);
const s = new pixi.Sprite(tex);
s.anchor?.set?.(0, 0);
s.x = viewport.x;
s.y = viewport.y;
s.width = viewport.w;
s.height = viewport.h;
s.blendMode = pixi.BLEND_MODES?.NORMAL ?? 0;
s.alpha = 0;
(s as any).__fx = { id: inst.id, type: inst.type, seed: inst.seed, vw: viewport.w, vh: viewport.h };
return s;
}
if (inst.type === 'lightning') {
const cont = new pixi.Container();
const textures = getLightningVfxFrameTextures(pixi);
const sprite = new pixi.Sprite(textures[0] ?? pixi.Texture.EMPTY);
sprite.anchor?.set?.(0.5, 0.5);
sprite.blendMode = pixi.BLEND_MODES?.ADD ?? 1;
const accentTextures = getElectricAccentFrameTextures(pixi);
const accentSprite = new pixi.Sprite(accentTextures[0] ?? pixi.Texture.EMPTY);
accentSprite.anchor?.set?.(0.5, 0.5);
accentSprite.blendMode = pixi.BLEND_MODES?.ADD ?? 1;
accentSprite.alpha = 0;
const g = new pixi.Graphics();
g.blendMode = pixi.BLEND_MODES?.ADD ?? 1;
cont.addChild(sprite);
cont.addChild(accentSprite);
cont.addChild(g);
(cont as any).__fx = {
id: inst.id,
type: inst.type,
sprite,
accentSprite,
g,
textures,
accentTextures,
};
void preloadLightningVfxFrameTextures(pixi).then((loadedTextures) => {
const fx = (cont as any).__fx;
if (fx?.type !== 'lightning') return;
fx.textures = loadedTextures;
if (loadedTextures[0] && sprite.texture === pixi.Texture.EMPTY) {
sprite.texture = loadedTextures[0];
}
});
void preloadElectricAccentFrameTextures(pixi).then((loadedTextures) => {
const fx = (cont as any).__fx;
if (fx?.type !== 'lightning') return;
fx.accentTextures = loadedTextures;
if (loadedTextures[0] && accentSprite.texture === pixi.Texture.EMPTY) {
accentSprite.texture = loadedTextures[0];
}
});
redrawLightningVfx(cont, inst, viewport, 0, Math.max(1, inst.lifetimeMs));
return cont;
}
if (inst.type === 'sunbeam') {
const cont = new pixi.Container();
const textures = getPulseDischargeFrameTextures(pixi);
const sprite = new pixi.Sprite(textures[0] ?? pixi.Texture.EMPTY);
sprite.anchor?.set?.(PULSE_DISCHARGE_IMPACT_ANCHOR_X, PULSE_DISCHARGE_IMPACT_ANCHOR_Y);
sprite.blendMode = pixi.BLEND_MODES?.ADD ?? 1;
const g = new pixi.Graphics();
g.blendMode = pixi.BLEND_MODES?.ADD ?? 1;
cont.addChild(sprite);
cont.addChild(g);
(cont as any).__fx = {
id: inst.id,
type: inst.type,
sprite,
g,
textures,
};
void preloadPulseDischargeFrameTextures(pixi).then((loadedTextures) => {
const fx = (cont as any).__fx;
if (fx?.type !== 'sunbeam') return;
fx.textures = loadedTextures;
if (loadedTextures[0] && sprite.texture === pixi.Texture.EMPTY) {
sprite.texture = loadedTextures[0];
}
});
redrawPulseDischargeVfx(cont, inst, viewport, 0, Math.max(1, inst.lifetimeMs));
return cont;
}
if (inst.type === 'poisonCloud') {
const cont = new pixi.Container();
const textures = getDustBurstFrameTextures(pixi);
const sprite = new pixi.Sprite(textures[0] ?? pixi.Texture.EMPTY);
sprite.anchor?.set?.(0.5, 1);
sprite.blendMode = pixi.BLEND_MODES?.ADD ?? 1;
sprite.tint = 0x66ff99;
const TextApi = (pixi as { Text?: new (opts: object) => any }).Text;
let skull: any = null;
if (typeof TextApi === 'function') {
skull = new TextApi({
text: '☠',
style: {
fontFamily: 'Arial, "Segoe UI Emoji", sans-serif',
fontSize: 40,
fill: 0x66ff99,
align: 'center',
},
});
skull.anchor?.set?.(0.5, 1);
skull.visible = false;
}
cont.addChild(sprite);
if (skull) cont.addChild(skull);
(cont as any).__fx = { id: inst.id, type: inst.type, sprite, skull, textures };
void preloadDustBurstFrameTextures(pixi).then((loadedTextures) => {
const fx = (cont as any).__fx;
if (fx?.type !== 'poisonCloud') return;
fx.textures = loadedTextures;
if (loadedTextures[0] && sprite.texture === pixi.Texture.EMPTY) {
sprite.texture = loadedTextures[0];
}
});
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);
s.anchor?.set?.(0, 0);
s.x = viewport.x;
s.y = viewport.y;
s.width = viewport.w;
s.height = viewport.h;
s.blendMode = pixi.BLEND_MODES?.NORMAL ?? 0;
s.alpha = 0;
(s as any).__fx = { id: inst.id, type: inst.type, seed: inst.seed, vw: viewport.w, vh: viewport.h };
return s;
}
if (inst.type === 'scorch') {
const tex = getScorchTexture(pixi);
const s = new pixi.Sprite(tex);
s.anchor?.set?.(0.5, 0.5);
s.x = vx + inst.at.x * w;
s.y = vy + inst.at.y * h;
const r = inst.radiusN * Math.min(w, h);
const scale = r / Math.max(1, tex.width * 0.5);
s.scale?.set?.(scale, scale);
s.rotation = hash01(inst.seed ^ 0xfeed, 0) * Math.PI * 2;
s.alpha = Math.max(0, Math.min(1, inst.opacity));
s.tint = 0x000000;
s.blendMode = pixi.BLEND_MODES?.MULTIPLY ?? 3;
(s as any).__fx = { id: inst.id, type: inst.type };
return s;
}
if (inst.type === 'ice') {
const tex = getIceTexture(pixi);
const s = new pixi.Sprite(tex);
s.anchor?.set?.(0.5, 0.5);
s.x = vx + inst.at.x * w;
s.y = vy + inst.at.y * h;
const r = inst.radiusN * Math.min(w, h);
const scale = r / Math.max(1, tex.width * 0.5);
s.scale?.set?.(scale, scale);
s.rotation = hash01(inst.seed ^ 0x1ced, 0) * Math.PI * 2;
s.alpha = Math.max(0, Math.min(1, inst.opacity));
// Цвет задаём в текстуре: белый прозрачный лёд + белые трещины.
s.tint = 0xffffff;
s.blendMode = pixi.BLEND_MODES?.SCREEN ?? 2;
(s as any).__fx = { id: inst.id, type: inst.type };
return s;
}
if (inst.type === 'shadow') {
const tex = getShadowTexture(pixi);
const s = new pixi.Sprite(tex);
s.anchor?.set?.(0.5, 0.5);
s.x = vx + inst.at.x * w;
s.y = vy + inst.at.y * h;
const r = inst.radiusN * Math.min(w, h);
const scale = r / Math.max(1, tex.width * 0.5);
s.scale?.set?.(scale, scale);
s.alpha = Math.max(0, Math.min(1, inst.opacity));
s.tint = 0xffffff;
s.blendMode = pixi.BLEND_MODES?.NORMAL ?? 0;
(s as any).__fx = { id: inst.id, type: inst.type };
return s;
}
return null;
}
function animateNodes(
pixi: any,
nodes: Map<string, any>,
state: EffectsState,
nowMs: number,
size: { w: number; h: number },
viewport: { x: number; y: number; w: number; h: number },
) {
const instById = new Map(state.instances.map((i) => [i.id, i]));
void pixi;
void size;
for (const [id, node] of nodes.entries()) {
const inst = instById.get(id);
if (!inst) continue;
const t = Math.max(0, nowMs - inst.createdAtMs);
if (inst.type === 'fog') {
const cont = node;
const fxRoot = (cont as any).__fx;
const textures = Array.isArray(fxRoot?.textures) ? fxRoot.textures : [];
const pulse = 0.92 + 0.08 * Math.sin(0.0012 * t + hash01(inst.seed, 0) * 6.28);
cont.alpha = 1;
for (const child of cont.children ?? []) {
const fx = (child as any).__fx;
if (!fx) continue;
if (textures.length > 0) {
const frameT = (t + (fx.phase ?? 0) * FOG_VFX_LOOP_MS) % FOG_VFX_LOOP_MS;
const frameIndex = Math.min(
textures.length - 1,
Math.floor((frameT / FOG_VFX_LOOP_MS) * textures.length),
);
const frame = textures[frameIndex];
if (frame && child.texture !== frame) {
child.texture = frame;
}
}
child.alpha = Math.max(0.05, Math.min(0.9, inst.opacity * pulse));
child.width = typeof fx.w0 === 'number' ? fx.w0 : child.width;
child.height = typeof fx.h0 === 'number' ? fx.h0 : child.height;
}
}
if (inst.type === 'fire') {
const cont = node;
const fxRoot = (cont as any).__fx;
const textures = Array.isArray(fxRoot?.textures) ? fxRoot.textures : [];
cont.alpha = 1;
for (const child of cont.children ?? []) {
const fx = (child as any).__fx;
if (!fx) continue;
if (textures.length > 0) {
const frameT = (t + (fx.phase ?? 0) * GROUND_FIRE_VFX_LOOP_MS) % GROUND_FIRE_VFX_LOOP_MS;
const frameIndex = Math.min(
textures.length - 1,
Math.floor((frameT / GROUND_FIRE_VFX_LOOP_MS) * textures.length),
);
const frame = textures[frameIndex];
if (frame && child.texture !== frame) {
child.texture = frame;
}
}
const wobble = typeof fx.wobble === 'number' ? fx.wobble : 2;
child.x = fx.bx + Math.sin(0.0016 * t + (fx.phase ?? 0) * 6.28) * wobble;
child.y = fx.by + Math.cos(0.0019 * t + (fx.phase ?? 0) * 6.28) * wobble * 0.45;
const local = 0.93 + 0.07 * Math.sin(0.0042 * t + (fx.phase ?? 0) * 12.56);
child.width = (typeof fx.w0 === 'number' ? fx.w0 : child.width) * local;
child.height = (typeof fx.h0 === 'number' ? fx.h0 : child.height) * (0.96 + (local - 0.93) * 0.7);
child.alpha = 1;
}
}
if (inst.type === 'water') {
const cont = node;
const pulse = 0.94 + 0.06 * Math.sin(0.0011 * t + hash01(inst.seed ^ 0xc001d00d, 0) * 6.28);
cont.alpha = Math.max(0, Math.min(1, inst.opacity * pulse));
const fxRoot = (cont as any).__fx;
const sprite = fxRoot?.sprite;
const textures = Array.isArray(fxRoot?.textures) ? fxRoot.textures : [];
if (sprite && textures.length > 0) {
const frameT = (t + (fxRoot.phase ?? 0) * WATER_VFX_LOOP_MS) % WATER_VFX_LOOP_MS;
const frameIndex = Math.min(
textures.length - 1,
Math.floor((frameT / WATER_VFX_LOOP_MS) * textures.length),
);
const frame = textures[frameIndex];
if (frame && sprite.texture !== frame) {
sprite.texture = frame;
}
}
}
if (inst.type === 'rain') {
const cont = node;
const fxRoot = (cont as any).__fx;
const textures = Array.isArray(fxRoot?.textures) ? fxRoot.textures : [];
const base = 0.94 + 0.06 * Math.sin(0.0018 * t + hash01(inst.seed ^ 0xdeadbeef, 0) * 6.28);
cont.alpha = 1;
for (const child of cont.children ?? []) {
const fx = (child as any).__fx;
if (!fx) continue;
if (textures.length > 0) {
const frameT = (t + (fx.phase ?? 0) * RAIN_VFX_LOOP_MS) % RAIN_VFX_LOOP_MS;
const frameIndex = Math.min(
textures.length - 1,
Math.floor((frameT / RAIN_VFX_LOOP_MS) * textures.length),
);
const frame = textures[frameIndex];
if (frame && child.texture !== frame) {
child.texture = frame;
}
}
const alphaMul = typeof fx.alphaMul === 'number' ? fx.alphaMul : 1;
const wob = typeof fx.drift === 'number' ? fx.drift : 0;
child.x = fx.bx + Math.sin(0.0011 * t + (fx.phase ?? 0) * 6.28) * wob;
child.y = fx.by + Math.cos(0.0014 * t + (fx.phase ?? 0) * 6.28) * wob * 0.35;
child.width = typeof fx.w0 === 'number' ? fx.w0 : child.width;
child.height = typeof fx.h0 === 'number' ? fx.h0 : child.height;
child.alpha = Math.max(0.25, Math.min(1, inst.opacity * base * alphaMul));
}
}
if (inst.type === 'lightning') {
const cont = node;
const life = Math.max(1, inst.lifetimeMs);
if (t >= life) {
cont.visible = false;
continue;
}
cont.visible = true;
redrawLightningVfx(cont, inst, viewport, t, life);
}
if (inst.type === 'sunbeam') {
const cont = node;
const life = Math.max(1, inst.lifetimeMs);
if (t >= life) {
cont.visible = false;
continue;
}
cont.visible = true;
redrawPulseDischargeVfx(cont, inst, viewport, t, life);
}
if (inst.type === 'poisonCloud') {
const cont = node;
const life = Math.max(1, inst.lifetimeMs);
if (t >= life) {
cont.visible = false;
continue;
}
cont.visible = true;
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);
if (t >= life) {
s.visible = false;
continue;
}
s.visible = true;
const fx = (s as any).__fx ?? {};
if (fx.vw !== viewport.w || fx.vh !== viewport.h) {
s.texture = getFreezeScreenTexture(pixi, inst.seed, viewport);
fx.vw = viewport.w;
fx.vh = viewport.h;
(s as any).__fx = fx;
}
s.x = viewport.x;
s.y = viewport.y;
s.width = viewport.w;
s.height = viewport.h;
s.alpha = freezeAlpha(t, life) * Math.max(0, Math.min(1.2, inst.intensity));
}
if (inst.type === 'darkness') {
const s = node;
const life = Math.max(1, inst.lifetimeMs);
if (t >= life) {
s.visible = false;
continue;
}
s.visible = true;
const fx = (s as any).__fx ?? {};
if (fx.vw !== viewport.w || fx.vh !== viewport.h) {
s.texture = getDarknessScreenTexture(pixi, inst.seed, viewport);
fx.vw = viewport.w;
fx.vh = viewport.h;
(s as any).__fx = fx;
}
s.x = viewport.x;
s.y = viewport.y;
s.width = viewport.w;
s.height = viewport.h;
s.alpha = freezeAlpha(t, life) * Math.max(0, Math.min(1.2, inst.intensity));
}
if (inst.type === 'scorch') {
const s = node;
const life = Math.max(1, inst.lifetimeMs);
const fade = 1 - t / life;
s.visible = fade > 0;
s.alpha = Math.max(0, Math.min(1, inst.opacity * fade));
}
if (inst.type === 'ice') {
const s = node;
// Пятно льда: сразу на полную яркость и остаётся на сцене (не синхронизируем с длительностью «замершего экрана»).
s.visible = true;
s.alpha = Math.max(0, Math.min(1, inst.opacity));
}
}
}
function hashWaterStroke(inst: { points: { x: number; y: number }[] }): number {
let h = 2166136261 >>> 0;
for (const p of inst.points) {
if (!p) continue;
h ^= Math.round(p.x * 10000);
h = Math.imul(h, 16777619);
h ^= Math.round(p.y * 10000);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
function redrawWaterDraft(
g: any,
inst: Extract<EffectInstance, { type: 'water' }>,
viewport: { x: number; y: number; w: number; h: number },
halfW: number,
) {
const { x: vx, y: vy, w, h } = viewport;
g.clear();
const pts = inst.points;
if (pts.length === 0) return;
const px = pts.map((p) => ({ x: vx + p.x * w, y: vy + p.y * h }));
// Толщина как у итоговой заливки: диаметр = 2 × радиус кисти (см. buildWaterFillSprite).
const lineW = Math.max(2, halfW * 2);
if (px.length === 1) {
const p0 = px[0];
if (!p0) return;
g.circle(p0.x, p0.y, Math.max(2, halfW));
g.fill({ color: 0x5fc3ff, alpha: 0.42 });
return;
}
const pStart = px[0];
if (!pStart) return;
g.moveTo(pStart.x, pStart.y);
for (let i = 1; i < px.length; i += 1) {
const pi = px[i];
if (!pi) continue;
g.lineTo(pi.x, pi.y);
}
g.stroke({ width: lineW, color: 0x5fc3ff, alpha: 0.48, cap: 'round', join: 'round' });
}
function buildWaterFillSprite(
pixi: any,
inst: Extract<EffectInstance, { type: 'water' }>,
viewport: { x: number; y: number; w: number; h: number },
halfW: number,
): { sprite: any; mask: any; textures: any[] } | null {
const pts = inst.points;
if (pts.length === 0) return null;
const { x: vx, y: vy, w, h } = viewport;
const pxPts = pts.map((p) => ({ x: vx + p.x * w, y: vy + p.y * h }));
const firstPt = pxPts[0];
if (!firstPt) return null;
let minX = firstPt.x;
let maxX = firstPt.x;
let minY = firstPt.y;
let maxY = firstPt.y;
for (const p of pxPts) {
minX = Math.min(minX, p.x);
maxX = Math.max(maxX, p.x);
minY = Math.min(minY, p.y);
maxY = Math.max(maxY, p.y);
}
const pad = halfW * 2 + 8;
const cw = Math.max(1, Math.ceil(maxX - minX + pad));
const ch = Math.max(1, Math.ceil(maxY - minY + pad));
const canvas = document.createElement('canvas');
canvas.width = cw;
canvas.height = ch;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
const ox = -minX + pad / 2;
const oy = -minY + pad / 2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = 'rgba(255, 255, 255, 1)';
ctx.fillStyle = 'rgba(255, 255, 255, 1)';
ctx.lineWidth = halfW * 2;
if (pxPts.length === 1) {
ctx.beginPath();
ctx.arc(firstPt.x + ox, firstPt.y + oy, halfW, 0, Math.PI * 2);
ctx.fill();
} else {
ctx.beginPath();
ctx.moveTo(firstPt.x + ox, firstPt.y + oy);
for (let i = 1; i < pxPts.length; i += 1) {
const pi = pxPts[i];
if (!pi) continue;
ctx.lineTo(pi.x + ox, pi.y + oy);
}
ctx.stroke();
}
const maskTexture = pixi.Texture.from(canvas);
const mask = new pixi.Sprite(maskTexture);
mask.x = minX - pad / 2;
mask.y = minY - pad / 2;
mask.width = cw;
mask.height = ch;
mask.renderable = false;
const textures = getWaterVfxFrameTextures(pixi);
const sprite = new pixi.Sprite(textures[0] ?? pixi.Texture.EMPTY);
sprite.x = minX - pad / 2;
sprite.y = minY - pad / 2;
sprite.width = cw;
sprite.height = ch;
sprite.blendMode = pixi.BLEND_MODES?.NORMAL ?? 0;
sprite.mask = mask;
return { sprite, mask, textures };
}
function redrawPoisonCloud(
cont: any,
inst: Extract<EffectInstance, { type: 'poisonCloud' }>,
viewport: { x: number; y: number; w: number; h: number },
t: number,
life: number,
) {
const fx = (cont as any).__fx;
const sprite = fx?.sprite;
const skull = fx?.skull;
const textures = Array.isArray(fx?.textures) ? fx.textures : [];
if (!sprite) return;
const { x: vx, y: vy, w, h } = viewport;
const minDim = Math.min(w, h);
const cx = vx + inst.at.x * w;
const cy = vy + inst.at.y * h;
const R = Math.max(18, inst.radiusN * minDim * 1.35);
const p = Math.max(0, Math.min(1, t / Math.min(life, DUST_BURST_VFX_DURATION_MS)));
const fadeIn = Math.min(1, p / 0.08);
const fadeOut = Math.min(1, (1 - p) / 0.18);
const alpha = Math.max(0, Math.min(1, fadeIn * fadeOut * inst.intensity));
const frame = textures[Math.min(textures.length - 1, Math.floor(p * textures.length))];
if (frame && sprite.texture !== frame) {
sprite.texture = frame;
}
const width = Math.max(R * 5.2, minDim * 0.18);
sprite.x = cx;
sprite.y = cy;
sprite.width = width;
sprite.height = width / DUST_BURST_VFX_FRAME_ASPECT;
sprite.alpha = alpha;
if (skull) {
const show = p > 0.18 && p < 0.92;
skull.visible = show;
skull.alpha = show ? Math.max(0, Math.min(1, alpha * 0.95)) : 0;
skull.x = cx;
skull.y = cy - R * 0.72;
const fs = Math.max(18, Math.min(92, R * 0.92));
if (skull.style && typeof skull.style === 'object' && 'fontSize' in skull.style) {
(skull.style as { fontSize: number }).fontSize = fs;
}
}
}
function redrawPulseDischargeVfx(
cont: any,
inst: Extract<EffectInstance, { type: 'sunbeam' }>,
viewport: { x: number; y: number; w: number; h: number },
t: number,
life: number,
) {
const fx = (cont as any).__fx;
const sprite = fx?.sprite;
const g = fx?.g;
const textures = Array.isArray(fx?.textures) ? fx.textures : [];
if (!sprite || !g) return;
const { x: vx, y: vy, w, h } = viewport;
const minDim = Math.min(w, h);
const sx = vx + inst.start.x * w;
const sy = vy + inst.start.y * h;
const ex = vx + inst.end.x * w;
const ey = vy + inst.end.y * h;
const dx = ex - sx;
const dy = ey - sy;
const len = Math.max(1, Math.hypot(dx, dy));
const p = Math.max(0, Math.min(1, t / Math.min(life, PULSE_DISCHARGE_VFX_DURATION_MS)));
const fadeIn = Math.min(1, p / 0.08);
const fadeOut = Math.min(1, (1 - p) / 0.16);
const alpha = Math.max(0, Math.min(1, fadeIn * fadeOut * inst.intensity));
const frame = textures[Math.min(textures.length - 1, Math.floor(p * textures.length))];
if (frame && sprite.texture !== frame) {
sprite.texture = frame;
}
sprite.x = ex;
sprite.y = ey;
sprite.rotation = Math.atan2(dy, dx);
sprite.width = Math.max(len * 1.04, minDim * 0.28);
sprite.height = Math.max(inst.widthN * minDim * 20, sprite.width / PULSE_DISCHARGE_VFX_FRAME_ASPECT);
sprite.alpha = alpha;
g.clear();
}
function lightningTrailVfxFrameUrl(frameIndex: number): string {
return new URL(
`vfx/lightning-trail-01/frame_${String(frameIndex).padStart(2, '0')}.webp`,
window.location.href,
).href;
}
function electricAccentFrameUrl(frameIndex: number): string {
return new URL(
`vfx/electric-accent-10/frame_${String(frameIndex).padStart(2, '0')}.webp`,
window.location.href,
).href;
}
function fogVfxFrameUrl(frameIndex: number): string {
return new URL(
`vfx/smokey-atmosphere-1-round/frame_${String(frameIndex).padStart(3, '0')}.webp`,
window.location.href,
).href;
}
function groundFireVfxFrameUrl(frameIndex: number): string {
return new URL(`vfx/small-fire/frame_${String(frameIndex).padStart(3, '0')}.webp`, window.location.href)
.href;
}
function rainVfxFrameUrl(frameIndex: number): string {
return new URL(
`vfx/real-medium-rain-1/frame_${String(frameIndex).padStart(3, '0')}.webp`,
window.location.href,
).href;
}
function waterVfxFrameUrl(frameIndex: number): string {
return new URL(
`vfx/generated-water/frame_${String(frameIndex).padStart(3, '0')}.webp`,
window.location.href,
).href;
}
function pulseDischargeFrameUrl(frameIndex: number): string {
return new URL(
`vfx/pulse-discharge/frame_${String(frameIndex).padStart(3, '0')}.webp`,
window.location.href,
).href;
}
function dustBurstFrameUrl(frameIndex: number): string {
return new URL(
`vfx/dust-burst-large-2/frame_${String(frameIndex).padStart(3, '0')}.webp`,
window.location.href,
).href;
}
function getLightningVfxFrameTextures(pixi: any): any[] {
if (pixi !== null && (typeof pixi === 'object' || typeof pixi === 'function')) {
const existing = lightningVfxFrameTextureCache.get(pixi);
if (existing) return existing.textures;
}
const textures: any[] = [];
if (pixi !== null && (typeof pixi === 'object' || typeof pixi === 'function')) {
lightningVfxFrameTextureCache.set(pixi, { textures });
}
void preloadLightningVfxFrameTextures(pixi);
return textures;
}
function getElectricAccentFrameTextures(pixi: any): any[] {
if (pixi !== null && (typeof pixi === 'object' || typeof pixi === 'function')) {
const existing = electricAccentFrameTextureCache.get(pixi);
if (existing) return existing.textures;
}
const textures: any[] = [];
if (pixi !== null && (typeof pixi === 'object' || typeof pixi === 'function')) {
electricAccentFrameTextureCache.set(pixi, { textures });
}
void preloadElectricAccentFrameTextures(pixi);
return textures;
}
function getFogVfxFrameTextures(pixi: any): any[] {
if (pixi !== null && (typeof pixi === 'object' || typeof pixi === 'function')) {
const existing = fogVfxFrameTextureCache.get(pixi);
if (existing) return existing.textures;
}
const textures: any[] = [];
if (pixi !== null && (typeof pixi === 'object' || typeof pixi === 'function')) {
fogVfxFrameTextureCache.set(pixi, { textures });
}
void preloadFogVfxFrameTextures(pixi);
return textures;
}
function getGroundFireVfxFrameTextures(pixi: any): any[] {
if (pixi !== null && (typeof pixi === 'object' || typeof pixi === 'function')) {
const existing = groundFireVfxFrameTextureCache.get(pixi);
if (existing) return existing.textures;
}
const textures: any[] = [];
if (pixi !== null && (typeof pixi === 'object' || typeof pixi === 'function')) {
groundFireVfxFrameTextureCache.set(pixi, { textures });
}
void preloadGroundFireVfxFrameTextures(pixi);
return textures;
}
function getRainVfxFrameTextures(pixi: any): any[] {
if (pixi !== null && (typeof pixi === 'object' || typeof pixi === 'function')) {
const existing = rainVfxFrameTextureCache.get(pixi);
if (existing) return existing.textures;
}
const textures: any[] = [];
if (pixi !== null && (typeof pixi === 'object' || typeof pixi === 'function')) {
rainVfxFrameTextureCache.set(pixi, { textures });
}
void preloadRainVfxFrameTextures(pixi);
return textures;
}
function getWaterVfxFrameTextures(pixi: any): any[] {
if (pixi !== null && (typeof pixi === 'object' || typeof pixi === 'function')) {
const existing = waterVfxFrameTextureCache.get(pixi);
if (existing) return existing.textures;
}
const textures: any[] = [];
if (pixi !== null && (typeof pixi === 'object' || typeof pixi === 'function')) {
waterVfxFrameTextureCache.set(pixi, { textures });
}
void preloadWaterVfxFrameTextures(pixi);
return textures;
}
function getPulseDischargeFrameTextures(pixi: any): any[] {
if (pixi !== null && (typeof pixi === 'object' || typeof pixi === 'function')) {
const existing = pulseDischargeFrameTextureCache.get(pixi);
if (existing) return existing.textures;
}
const textures: any[] = [];
if (pixi !== null && (typeof pixi === 'object' || typeof pixi === 'function')) {
pulseDischargeFrameTextureCache.set(pixi, { textures });
}
void preloadPulseDischargeFrameTextures(pixi);
return textures;
}
function getDustBurstFrameTextures(pixi: any): any[] {
if (pixi !== null && (typeof pixi === 'object' || typeof pixi === 'function')) {
const existing = dustBurstFrameTextureCache.get(pixi);
if (existing) return existing.textures;
}
const textures: any[] = [];
if (pixi !== null && (typeof pixi === 'object' || typeof pixi === 'function')) {
dustBurstFrameTextureCache.set(pixi, { textures });
}
void preloadDustBurstFrameTextures(pixi);
return textures;
}
function preloadLightningVfxFrameTextures(pixi: any): Promise<any[]> {
if (pixi === null || (typeof pixi !== 'object' && typeof pixi !== 'function')) {
return Promise.resolve([]);
}
const cached = lightningVfxFrameTextureCache.get(pixi);
if (cached?.textures.length) return Promise.resolve(cached.textures);
if (cached?.loading) return cached.loading;
const record = cached ?? { textures: [] };
const loading = Promise.all(
Array.from({ length: LIGHTNING_VFX_FRAME_COUNT }, async (_, i) => {
const image = await loadImageElement(lightningTrailVfxFrameUrl(i + 1));
return pixi.Texture.from(image) ?? pixi.Texture.EMPTY;
}),
)
.then((textures) => {
record.textures.splice(0, record.textures.length, ...textures);
return record.textures;
})
.catch(() => record.textures);
record.loading = loading;
lightningVfxFrameTextureCache.set(pixi, record);
return loading;
}
function preloadElectricAccentFrameTextures(pixi: any): Promise<any[]> {
if (pixi === null || (typeof pixi !== 'object' && typeof pixi !== 'function')) {
return Promise.resolve([]);
}
const cached = electricAccentFrameTextureCache.get(pixi);
if (cached?.textures.length) return Promise.resolve(cached.textures);
if (cached?.loading) return cached.loading;
const record = cached ?? { textures: [] };
const loading = Promise.all(
Array.from({ length: ELECTRIC_ACCENT_FRAME_COUNT }, async (_, i) => {
const image = await loadImageElement(electricAccentFrameUrl(i + 1));
return pixi.Texture.from(image) ?? pixi.Texture.EMPTY;
}),
)
.then((textures) => {
record.textures.splice(0, record.textures.length, ...textures);
return record.textures;
})
.catch(() => record.textures);
record.loading = loading;
electricAccentFrameTextureCache.set(pixi, record);
return loading;
}
function preloadFogVfxFrameTextures(pixi: any): Promise<any[]> {
if (pixi === null || (typeof pixi !== 'object' && typeof pixi !== 'function')) {
return Promise.resolve([]);
}
const cached = fogVfxFrameTextureCache.get(pixi);
if (cached?.textures.length) return Promise.resolve(cached.textures);
if (cached?.loading) return cached.loading;
const record = cached ?? { textures: [] };
const loading = Promise.all(
Array.from({ length: FOG_VFX_FRAME_COUNT }, async (_, i) => {
const image = await loadImageElement(fogVfxFrameUrl(i + 1));
return pixi.Texture.from(image) ?? pixi.Texture.EMPTY;
}),
)
.then((textures) => {
record.textures.splice(0, record.textures.length, ...textures);
return record.textures;
})
.catch(() => record.textures);
record.loading = loading;
fogVfxFrameTextureCache.set(pixi, record);
return loading;
}
function preloadGroundFireVfxFrameTextures(pixi: any): Promise<any[]> {
if (pixi === null || (typeof pixi !== 'object' && typeof pixi !== 'function')) {
return Promise.resolve([]);
}
const cached = groundFireVfxFrameTextureCache.get(pixi);
if (cached?.textures.length) return Promise.resolve(cached.textures);
if (cached?.loading) return cached.loading;
const record = cached ?? { textures: [] };
const loading = Promise.all(
Array.from({ length: GROUND_FIRE_VFX_FRAME_COUNT }, async (_, i) => {
const image = await loadImageElement(groundFireVfxFrameUrl(i + 1));
return pixi.Texture.from(image) ?? pixi.Texture.EMPTY;
}),
)
.then((textures) => {
record.textures.splice(0, record.textures.length, ...textures);
return record.textures;
})
.catch(() => record.textures);
record.loading = loading;
groundFireVfxFrameTextureCache.set(pixi, record);
return loading;
}
function preloadRainVfxFrameTextures(pixi: any): Promise<any[]> {
if (pixi === null || (typeof pixi !== 'object' && typeof pixi !== 'function')) {
return Promise.resolve([]);
}
const cached = rainVfxFrameTextureCache.get(pixi);
if (cached?.textures.length) return Promise.resolve(cached.textures);
if (cached?.loading) return cached.loading;
const record = cached ?? { textures: [] };
const loading = Promise.all(
Array.from({ length: RAIN_VFX_FRAME_COUNT }, async (_, i) => {
const image = await loadImageElement(rainVfxFrameUrl(i + 1));
return pixi.Texture.from(image) ?? pixi.Texture.EMPTY;
}),
)
.then((textures) => {
record.textures.splice(0, record.textures.length, ...textures);
return record.textures;
})
.catch(() => record.textures);
record.loading = loading;
rainVfxFrameTextureCache.set(pixi, record);
return loading;
}
function preloadWaterVfxFrameTextures(pixi: any): Promise<any[]> {
if (pixi === null || (typeof pixi !== 'object' && typeof pixi !== 'function')) {
return Promise.resolve([]);
}
const cached = waterVfxFrameTextureCache.get(pixi);
if (cached?.textures.length) return Promise.resolve(cached.textures);
if (cached?.loading) return cached.loading;
const record = cached ?? { textures: [] };
const loading = Promise.all(
Array.from({ length: WATER_VFX_FRAME_COUNT }, async (_, i) => {
const image = await loadImageElement(waterVfxFrameUrl(i + 1));
return pixi.Texture.from(image) ?? pixi.Texture.EMPTY;
}),
)
.then((textures) => {
record.textures.splice(0, record.textures.length, ...textures);
return record.textures;
})
.catch(() => record.textures);
record.loading = loading;
waterVfxFrameTextureCache.set(pixi, record);
return loading;
}
function preloadPulseDischargeFrameTextures(pixi: any): Promise<any[]> {
if (pixi === null || (typeof pixi !== 'object' && typeof pixi !== 'function')) {
return Promise.resolve([]);
}
const cached = pulseDischargeFrameTextureCache.get(pixi);
if (cached?.textures.length) return Promise.resolve(cached.textures);
if (cached?.loading) return cached.loading;
const record = cached ?? { textures: [] };
const loading = Promise.all(
Array.from({ length: PULSE_DISCHARGE_VFX_FRAME_COUNT }, async (_, i) => {
const image = await loadImageElement(pulseDischargeFrameUrl(i + 1));
return pixi.Texture.from(image) ?? pixi.Texture.EMPTY;
}),
)
.then((textures) => {
record.textures.splice(0, record.textures.length, ...textures);
return record.textures;
})
.catch(() => record.textures);
record.loading = loading;
pulseDischargeFrameTextureCache.set(pixi, record);
return loading;
}
function preloadDustBurstFrameTextures(pixi: any): Promise<any[]> {
if (pixi === null || (typeof pixi !== 'object' && typeof pixi !== 'function')) {
return Promise.resolve([]);
}
const cached = dustBurstFrameTextureCache.get(pixi);
if (cached?.textures.length) return Promise.resolve(cached.textures);
if (cached?.loading) return cached.loading;
const record = cached ?? { textures: [] };
const loading = Promise.all(
Array.from({ length: DUST_BURST_VFX_FRAME_COUNT }, async (_, i) => {
const image = await loadImageElement(dustBurstFrameUrl(i + 1));
return pixi.Texture.from(image) ?? pixi.Texture.EMPTY;
}),
)
.then((textures) => {
record.textures.splice(0, record.textures.length, ...textures);
return record.textures;
})
.catch(() => record.textures);
record.loading = loading;
dustBurstFrameTextureCache.set(pixi, record);
return loading;
}
async function loadImageElement(src: string): Promise<HTMLImageElement> {
const image = new Image();
image.decoding = 'async';
image.src = src;
if (!image.complete) {
await new Promise<void>((resolve, reject) => {
image.addEventListener('load', () => resolve(), { once: true });
image.addEventListener('error', () => reject(new Error(`Failed to load image: ${src}`)), {
once: true,
});
});
}
await image.decode?.().catch(() => undefined);
return image;
}
function redrawLightningVfx(
cont: any,
inst: Extract<EffectInstance, { type: 'lightning' }>,
viewport: { x: number; y: number; w: number; h: number },
t: number,
life: number,
) {
const fx = (cont as any).__fx;
const sprite = fx?.sprite;
const accentSprite = fx?.accentSprite;
const g = fx?.g;
const textures = Array.isArray(fx?.textures) ? fx.textures : [];
const accentTextures = Array.isArray(fx?.accentTextures) ? fx.accentTextures : [];
if (!sprite || !g) return;
const { x: vx, y: vy, w, h } = viewport;
const minDim = Math.min(w, h);
const ex = vx + inst.end.x * w;
const ey = vy + inst.end.y * h;
const strikeLife = Math.min(Math.max(1, life), LIGHTNING_VFX_STRIKE_MS);
const p = Math.max(0, Math.min(1, t / strikeLife));
const fadeIn = Math.min(1, p / 0.08);
const fadeOut = Math.min(1, (1 - p) / 0.18);
const alpha = Math.max(0, Math.min(1, fadeIn * fadeOut * inst.intensity));
const hasFrameTextures = textures.length >= LIGHTNING_VFX_FRAME_COUNT;
const frame = textures[Math.min(textures.length - 1, Math.floor(p * textures.length))];
if (frame && sprite.texture !== frame) {
sprite.texture = frame;
}
const strikeHeight = Math.max(minDim * 0.22, ey - vy);
sprite.anchor?.set?.(0.5, LIGHTNING_VFX_IMPACT_ANCHOR_Y);
sprite.x = ex;
sprite.y = ey;
sprite.rotation = 0;
sprite.height = strikeHeight * 1.04;
sprite.width = Math.max(inst.widthN * minDim * 18, strikeHeight * LIGHTNING_VFX_FRAME_ASPECT * 0.72);
sprite.alpha = Math.min(1, alpha);
if (accentSprite) {
const accentEndMs = ELECTRIC_ACCENT_START_MS + ELECTRIC_ACCENT_DURATION_MS;
const accentP = Math.max(0, Math.min(1, (t - ELECTRIC_ACCENT_START_MS) / ELECTRIC_ACCENT_DURATION_MS));
const accentFrame =
accentTextures[Math.min(accentTextures.length - 1, Math.floor(accentP * accentTextures.length))];
if (accentFrame && accentSprite.texture !== accentFrame) {
accentSprite.texture = accentFrame;
}
const accentFadeOut = Math.min(1, (accentEndMs - t) / 130);
const accentAlpha =
t >= ELECTRIC_ACCENT_START_MS &&
t <= accentEndMs &&
accentTextures.length >= ELECTRIC_ACCENT_FRAME_COUNT
? Math.max(0, Math.min(1, accentFadeOut * inst.intensity))
: 0;
const accentWidth = Math.max(minDim * 0.2, Math.min(minDim * 0.48, inst.widthN * minDim * 18));
accentSprite.x = ex;
accentSprite.y = ey;
accentSprite.rotation = 0;
accentSprite.width = accentWidth;
accentSprite.height = accentWidth / ELECTRIC_ACCENT_FRAME_ASPECT;
accentSprite.alpha = accentAlpha;
}
g.clear();
if (!hasFrameTextures) {
const boltAlpha = Math.max(0, Math.min(1, alpha * 1.15));
const topY = Math.max(vy, ey - strikeHeight);
const segments = 10;
let px = ex;
let py = topY;
for (let i = 1; i <= segments; i += 1) {
const u = i / segments;
const nextY = topY + (ey - topY) * u;
const wobble =
(hash01(inst.seed ^ 0x91e10f, i) - 0.5) * inst.widthN * minDim * 7 * (1 - Math.abs(u - 0.5));
const nextX = ex + wobble;
g.moveTo(px, py);
g.lineTo(nextX, nextY);
g.stroke({ color: 0x7eeeff, width: Math.max(2, inst.widthN * minDim * 2.5), alpha: boltAlpha * 0.65 });
g.stroke({ color: 0xffffff, width: Math.max(1, inst.widthN * minDim * 1.1), alpha: boltAlpha });
px = nextX;
py = nextY;
}
}
}
function viewportSig(viewport: { x: number; y: number; w: number; h: number }): string {
return `${Math.round(viewport.x)}:${Math.round(viewport.y)}:${Math.round(viewport.w)}:${Math.round(viewport.h)}`;
}
/** Сигнатура содержимого инстанса без viewport — для in-place relayout при зуме/пане. */
function instanceContentSig(inst: EffectInstance): string {
if (inst.type === 'fog') {
const last = inst.points[inst.points.length - 1];
const lx = last ? Math.round(last.x * 1000) : 0;
const ly = last ? Math.round(last.y * 1000) : 0;
return `fog:${inst.points.length}:${lx}:${ly}:${Math.round(inst.radiusN * 1000)}`;
}
if (inst.type === 'fire') {
const last = inst.points[inst.points.length - 1];
const lx = last ? Math.round(last.x * 1000) : 0;
const ly = last ? Math.round(last.y * 1000) : 0;
return `fire:${inst.points.length}:${lx}:${ly}:${Math.round(inst.radiusN * 1000)}`;
}
if (inst.type === 'rain') {
const last = inst.points[inst.points.length - 1];
const lx = last ? Math.round(last.x * 1000) : 0;
const ly = last ? Math.round(last.y * 1000) : 0;
return `rain:${inst.points.length}:${lx}:${ly}:${Math.round(inst.radiusN * 1000)}`;
}
if (inst.type === 'water') {
const hp = hashWaterStroke(inst);
return `water:${inst.points.length}:${hp}:${Math.round(inst.radiusN * 1000)}:${Math.round(inst.opacity * 1000)}`;
}
if (inst.type === 'lightning') {
return `lt:${Math.round(inst.end.x * 1000)}:${Math.round(inst.end.y * 1000)}:${Math.round(inst.widthN * 1000)}`;
}
if (inst.type === 'sunbeam') {
return `sb:${Math.round(inst.end.x * 1000)}:${Math.round(inst.end.y * 1000)}:${Math.round(inst.widthN * 1000)}`;
}
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)}`;
}
if (inst.type === 'darkness') {
return `dk:${Math.round(inst.at.x * 1000)}:${Math.round(inst.at.y * 1000)}:${Math.round(inst.intensity * 1000)}`;
}
if (inst.type === 'scorch') {
return `sc:${Math.round(inst.at.x * 1000)}:${Math.round(inst.at.y * 1000)}:${Math.round(inst.radiusN * 1000)}`;
}
if (inst.type === 'ice') {
return `ice:${Math.round(inst.at.x * 1000)}:${Math.round(inst.at.y * 1000)}:${Math.round(inst.radiusN * 1000)}`;
}
if (inst.type === 'shadow') {
return `sh:${Math.round(inst.at.x * 1000)}:${Math.round(inst.at.y * 1000)}:${Math.round(inst.radiusN * 1000)}`;
}
// Exhaustive guard — на случай, если добавим новый тип инстанса и забудем сюда.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _exhaustive: never = inst;
return 'unknown';
}
/**
* Пересчитать экранные координаты/размер под новый contentRect (зум/пан).
* false — нужна полная пересборка ноды.
*/
function relayoutInstanceNode(
pixi: any,
node: any,
inst: EffectInstance,
viewport: { x: number; y: number; w: number; h: number },
): boolean {
const { x: vx, y: vy, w, h } = viewport;
const minDim = Math.min(w, h);
if (inst.type === 'fog') {
const r = inst.radiusN * minDim;
const fogSize = Math.max(4, r * 2);
const children = node.children ?? [];
let pi = 0;
for (const child of children) {
const p = inst.points[pi];
pi += 1;
if (!p) continue;
const fx = (child as any).__fx ?? {};
fx.bx = vx + p.x * w;
fx.by = vy + p.y * h;
fx.w0 = fogSize;
fx.h0 = fogSize;
(child as any).__fx = fx;
child.x = fx.bx;
child.y = fx.by;
child.width = fogSize;
child.height = fogSize;
}
return true;
}
if (inst.type === 'fire') {
const r = inst.radiusN * minDim;
const flameWidth = Math.max(r * 3.2, minDim * 0.05);
const flameHeight = flameWidth / GROUND_FIRE_VFX_FRAME_ASPECT;
const children = node.children ?? [];
let pi = 0;
for (const child of children) {
const p = inst.points[pi];
pi += 1;
if (!p) continue;
const fx = (child as any).__fx ?? {};
fx.bx = vx + p.x * w;
fx.by = vy + p.y * h;
fx.w0 = flameWidth;
fx.h0 = flameHeight;
(child as any).__fx = fx;
child.x = fx.bx;
child.y = fx.by;
child.width = flameWidth;
child.height = flameHeight;
}
return true;
}
if (inst.type === 'rain') {
const r = inst.radiusN * minDim;
const rainWidth = Math.max(r * 3.8, minDim * 0.08);
const rainHeight = rainWidth / RAIN_VFX_FRAME_ASPECT;
const children = node.children ?? [];
let pi = 0;
for (const child of children) {
const p = inst.points[pi];
pi += 1;
if (!p) continue;
const fx = (child as any).__fx ?? {};
fx.bx = vx + p.x * w;
fx.by = vy + p.y * h;
fx.w0 = rainWidth;
fx.h0 = rainHeight;
(child as any).__fx = fx;
child.x = fx.bx;
child.y = fx.by;
child.width = rainWidth;
child.height = rainHeight;
}
return true;
}
if (inst.type === 'water') {
const fx = (node as any).__fx;
if (fx?.kind === 'waterDraft' && fx.g) {
const halfW = Math.max(1.5, inst.radiusN * minDim);
redrawWaterDraft(fx.g, inst, viewport, halfW);
return true;
}
// Заливка воды строится в texture/mask под размер viewport — проще пересоздать.
return false;
}
if (inst.type === 'lightning') {
const life = Math.max(1, inst.lifetimeMs);
const t = Math.max(0, Date.now() - inst.createdAtMs);
redrawLightningVfx(node, inst, viewport, t, life);
return true;
}
if (inst.type === 'sunbeam') {
const life = Math.max(1, inst.lifetimeMs);
const t = Math.max(0, Date.now() - inst.createdAtMs);
redrawPulseDischargeVfx(node, inst, viewport, t, life);
return true;
}
if (inst.type === 'poisonCloud') {
const life = Math.max(1, inst.lifetimeMs);
const t = Math.max(0, Date.now() - inst.createdAtMs);
redrawPoisonCloud(node, inst, viewport, t, life);
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) {
node.texture = getFreezeScreenTexture(pixi, inst.seed, viewport);
fx.vw = viewport.w;
fx.vh = viewport.h;
(node as any).__fx = fx;
}
node.x = viewport.x;
node.y = viewport.y;
node.width = viewport.w;
node.height = viewport.h;
return true;
}
if (inst.type === 'darkness') {
const fx = (node as any).__fx ?? {};
if (fx.vw !== viewport.w || fx.vh !== viewport.h) {
node.texture = getDarknessScreenTexture(pixi, inst.seed, viewport);
fx.vw = viewport.w;
fx.vh = viewport.h;
(node as any).__fx = fx;
}
node.x = viewport.x;
node.y = viewport.y;
node.width = viewport.w;
node.height = viewport.h;
return true;
}
if (inst.type === 'scorch' || inst.type === 'ice' || inst.type === 'shadow') {
node.x = vx + inst.at.x * w;
node.y = vy + inst.at.y * h;
const r = inst.radiusN * minDim;
const texW = node.texture?.width ?? node.width ?? 1;
const scale = r / Math.max(1, texW * 0.5);
node.scale?.set?.(scale, scale);
return true;
}
return false;
}
function hash01(seed: number, n: number): number {
// Дешёвый детерминированный шум 0..1 (без Math.random).
let x = (seed ^ (n * 374761393)) >>> 0;
x = (x ^ (x >>> 13)) >>> 0;
x = (x * 1274126177) >>> 0;
return ((x ^ (x >>> 16)) >>> 0) / 0xffffffff;
}
function computeSceneShake(
state: EffectsState,
nowMs: number,
size: { w: number; h: number },
): { x: number; y: number } {
// Ищем самую “активную” молнию в фазе вспышки и строим shake от неё.
let best = 0;
let bestSeed = 0;
const { w, h } = size;
for (const inst of state.instances) {
if (inst.type !== 'lightning') continue;
const t = Math.max(0, nowMs - inst.createdAtMs);
const life = Math.max(1, inst.lifetimeMs);
const flashMs = Math.min(420, Math.max(160, Math.floor(life * 0.42)));
if (t > flashMs) continue;
const flashT = Math.max(0, Math.min(1, 1 - t / flashMs));
const k = flashT * Math.max(0, inst.intensity) * 1.4;
if (k > best) {
best = k;
bestSeed = inst.seed;
}
}
if (best <= 0.0001) return { x: 0, y: 0 };
// Амплитуда небольшая: 0..~8px, зависит от размера экрана и интенсивности.
const amp = Math.min(9, Math.max(2, 0.012 * Math.min(w, h))) * Math.min(1, best);
const n = Math.floor(nowMs / 16); // примерно 60fps-ступенька, чтобы “дребезжало”
const ox = (hash01(bestSeed ^ 0x13579bdf, n) - 0.5) * 2 * amp;
const oy = (hash01(bestSeed ^ 0x2468ace, n) - 0.5) * 2 * amp;
return { x: ox, y: oy };
}
let scorchTextureCache: { key: string; texture: any } | null = null;
let iceTextureCache: { key: string; texture: any } | null = null;
let shadowTextureCache: { key: string; texture: any } | null = null;
let freezeScreenTextureCache: Map<string, any> | null = null;
let darknessScreenTextureCache: Map<string, any> | null = null;
function getIceTexture(pixi: any): any {
const key = 'ice_v3';
if (iceTextureCache?.key === key) return iceTextureCache.texture;
const size = 256;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) {
const t = pixi.Texture.WHITE;
iceTextureCache = { key, texture: t };
return t;
}
ctx.clearRect(0, 0, size, size);
// “Пятно” льда на земле: хаотичный кусок льда (не круг),
// центр заполнен, сверху белые “трещины”/кромка инея.
const cx = size / 2;
const cy = size / 2;
const radius = size * 0.48;
const amp = Math.max(10, radius * 0.12);
const img = ctx.createImageData(size, size);
const data = img.data;
const n2 = (x: number, y: number) => {
const xi = x | 0;
const yi = y | 0;
const a = hash01(0x1ced ^ 0x9e3779b9, xi * 17 + yi * 131);
const b = hash01(0x1ced ^ 0x7f4a7c15, xi * 53 + yi * 97);
return a * 0.65 + b * 0.35;
};
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
const i = (y * size + x) * 4;
const dx = x - cx;
const dy = y - cy;
const dist = Math.sqrt(dx * dx + dy * dy);
// Неровная граница “куска льда”: радиус искажаем шумом.
const nn = n2(x * 0.55, y * 0.55);
const rr = radius + (nn - 0.5) * 2 * amp;
if (dist > rr) {
data[i + 3] = 0;
continue;
}
// Центр обязательно заполнен: базовая заливка сильнее в центре, мягче к краю.
const t = Math.max(0, Math.min(1, 1 - dist / Math.max(1, rr))); // 1 в центре, 0 на краю
const fill = 0.35 + 0.65 * Math.pow(t, 0.7);
// Лёгкая “молочность” и глубина (вариация прозрачности).
const depth = 0.75 + 0.25 * n2(x * 0.12 + 13, y * 0.12 + 7);
const a = Math.round(255 * fill * depth);
// Белый прозрачный лёд: цвет почти белый, “структура” читается в альфе.
const ice = 0.45 + 0.55 * n2(x * 0.22 + 3, y * 0.22 + 11);
const edge = Math.pow(1 - t, 1.1);
data[i] = Math.round(242 + 10 * ice - 6 * edge);
data[i + 1] = Math.round(248 + 6 * ice - 5 * edge);
data[i + 2] = Math.round(255 - 4 * edge);
// Сделаем пятно менее прозрачным (но не “пустым”).
data[i + 3] = Math.min(255, Math.round(a * 0.72));
}
}
ctx.putImageData(img, 0, 0);
// Белые трещины и “иней” сверху (чтобы не было ощущения идеально гладкого пятна).
ctx.save();
ctx.globalCompositeOperation = 'source-over';
ctx.shadowColor = 'rgba(200,245,255,0.35)';
ctx.shadowBlur = 4;
ctx.lineCap = 'round';
// Трещины (полилинии)
for (let k = 0; k < 28; k += 1) {
const ang = hash01(0x1ced ^ 0x91e10da, k) * Math.PI * 2;
const r0 = radius * (0.05 + 0.22 * hash01(0x1ced ^ 0x1234567, k));
const r1 = radius * (0.65 + 0.35 * hash01(0x1ced ^ 0x2345678, k));
const sx = cx + Math.cos(ang) * r0;
const sy = cy + Math.sin(ang) * r0;
const ex = cx + Math.cos(ang + (hash01(0x1ced ^ 0x3456789, k) - 0.5) * 0.55) * r1;
const ey = cy + Math.sin(ang + (hash01(0x1ced ^ 0x456789a, k) - 0.5) * 0.55) * r1;
const midx = (sx + ex) / 2 + (hash01(0x1ced ^ 0x56789ab, k) - 0.5) * 18;
const midy = (sy + ey) / 2 + (hash01(0x1ced ^ 0x6789abc, k) - 0.5) * 18;
ctx.lineWidth = 1.2 + 1.8 * hash01(0x1ced ^ 0x789abcd, k);
ctx.strokeStyle = `rgba(255,255,255,${String(0.22 + 0.35 * hash01(0x1ced ^ 0x89abcde, k))})`;
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(midx, midy);
ctx.lineTo(ex, ey);
ctx.stroke();
}
// Иней по границе (короткие штрихи внутрь)
for (let k = 0; k < 140; k += 1) {
const u = hash01(0x1ced ^ 0x51c0ffee, k);
const ang = u * Math.PI * 2;
const jitter = (hash01(0x1ced ^ 0x44aa77cc, k) - 0.5) * amp;
const sx = cx + Math.cos(ang) * (radius + jitter);
const sy = cy + Math.sin(ang) * (radius + jitter);
const dx = -Math.cos(ang);
const dy = -Math.sin(ang);
const len = radius * (0.06 + 0.22 * hash01(0x1ced ^ 0xabcddcba, k));
ctx.lineWidth = 0.6 + 1.1 * hash01(0x1ced ^ 0x12121212, k);
ctx.strokeStyle = `rgba(255,255,255,${String(0.1 + 0.22 * hash01(0x1ced ^ 0x1c3d5e7, k))})`;
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(sx + dx * len, sy + dy * len);
ctx.stroke();
}
ctx.restore();
const tex = pixi.Texture.from(canvas);
iceTextureCache = { key, texture: tex };
return tex;
}
function getScorchTexture(pixi: any): any {
const key = 'scorch_v1';
if (scorchTextureCache?.key === key) return scorchTextureCache.texture;
const size = 256;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) {
const t = pixi.Texture.WHITE;
scorchTextureCache = { key, texture: t };
return t;
}
ctx.clearRect(0, 0, size, size);
// “Рыхлый” ожог: более резкая форма + сильный шум по альфе.
const g = ctx.createRadialGradient(size / 2, size / 2, size * 0.06, size / 2, size / 2, size / 2);
g.addColorStop(0, 'rgba(255,255,255,0.98)');
g.addColorStop(0.35, 'rgba(255,255,255,0.80)');
g.addColorStop(0.7, 'rgba(255,255,255,0.25)');
g.addColorStop(1, 'rgba(255,255,255,0.00)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
ctx.fill();
const img = ctx.getImageData(0, 0, size, size);
for (let i = 0; i < img.data.length; i += 4) {
const a = img.data[i + 3] ?? 0;
if (a === 0) continue;
// Шум по альфе и чуть по яркости: делает край “рваным”.
const n = (Math.random() - 0.5) * 90;
const na = (Math.random() - 0.5) * 140;
img.data[i] = clamp255((img.data[i] ?? 0) + n);
img.data[i + 1] = clamp255((img.data[i + 1] ?? 0) + n);
img.data[i + 2] = clamp255((img.data[i + 2] ?? 0) + n);
img.data[i + 3] = clamp255(a + na);
}
ctx.putImageData(img, 0, 0);
const tex = pixi.Texture.from(canvas);
scorchTextureCache = { key, texture: tex };
return tex;
}
const SHADOW_EDGE_ALPHA = 0.4;
const SHADOW_CENTER_ALPHA = Math.min(1, 0.85 * 1.4);
function getShadowTexture(pixi: any): any {
const key = 'shadow_v3';
if (shadowTextureCache?.key === key) return shadowTextureCache.texture;
const size = 256;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) {
const t = pixi.Texture.WHITE;
shadowTextureCache = { key, texture: t };
return t;
}
ctx.clearRect(0, 0, size, size);
const cx = size / 2;
const cy = size / 2;
const r = size / 2 - 0.5;
const grd = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
grd.addColorStop(0, `rgba(0, 0, 0, ${String(SHADOW_CENTER_ALPHA)})`);
grd.addColorStop(1, `rgba(0, 0, 0, ${String(SHADOW_EDGE_ALPHA)})`);
ctx.fillStyle = grd;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
const tex = pixi.Texture.from(canvas);
shadowTextureCache = { key, texture: tex };
return tex;
}
function smoothstep01(x: number): number {
const t = Math.max(0, Math.min(1, x));
return t * t * (3 - 2 * t);
}
function freezeAlpha(t: number, life: number): number {
const L = Math.max(1, life);
// Те же доли, что при life=820 (180 / 220 / остаток), масштабируются под длину звука.
const inMs = Math.max(60, (L * 180) / 820);
const holdMs = Math.max(40, (L * 220) / 820);
const outMs = Math.max(80, L - inMs - holdMs);
if (t <= inMs) return smoothstep01(t / inMs);
if (t <= inMs + holdMs) return 1;
return 1 - smoothstep01((t - inMs - holdMs) / outMs);
}
function getFreezeScreenTexture(
pixi: any,
seed: number,
viewport: { x: number; y: number; w: number; h: number },
): any {
const w = Math.max(1, Math.floor(viewport.w));
const h = Math.max(1, Math.floor(viewport.h));
const key = `freeze_v1:${String(w)}x${String(h)}:${String(seed >>> 0)}`;
if (!freezeScreenTextureCache) freezeScreenTextureCache = new Map();
const cached = freezeScreenTextureCache.get(key);
if (cached) return cached;
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) {
const t = pixi.Texture.WHITE;
freezeScreenTextureCache.set(key, t);
return t;
}
ctx.clearRect(0, 0, w, h);
const thickness = 0.35 * Math.min(w, h); // чистый центр ~30%
const amp = Math.max(10, thickness * 0.14); // неровная внутренняя граница
const img = ctx.createImageData(w, h);
const data = img.data;
const n2 = (x: number, y: number) => {
const xi = x | 0;
const yi = y | 0;
const a = hash01(seed ^ 0x9e3779b9, xi * 17 + yi * 131);
const b = hash01(seed ^ 0x7f4a7c15, xi * 53 + yi * 97);
return a * 0.65 + b * 0.35;
};
for (let y = 0; y < h; y += 1) {
for (let x = 0; x < w; x += 1) {
const i = (y * w + x) * 4;
const d = Math.min(x, y, w - 1 - x, h - 1 - y);
const nn = n2(x * 0.55, y * 0.55);
const dd = d + (nn - 0.5) * 2 * amp;
const t = 1 - Math.max(0, Math.min(1, dd / thickness)); // 1 у края, 0 внутри
if (t <= 0) {
data[i + 3] = 0;
continue;
}
const soft = Math.pow(t, 1.7);
const depth = 0.65 + 0.35 * n2(x * 0.12 + 13, y * 0.12 + 7);
const a = Math.round(255 * soft * depth);
const ice = 0.55 + 0.45 * n2(x * 0.22 + 3, y * 0.22 + 11);
const r = Math.round(210 + 40 * ice);
const g = Math.round(232 + 20 * ice);
const b = Math.round(245 + 10 * ice);
data[i] = r;
data[i + 1] = g;
data[i + 2] = b;
data[i + 3] = Math.min(255, a);
}
}
ctx.putImageData(img, 0, 0);
// Игольчатые кристаллы + лёгкий blur (глубина).
ctx.save();
ctx.globalCompositeOperation = 'source-over';
for (let pass = 0; pass < 2; pass += 1) {
ctx.shadowColor = 'rgba(180,240,255,0.35)';
ctx.shadowBlur = pass === 0 ? 7 : 2;
const count = pass === 0 ? 140 : 220;
for (let k = 0; k < count; k += 1) {
const u = hash01(seed ^ 0x51c0ffee, k);
const side = Math.floor(hash01(seed ^ 0x1cedf00d, k) * 4);
const baseLen = thickness * (0.18 + 0.55 * hash01(seed ^ 0xabcddcba, k));
const branches = 2 + Math.floor(hash01(seed ^ 0x12121212, k) * 3);
ctx.lineWidth = pass === 0 ? 1.4 : 0.9;
ctx.strokeStyle = pass === 0 ? 'rgba(230,252,255,0.18)' : 'rgba(245,255,255,0.22)';
ctx.lineCap = 'round';
let sx = 0;
let sy = 0;
let dx = 0;
let dy = 0;
if (side === 0) {
sx = u * w;
sy = 0;
dx = (hash01(seed ^ 0x33aa55, k) - 0.5) * 0.35;
dy = 1;
} else if (side === 2) {
sx = u * w;
sy = h;
dx = (hash01(seed ^ 0x33aa55, k) - 0.5) * 0.35;
dy = -1;
} else if (side === 3) {
sx = 0;
sy = u * h;
dx = 1;
dy = (hash01(seed ^ 0x55aa33, k) - 0.5) * 0.35;
} else {
sx = w;
sy = u * h;
dx = -1;
dy = (hash01(seed ^ 0x55aa33, k) - 0.5) * 0.35;
}
const ex = sx + dx * baseLen;
const ey = sy + dy * baseLen;
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(ex, ey);
ctx.stroke();
for (let b = 0; b < branches; b += 1) {
const tt = 0.25 + 0.6 * hash01(seed ^ 0x778899, k * 7 + b);
const bx = sx + (ex - sx) * tt;
const by = sy + (ey - sy) * tt;
const ang = (hash01(seed ^ 0x998877, k * 11 + b) - 0.5) * 1.25;
const bl = baseLen * (0.15 + 0.35 * hash01(seed ^ 0x445566, k * 13 + b));
const ndx = dx * Math.cos(ang) - dy * Math.sin(ang);
const ndy = dx * Math.sin(ang) + dy * Math.cos(ang);
ctx.beginPath();
ctx.moveTo(bx, by);
ctx.lineTo(bx + ndx * bl, by + ndy * bl);
ctx.stroke();
}
}
}
ctx.restore();
const tex = pixi.Texture.from(canvas);
freezeScreenTextureCache.set(key, tex);
if (freezeScreenTextureCache.size > 18) {
const first = freezeScreenTextureCache.keys().next().value as string | undefined;
if (first) freezeScreenTextureCache.delete(first);
}
return tex;
}
function getDarknessScreenTexture(
pixi: any,
seed: number,
viewport: { x: number; y: number; w: number; h: number },
): any {
const w = Math.max(1, Math.floor(viewport.w));
const h = Math.max(1, Math.floor(viewport.h));
const key = `darkness_v1:${String(w)}x${String(h)}:${String(seed >>> 0)}`;
if (!darknessScreenTextureCache) darknessScreenTextureCache = new Map();
const cached = darknessScreenTextureCache.get(key);
if (cached) return cached;
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) {
const t = pixi.Texture.WHITE;
darknessScreenTextureCache.set(key, t);
return t;
}
ctx.clearRect(0, 0, w, h);
const thickness = 0.35 * Math.min(w, h);
const amp = Math.max(10, thickness * 0.14);
const img = ctx.createImageData(w, h);
const data = img.data;
const n2 = (x: number, y: number) => {
const xi = x | 0;
const yi = y | 0;
const a = hash01(seed ^ 0x9e3779b9, xi * 17 + yi * 131);
const b = hash01(seed ^ 0x7f4a7c15, xi * 53 + yi * 97);
return a * 0.65 + b * 0.35;
};
for (let y = 0; y < h; y += 1) {
for (let x = 0; x < w; x += 1) {
const i = (y * w + x) * 4;
const d = Math.min(x, y, w - 1 - x, h - 1 - y);
const nn = n2(x * 0.55, y * 0.55);
const dd = d + (nn - 0.5) * 2 * amp;
const t = 1 - Math.max(0, Math.min(1, dd / thickness));
if (t <= 0) {
data[i + 3] = 0;
continue;
}
const soft = Math.pow(t, 1.7);
const depth = 0.65 + 0.35 * n2(x * 0.12 + 13, y * 0.12 + 7);
const a = Math.round(255 * soft * depth);
data[i] = 0;
data[i + 1] = 0;
data[i + 2] = 0;
data[i + 3] = Math.min(255, a);
}
}
ctx.putImageData(img, 0, 0);
const tex = pixi.Texture.from(canvas);
darknessScreenTextureCache.set(key, tex);
if (darknessScreenTextureCache.size > 18) {
const first = darknessScreenTextureCache.keys().next().value as string | undefined;
if (first) darknessScreenTextureCache.delete(first);
}
return tex;
}
function clamp255(v: number): number {
if (!Number.isFinite(v)) return 0;
if (v < 0) return 0;
if (v > 255) return 255;
return Math.round(v);
}