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']; 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']; default: return []; } } function collectNeededVfxPacks(state: EffectsState | null): VfxFramePack[] { if (!state) return []; const packs = new Set(); 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 }>(); const electricAccentFrameTextureCache = new WeakMap }>(); const fogVfxFrameTextureCache = new WeakMap }>(); const groundFireVfxFrameTextureCache = new WeakMap }>(); const rainVfxFrameTextureCache = new WeakMap }>(); const waterVfxFrameTextureCache = new WeakMap }>(); const pulseDischargeFrameTextureCache = new WeakMap }>(); const dustBurstFrameTextureCache = new WeakMap }>(); 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(function PixiEffectsOverlay( { state, interactive = false, style, viewport }, ref, ) { const hostRef = useRef(null); const appRef = useRef(null); const rootRef = useRef(null); const pixiRef = useRef(null); const nodesRef = useRef>(new Map()); const committedStateRef = useRef(null); const draftRef = useRef(null); const stateRef = useRef(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(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; if (!pixi || !root) return; syncNodes(pixi, root, nodesRef.current, stateRef.current, sizeRef.current, viewportRef.current); }, [viewport]); const hostClass = [styles.host, interactive ? styles.hostInteractive : styles.hostPassthrough].join(' '); return
; }); function syncNodes( pixi: any, root: any, nodes: Map, 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(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 sig = instanceSig(inst, 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).__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).__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 === '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, 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 === '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, 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, 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, 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, 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { const image = new Image(); image.decoding = 'async'; image.src = src; if (!image.complete) { await new Promise((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, 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 instanceSig(inst: EffectInstance, viewport: { x: number; y: number; w: number; h: number }): 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)}:${Math.round(viewport.w)}:${Math.round(viewport.h)}`; } 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 === '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'; } 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 | null = null; let darknessScreenTextureCache: Map | 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); }