fed5674468
Wire mimic/pit/arrow/laser media on activate, poison/explosion via effects, and document the scene editor and traps in Instructions. Co-authored-by: Cursor <cursoragent@cursor.com>
2466 lines
84 KiB
TypeScript
2466 lines
84 KiB
TypeScript
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);
|
||
}
|