a6cbcc273e
Made-with: Cursor
1208 lines
47 KiB
TypeScript
1208 lines
47 KiB
TypeScript
import React, { useEffect, useMemo, useRef } from 'react';
|
|
|
|
import type { EffectsState, EffectInstance } from '../../../shared/types/effects';
|
|
|
|
import styles from './PxiEffectsOverlay.module.css';
|
|
|
|
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: рисуем "инстансы эффектов" (данные), а не пиксели.
|
|
*/
|
|
export function PixiEffectsOverlay({ state, interactive = false, style, viewport }: Props) {
|
|
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 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);
|
|
|
|
const dpr = useMemo(() => Math.min(2, window.devicePixelRatio || 1), []);
|
|
|
|
useEffect(() => {
|
|
const host = hostRef.current;
|
|
if (!host) return;
|
|
|
|
let destroyed = false;
|
|
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: true,
|
|
resolution: dpr,
|
|
autoDensity: true,
|
|
});
|
|
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(() => {
|
|
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 };
|
|
}
|
|
syncNodes(pixi, root, nodesRef.current, stateRef.current, sizeRef.current, viewportRef.current);
|
|
|
|
// Animation loop: на каждом кадре обновляем свойства инстансов (alpha/дрейф/фликер).
|
|
app.ticker.add(() => {
|
|
const s = stateRef.current;
|
|
if (!s) return;
|
|
const nowMs = Date.now() + timeOffsetRef.current;
|
|
animateNodes(pixi, nodesRef.current, s, nowMs, sizeRef.current, viewportRef.current);
|
|
|
|
// Лёгкое “потряхивание” сцены в момент удара молнии.
|
|
// Делаем через смещение корневого контейнера, чтобы не вмешиваться в рендерер/камера-логику.
|
|
const root = rootRef.current;
|
|
if (root) {
|
|
const { x, y } = computeSceneShake(s, nowMs, sizeRef.current);
|
|
root.x = x;
|
|
root.y = y;
|
|
}
|
|
});
|
|
|
|
cleanup = () => ro.disconnect();
|
|
} catch (e) {
|
|
// Если Pixi/WebGL не поднялись, не ломаем всё приложение.
|
|
// Эффекты просто не будут отображаться, но UI останется живым.
|
|
console.error('[effects] Pixi init failed', e);
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
destroyed = true;
|
|
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(() => {
|
|
const app = appRef.current;
|
|
const root = rootRef.current;
|
|
if (!app || !root) return;
|
|
stateRef.current = state;
|
|
if (state) {
|
|
timeOffsetRef.current = state.serverNowMs - Date.now();
|
|
}
|
|
const pixi = pixiRef.current;
|
|
if (!pixi) return;
|
|
syncNodes(pixi, root, nodesRef.current, state, sizeRef.current, viewportRef.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 <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 desired = new Set<string>((state?.instances ?? []).map((i) => i.id));
|
|
for (const [id, node] of nodes.entries()) {
|
|
if (desired.has(id)) continue;
|
|
root.removeChild(node);
|
|
node.destroy?.({ children: true });
|
|
nodes.delete(id);
|
|
}
|
|
if (!state) return;
|
|
for (const inst of state.instances) {
|
|
const sig = instanceSig(inst);
|
|
const existing = nodes.get(inst.id);
|
|
if (existing && (existing as any).__sig === sig) continue;
|
|
if (existing) {
|
|
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 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 r = inst.radiusN * Math.min(w, h);
|
|
const tex = getFogTexture(pixi);
|
|
// Вместо кругов используем Sprite — визуально мягче и быстрее.
|
|
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(tex);
|
|
s.anchor?.set?.(0.5, 0.5);
|
|
s.x = vx + p.x * w;
|
|
s.y = vy + p.y * h;
|
|
const scale = r / Math.max(1, tex.width * 0.5);
|
|
s.scale?.set?.(scale, scale);
|
|
s.rotation = hash01(inst.seed ^ 0x3141592, i) * Math.PI * 2;
|
|
s.alpha = base;
|
|
s.blendMode = pixi.BLEND_MODES?.SCREEN ?? 2;
|
|
// Сохраним параметры анимации прямо на объекте (простая практика для Pixi).
|
|
(s as any).__fx = {
|
|
bx: s.x,
|
|
by: s.y,
|
|
phase: hash01(inst.seed, i) * Math.PI * 2,
|
|
drift: 6 + 26 * hash01(inst.seed ^ 0x9e3779b9, i),
|
|
rot0: s.rotation,
|
|
rotSpeed: (hash01(inst.seed ^ 0x2718281, i) - 0.5) * 0.35,
|
|
};
|
|
c.addChild(s);
|
|
}
|
|
(c as any).__fx = { id: inst.id, type: inst.type };
|
|
c.alpha = Math.max(0, Math.min(1, inst.opacity));
|
|
return c;
|
|
}
|
|
if (inst.type === 'fire') {
|
|
const base = inst.opacity;
|
|
const r = inst.radiusN * Math.min(w, h);
|
|
const tex = getFireTexture(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(tex);
|
|
s.anchor?.set?.(0.5, 0.5);
|
|
s.x = vx + p.x * w;
|
|
s.y = vy + p.y * h;
|
|
const scale = r / Math.max(1, tex.width * 0.5);
|
|
s.scale?.set?.(scale, scale);
|
|
s.rotation = (hash01(inst.seed ^ 0x1f1f1f1f, i) - 0.5) * 0.35;
|
|
s.alpha = base;
|
|
// Огонь “подсвечивает” — используем аддитивное смешивание.
|
|
s.blendMode = pixi.BLEND_MODES?.ADD ?? 1;
|
|
// Тёплый тинт: от оранжевого к жёлтому.
|
|
const warm = hash01(inst.seed ^ 0x2222222, i);
|
|
s.tint = warm > 0.5 ? 0xffa000 : 0xff5a00;
|
|
(s as any).__fx = {
|
|
bx: s.x,
|
|
by: s.y,
|
|
phase: hash01(inst.seed, i) * Math.PI * 2,
|
|
phase2: hash01(inst.seed ^ 0x1234567, i) * Math.PI * 2,
|
|
drift: 4 + 18 * hash01(inst.seed ^ 0x9e3779b9, i),
|
|
flicker: 0.7 + 0.6 * hash01(inst.seed ^ 0x3141592, i),
|
|
flickerSpeed: 0.0022 + 0.0038 * hash01(inst.seed ^ 0x7777777, i),
|
|
flickerAmp: 0.35 + 0.55 * hash01(inst.seed ^ 0x8888888, i),
|
|
tongue: 0.55 + 0.65 * hash01(inst.seed ^ 0x9999999, i), // “высота языка” (индивидуально для точки)
|
|
rot0: s.rotation,
|
|
rotSpeed: (hash01(inst.seed ^ 0x2718281, i) - 0.5) * 0.65,
|
|
sy0: s.scale?.y ?? 1,
|
|
};
|
|
c.addChild(s);
|
|
}
|
|
(c as any).__fx = { id: inst.id, type: inst.type };
|
|
c.alpha = Math.max(0, Math.min(1, inst.opacity));
|
|
return c;
|
|
}
|
|
if (inst.type === 'rain') {
|
|
const base = inst.opacity;
|
|
const r = inst.radiusN * Math.min(w, h);
|
|
const tex = getRainTexture(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(tex);
|
|
s.anchor?.set?.(0.5, 0.5);
|
|
s.x = vx + p.x * w;
|
|
s.y = vy + p.y * h;
|
|
const scale = r / Math.max(1, tex.width * 0.5);
|
|
s.scale?.set?.(scale, scale);
|
|
s.rotation = (-12 * Math.PI) / 180; // лёгкий наклон “ветра”
|
|
s.alpha = base;
|
|
// Тёмные линии дождя должны быть заметны на карте — рисуем обычным смешиванием.
|
|
s.blendMode = pixi.BLEND_MODES?.NORMAL ?? 0;
|
|
// Цвет капли: рандом (детерминированный) с более заметным разбегом,
|
|
// чтобы было видно, что линии разные (тёмно-синий -> водянистый голубовато-синий).
|
|
const ct = hash01(inst.seed ^ 0x6d2b79f5, i);
|
|
const r0 = 0x08;
|
|
const g0 = 0x1a;
|
|
const b0 = 0x2e;
|
|
const r1 = 0x78;
|
|
const g1 = 0xb8;
|
|
const b1 = 0xf0;
|
|
const rr = Math.round(r0 + (r1 - r0) * ct);
|
|
const gg = Math.round(g0 + (g1 - g0) * ct);
|
|
const bb = Math.round(b0 + (b1 - b0) * ct);
|
|
s.tint = (rr << 16) | (gg << 8) | bb;
|
|
(s as any).__fx = {
|
|
bx: s.x,
|
|
by: s.y,
|
|
phase: hash01(inst.seed ^ 0x55aa55aa, i) * Math.PI * 2,
|
|
drift: 3 + 10 * hash01(inst.seed ^ 0x9e3779b9, i),
|
|
fallSpeed: 0.35 + 1.25 * hash01(inst.seed ^ 0x1020304, i), // норм. к радиусу
|
|
spread: 0.45 + 0.75 * hash01(inst.seed ^ 0x2030405, i),
|
|
alphaMul: 1.05 + 0.7 * hash01(inst.seed ^ 0x3040506, i),
|
|
rot0: s.rotation,
|
|
};
|
|
c.addChild(s);
|
|
}
|
|
(c as any).__fx = { id: inst.id, type: inst.type };
|
|
c.alpha = Math.max(0, Math.min(1, inst.opacity));
|
|
return c;
|
|
}
|
|
if (inst.type === 'lightning') {
|
|
const g = new pixi.Graphics();
|
|
g.blendMode = pixi.BLEND_MODES?.ADD ?? 1;
|
|
(g as any).__fx = {
|
|
id: inst.id,
|
|
type: inst.type,
|
|
start: inst.start,
|
|
end: inst.end,
|
|
widthN: inst.widthN,
|
|
};
|
|
// Первичная отрисовка, дальше будет обновляться в animateNodes.
|
|
redrawLightning(pixi, g, inst, viewport, 0, Math.max(1, inst.lifetimeMs));
|
|
return g;
|
|
}
|
|
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;
|
|
}
|
|
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;
|
|
const { w, h } = viewport;
|
|
|
|
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 pulse = 0.88 + 0.14 * Math.sin(0.0012 * t + hash01(inst.seed, 0) * 6.28);
|
|
cont.alpha = Math.max(0, Math.min(1, inst.opacity * pulse));
|
|
for (const child of cont.children ?? []) {
|
|
const fx = (child as any).__fx;
|
|
if (!fx) continue;
|
|
const wob = fx.drift;
|
|
child.x = fx.bx + Math.sin(0.0009 * t + fx.phase) * wob;
|
|
child.y = fx.by + Math.cos(0.0011 * t + fx.phase) * wob;
|
|
child.alpha = cont.alpha;
|
|
if (typeof fx.rot0 === 'number' && typeof fx.rotSpeed === 'number') {
|
|
child.rotation = fx.rot0 + fx.rotSpeed * (t / 1000);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (inst.type === 'fire') {
|
|
const cont = node;
|
|
// Огонь: общий фон + per-sprite “языки” и неравномерная пульсация.
|
|
const baseFlick = 0.78 + 0.22 * Math.sin(0.0062 * t + hash01(inst.seed ^ 0xabcdef, 0) * 6.28);
|
|
// Не даём “пламени” уходить в почти ноль — всегда остаётся базовое свечение.
|
|
cont.alpha = Math.max(0, Math.min(1, inst.opacity * Math.max(0.55, baseFlick)));
|
|
for (const child of cont.children ?? []) {
|
|
const fx = (child as any).__fx;
|
|
if (!fx) continue;
|
|
const wob = fx.drift;
|
|
// Пламя чуть “тянется” вверх, но без изменения конечной позиции кисти.
|
|
// Лифт делаем “языками”: сильнее при вспышках конкретного спрайта.
|
|
const sF = typeof fx.flickerSpeed === 'number' ? fx.flickerSpeed * 1.75 : 0.0058;
|
|
const sA = typeof fx.flickerAmp === 'number' ? fx.flickerAmp : 0.6;
|
|
const tongue = typeof fx.tongue === 'number' ? fx.tongue : 1;
|
|
const local =
|
|
0.55 +
|
|
0.45 * Math.sin(sF * t + (fx.phase ?? 0)) * sA +
|
|
0.25 * Math.sin(sF * 1.9 * t + (fx.phase2 ?? 0)) * (0.35 + 0.65 * tongue);
|
|
const local01 = Math.max(0, Math.min(1.2, local));
|
|
// Без накопления: язык “дышит” вверх-вниз, но не уплывает со временем.
|
|
const lift =
|
|
-(4 + 10 * local01) * (0.55 + 0.65 * tongue) -
|
|
(1.5 + 2.5 * tongue) * Math.sin(0.0045 * t + (fx.phase ?? 0));
|
|
child.x = fx.bx + Math.sin(0.0018 * t + fx.phase) * wob;
|
|
child.y = fx.by + Math.cos(0.0022 * t + fx.phase) * wob + lift;
|
|
// Неравномерная пульсация без ступенчатого шума (ступени давали заметное моргание).
|
|
const nPhase = hash01(inst.seed ^ 0xf00baa, (fx.bx | 0) + (fx.by | 0)) * Math.PI * 2;
|
|
const noise = 0.11 * Math.sin(0.0031 * t + nPhase) + 0.07 * Math.sin(0.0077 * t + nPhase * 1.7);
|
|
child.alpha = Math.max(0, Math.min(1, cont.alpha * (0.85 + 0.45 * local01 + noise)));
|
|
// “Языки” виднее, если чуть пульсировать высоту спрайта.
|
|
const sy0 = typeof fx.sy0 === 'number' ? fx.sy0 : 1;
|
|
const stretch = 0.85 + 0.35 * local01 * (0.7 + 0.3 * tongue);
|
|
child.scale?.set?.(child.scale.x, sy0 * stretch);
|
|
if (typeof fx.rot0 === 'number' && typeof fx.rotSpeed === 'number') {
|
|
child.rotation = fx.rot0 + fx.rotSpeed * (t / 1000);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (inst.type === 'rain') {
|
|
const cont = node;
|
|
// Дождь: стабильная “шторка” + движение капель вниз с лёгким дрейфом.
|
|
const base = 0.85 + 0.15 * Math.sin(0.0018 * t + hash01(inst.seed ^ 0xdeadbeef, 0) * 6.28);
|
|
cont.alpha = Math.max(0, Math.min(1, inst.opacity * Math.max(1.35, base * 1.28)));
|
|
const rPx = inst.radiusN * Math.min(w, h);
|
|
const wrap = Math.max(18, rPx * 1.8);
|
|
for (const child of cont.children ?? []) {
|
|
const fx = (child as any).__fx;
|
|
if (!fx) continue;
|
|
const fall = typeof fx.fallSpeed === 'number' ? fx.fallSpeed : 1;
|
|
const spread = typeof fx.spread === 'number' ? fx.spread : 1;
|
|
const alphaMul = typeof fx.alphaMul === 'number' ? fx.alphaMul : 1;
|
|
const wob = typeof fx.drift === 'number' ? fx.drift : 0;
|
|
|
|
// Позиция: вокруг базовой точки, капля “перезапускается” по кругу вниз.
|
|
const yOff = ((t * 0.04 * fall + (fx.phase ?? 0) * 120) % wrap) - wrap / 2;
|
|
const xOff = Math.sin(0.0022 * t + (fx.phase ?? 0)) * wob * 0.6;
|
|
const gust = Math.sin(0.00055 * t + hash01(inst.seed ^ 0xabcdef01, fx.bx | 0) * 6.28) * wob;
|
|
child.x = fx.bx + xOff + gust;
|
|
child.y = fx.by + yOff * spread;
|
|
|
|
// Альфа: немного “мерцает”, но не пропадает полностью.
|
|
const a = cont.alpha * (0.72 + 0.28 * Math.sin(0.0032 * t + (fx.phase ?? 0))) * alphaMul;
|
|
child.alpha = Math.max(0.68, Math.min(1, a));
|
|
}
|
|
}
|
|
|
|
if (inst.type === 'lightning') {
|
|
const g = node;
|
|
const life = Math.max(1, inst.lifetimeMs);
|
|
if (t >= life) {
|
|
g.visible = false;
|
|
continue;
|
|
}
|
|
g.visible = true;
|
|
redrawLightning(pixi, g, 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 === '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;
|
|
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 * (0.35 + 0.65 * fade)));
|
|
}
|
|
}
|
|
}
|
|
|
|
function redrawLightning(
|
|
pixi: any,
|
|
g: any,
|
|
inst: Extract<EffectInstance, { type: 'lightning' }>,
|
|
viewport: { x: number; y: number; w: number; h: number },
|
|
t: number,
|
|
life: number,
|
|
) {
|
|
void pixi;
|
|
const { x: vx, y: vy, w, h } = viewport;
|
|
const ex = vx + inst.end.x * w;
|
|
const ey = vy + inst.end.y * h;
|
|
const width = Math.max(1, inst.widthN * Math.min(w, h));
|
|
g.clear();
|
|
|
|
// Фаза удара: короткая яркая вспышка, которая на мгновение “заслоняет” весь экран.
|
|
const flashMs = Math.min(140, Math.max(60, Math.floor(life * 0.8)));
|
|
const flashT = Math.max(0, Math.min(1, 1 - t / flashMs));
|
|
const overlayAlpha = Math.max(0, Math.min(0.5, 0.5 * flashT * inst.intensity));
|
|
if (overlayAlpha > 0.001) {
|
|
g.rect(vx, vy, w, h);
|
|
g.fill({ color: 0xffe066, alpha: overlayAlpha });
|
|
}
|
|
|
|
// Тонкая “молния”: ломаная сверху -> середина -> отвод вправо -> точка удара.
|
|
const sx = ex;
|
|
const sy = vy;
|
|
const coreAlpha = Math.max(0, Math.min(1, (0.9 + 0.1 * flashT) * inst.intensity));
|
|
|
|
// Небольшой дрожащий джиттер, чтобы молния выглядела живой.
|
|
const n = Math.floor(t / 16);
|
|
const jx0 = (hash01(inst.seed ^ 0x111, n) - 0.5) * width * 0.35;
|
|
const jy0 = (hash01(inst.seed ^ 0x222, n) - 0.5) * width * 0.25;
|
|
const jx1 = (hash01(inst.seed ^ 0x333, n + 1) - 0.5) * width * 0.35;
|
|
const jy1 = (hash01(inst.seed ^ 0x444, n + 1) - 0.5) * width * 0.25;
|
|
|
|
const midY = ey * 0.5;
|
|
const midX = sx + (hash01(inst.seed ^ 0x777, 0) - 0.5) * width * 0.35;
|
|
const kinkY = ey * 0.62;
|
|
const kinkDx = (0.12 + 0.08 * hash01(inst.seed ^ 0x888, 0)) * Math.min(w, h);
|
|
const kinkX = midX + kinkDx; // “сворачиваем вправо”
|
|
|
|
// Рисуем ломаную как одну линию.
|
|
g.moveTo(sx + jx0, sy + jy0);
|
|
g.lineTo(midX + jx1, midY + jy1);
|
|
g.lineTo(kinkX + jx0, kinkY + jy0);
|
|
g.lineTo(ex - jx1, ey - jy1);
|
|
|
|
// Тонкое белое ядро + жёлтый ореол (тоже тонкий), чтобы выглядело как молния, а не маркер.
|
|
const thin = Math.max(1, width * 0.28);
|
|
g.stroke({ color: 0xffffff, width: thin, alpha: Math.min(1, coreAlpha) });
|
|
g.stroke({ color: 0xffd000, width: Math.max(1.5, thin * 1.9), alpha: Math.min(1, coreAlpha * 0.28) });
|
|
|
|
// Сеть разрядов от точки удара: короткие “ветки”, которые быстро исчезают.
|
|
// Оставляем только scorch-инстанс (он живёт отдельно), поэтому здесь всё затухает по flashT.
|
|
const branchAlpha = Math.min(1, coreAlpha) * flashT;
|
|
if (branchAlpha > 0.01) {
|
|
const baseR = (0.06 + 0.08 * hash01(inst.seed ^ 0x999, 0)) * Math.min(w, h);
|
|
const branches = 10;
|
|
for (let i = 0; i < branches; i += 1) {
|
|
const a0 = (i / branches) * Math.PI * 2 + (hash01(inst.seed ^ 0x5151, i) - 0.5) * 0.45;
|
|
const r0 = baseR * (0.55 + 0.65 * hash01(inst.seed ^ 0x6161, i));
|
|
const r1 = r0 * (0.55 + 0.4 * hash01(inst.seed ^ 0x7171, i));
|
|
|
|
const x1 = ex + Math.cos(a0) * r0;
|
|
const y1 = ey + Math.sin(a0) * r0;
|
|
// маленький “зубчик” посередине, чтобы ветка была ломаной
|
|
const a1 = a0 + (hash01(inst.seed ^ 0x8181, i) - 0.5) * 1.0;
|
|
const x2 = ex + Math.cos(a1) * r1;
|
|
const y2 = ey + Math.sin(a1) * r1;
|
|
|
|
g.moveTo(ex, ey);
|
|
g.lineTo(x2, y2);
|
|
g.lineTo(x1, y1);
|
|
const bw = Math.max(1, thin * 0.85);
|
|
g.stroke({ color: 0xffffff, width: bw, alpha: Math.min(1, branchAlpha * 0.75) });
|
|
g.stroke({ color: 0xffd000, width: Math.max(1.5, bw * 1.8), alpha: Math.min(1, branchAlpha * 0.18) });
|
|
}
|
|
}
|
|
}
|
|
|
|
function instanceSig(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 === 'lightning') {
|
|
return `lt:${Math.round(inst.end.x * 1000)}:${Math.round(inst.end.y * 1000)}:${Math.round(inst.widthN * 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 === '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)}`;
|
|
}
|
|
// 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(140, Math.max(60, Math.floor(life * 0.8)));
|
|
if (t > flashMs) continue;
|
|
const flashT = Math.max(0, Math.min(1, 1 - t / flashMs));
|
|
const k = flashT * Math.max(0, inst.intensity);
|
|
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 fogTextureCache: { key: string; texture: any } | null = null;
|
|
let fireTextureCache: { key: string; texture: any } | null = null;
|
|
let rainTextureCache: { key: string; texture: any } | null = null;
|
|
let scorchTextureCache: { key: string; texture: any } | null = null;
|
|
let iceTextureCache: { key: string; texture: any } | null = null;
|
|
let freezeScreenTextureCache: Map<string, any> | null = null;
|
|
|
|
function getFogTexture(pixi: any): any {
|
|
// Кэшируем один раз на процесс. Текстура — “дымка” с мягкой альфой.
|
|
const key = 'fog_v1';
|
|
if (fogTextureCache?.key === key) return fogTextureCache.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;
|
|
fogTextureCache = { key, texture: t };
|
|
return t;
|
|
}
|
|
|
|
ctx.clearRect(0, 0, size, size);
|
|
// База: мягкий радиальный градиент.
|
|
const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
|
|
g.addColorStop(0, 'rgba(255,255,255,0.85)');
|
|
g.addColorStop(0.55, 'rgba(255,255,255,0.35)');
|
|
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) * 28;
|
|
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);
|
|
}
|
|
ctx.putImageData(img, 0, 0);
|
|
|
|
const tex = pixi.Texture.from(canvas);
|
|
fogTextureCache = { key, texture: tex };
|
|
return tex;
|
|
}
|
|
|
|
function getFireTexture(pixi: any): any {
|
|
const key = 'fire_v2';
|
|
if (fireTextureCache?.key === key) return fireTextureCache.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;
|
|
fireTextureCache = { key, texture: t };
|
|
return t;
|
|
}
|
|
|
|
ctx.clearRect(0, 0, size, size);
|
|
|
|
// Градиент “пламени”: ярче внизу, мягко исчезает вверх/по краям.
|
|
const g = ctx.createRadialGradient(size / 2, size * 0.68, size * 0.04, size / 2, size * 0.62, size * 0.55);
|
|
g.addColorStop(0, 'rgba(255,255,255,0.95)');
|
|
g.addColorStop(0.18, 'rgba(255,210,120,0.90)');
|
|
g.addColorStop(0.42, 'rgba(255,140,40,0.70)');
|
|
g.addColorStop(0.72, 'rgba(255,60,0,0.25)');
|
|
g.addColorStop(1, 'rgba(255,0,0,0.00)');
|
|
ctx.fillStyle = g;
|
|
ctx.beginPath();
|
|
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// “Языки” пламени: рисуем полупрозрачные лепестки, сильнее внизу.
|
|
// Делается до шума, чтобы шум потом “рвал” край и делал пламя живым.
|
|
ctx.save();
|
|
ctx.globalCompositeOperation = 'source-atop';
|
|
for (let k = 0; k < 22; k += 1) {
|
|
const cx = size * (0.22 + 0.56 * Math.random());
|
|
const baseY = size * (0.62 + 0.22 * Math.random());
|
|
const height = size * (0.18 + 0.36 * Math.random());
|
|
const width = size * (0.03 + 0.06 * Math.random());
|
|
const bend = (Math.random() - 0.5) * width * 3.0;
|
|
const tipX = cx + bend;
|
|
const tipY = baseY - height;
|
|
|
|
const grad = ctx.createLinearGradient(cx, baseY, tipX, tipY);
|
|
grad.addColorStop(0, 'rgba(255,255,255,0.32)');
|
|
grad.addColorStop(0.35, 'rgba(255,190,90,0.22)');
|
|
grad.addColorStop(1, 'rgba(255,50,0,0.00)');
|
|
ctx.fillStyle = grad;
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - width, baseY);
|
|
ctx.quadraticCurveTo(cx - width * 0.4, baseY - height * 0.55, tipX, tipY);
|
|
ctx.quadraticCurveTo(cx + width * 0.45, baseY - height * 0.45, cx + width, baseY);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
}
|
|
ctx.restore();
|
|
|
|
// Немного “рваности” — шум по альфе + вертикальные полосы.
|
|
const img = ctx.getImageData(0, 0, size, size);
|
|
for (let y = 0; y < size; y += 1) {
|
|
for (let x = 0; x < size; x += 1) {
|
|
const i = (y * size + x) * 4;
|
|
const a = img.data[i + 3] ?? 0;
|
|
if (a === 0) continue;
|
|
const vy = 1 - y / (size - 1);
|
|
const stripe = Math.sin((x / size) * Math.PI * 10 + (y / size) * Math.PI * 1.5) * 0.5 + 0.5;
|
|
const n = (Math.random() - 0.5) * 110;
|
|
const boost = (0.25 + 0.75 * stripe) * (0.18 + 0.82 * vy);
|
|
img.data[i] = clamp255((img.data[i] ?? 0) + n * 0.28);
|
|
img.data[i + 1] = clamp255((img.data[i + 1] ?? 0) + n * 0.2);
|
|
img.data[i + 2] = clamp255((img.data[i + 2] ?? 0) + n * 0.07);
|
|
img.data[i + 3] = clamp255(a * boost + (Math.random() - 0.5) * 22);
|
|
}
|
|
}
|
|
ctx.putImageData(img, 0, 0);
|
|
|
|
const tex = pixi.Texture.from(canvas);
|
|
fireTextureCache = { key, texture: tex };
|
|
return tex;
|
|
}
|
|
|
|
function getRainTexture(pixi: any): any {
|
|
const key = 'rain_v2';
|
|
if (rainTextureCache?.key === key) return rainTextureCache.texture;
|
|
|
|
const w = 64;
|
|
const h = 256;
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = w;
|
|
canvas.height = h;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) {
|
|
const t = pixi.Texture.WHITE;
|
|
rainTextureCache = { key, texture: t };
|
|
return t;
|
|
}
|
|
|
|
ctx.clearRect(0, 0, w, h);
|
|
|
|
// Несколько “полос” разной толщины и длины — одна текстура на спрайт.
|
|
ctx.lineCap = 'round';
|
|
for (let i = 0; i < 42; i += 1) {
|
|
const x = w * (0.1 + 0.8 * Math.random());
|
|
const y0 = h * (0.05 + 0.9 * Math.random());
|
|
const len = h * (0.12 + 0.32 * Math.random());
|
|
// Прозрачность каждой линии — случайно 0.5..1 (множитель), чтобы дождь был разнообразнее.
|
|
const a = (0.22 + 0.45 * Math.random()) * (0.5 + 0.5 * Math.random());
|
|
const lw = 1.6 + Math.random() * 2.4;
|
|
const grad = ctx.createLinearGradient(x, y0, x, y0 + len);
|
|
// Цвет задаём tint’ом на спрайте (пер-капля), поэтому текстура — нейтрально-белая.
|
|
grad.addColorStop(0, `rgba(255,255,255,${String(a * 0.0)})`);
|
|
grad.addColorStop(0.2, `rgba(255,255,255,${String(a)})`);
|
|
grad.addColorStop(1, `rgba(255,255,255,${String(a * 0.0)})`);
|
|
ctx.strokeStyle = grad;
|
|
ctx.lineWidth = lw;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, y0);
|
|
ctx.lineTo(x, y0 + len);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Чуть зерна по альфе — чтобы дождь не был слишком “компьютерным”.
|
|
const img = ctx.getImageData(0, 0, w, h);
|
|
for (let i = 0; i < img.data.length; i += 4) {
|
|
const a = img.data[i + 3] ?? 0;
|
|
if (a === 0) continue;
|
|
img.data[i + 3] = clamp255(a + (Math.random() - 0.5) * 65);
|
|
}
|
|
ctx.putImageData(img, 0, 0);
|
|
|
|
const tex = pixi.Texture.from(canvas);
|
|
rainTextureCache = { key, texture: tex };
|
|
return tex;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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 inMs = 180;
|
|
const holdMs = 220;
|
|
const outMs = Math.max(120, life - 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 clamp255(v: number): number {
|
|
if (!Number.isFinite(v)) return 0;
|
|
if (v < 0) return 0;
|
|
if (v > 255) return 255;
|
|
return Math.round(v);
|
|
}
|