feat(effects): вода, облако яда, луч света; пульт и окна демонстрации
- Поле: вода (сплошная заливка по штриху, превью кистью), туман/огонь/дождь без изменений логики. - Действия: облако яда (частицы, круглая текстура, звук oblako-yada.mp3, длительность как у трека), луч света и заморозка со звуками из public/. - Пульт: инструменты воды и яда, синхрон SFX, тесты панели и ластика. - Окно управления: дочернее от окна просмотра (Z-order). - Типы эффектов, effectsStore prune, hit-test ластика. Made-with: Cursor
This commit is contained in:
@@ -4,6 +4,17 @@ import type { EffectsState, EffectInstance } from '../../../shared/types/effects
|
||||
|
||||
import styles from './PxiEffectsOverlay.module.css';
|
||||
|
||||
type PoisonParticleFx = {
|
||||
s: any;
|
||||
kind: 'stem' | 'cap';
|
||||
uStem: number;
|
||||
side: number;
|
||||
capAng: number;
|
||||
capDist: number;
|
||||
sz: number;
|
||||
flick: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
state: EffectsState | null;
|
||||
interactive?: boolean;
|
||||
@@ -173,7 +184,7 @@ function syncNodes(
|
||||
}
|
||||
if (!state) return;
|
||||
for (const inst of state.instances) {
|
||||
const sig = instanceSig(inst);
|
||||
const sig = instanceSig(inst, viewport);
|
||||
const existing = nodes.get(inst.id);
|
||||
if (existing && (existing as any).__sig === sig) continue;
|
||||
if (existing) {
|
||||
@@ -317,6 +328,26 @@ function createInstanceNode(
|
||||
c.alpha = Math.max(0, Math.min(1, inst.opacity));
|
||||
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 as any).__fx = { kind: 'waterSolid' };
|
||||
c.alpha = Math.max(0, Math.min(1, inst.opacity));
|
||||
c.blendMode = pixi.BLEND_MODES?.NORMAL ?? 0;
|
||||
return c;
|
||||
}
|
||||
if (inst.type === 'lightning') {
|
||||
const g = new pixi.Graphics();
|
||||
g.blendMode = pixi.BLEND_MODES?.ADD ?? 1;
|
||||
@@ -331,6 +362,66 @@ function createInstanceNode(
|
||||
redrawLightning(pixi, g, inst, viewport, 0, Math.max(1, inst.lifetimeMs));
|
||||
return g;
|
||||
}
|
||||
if (inst.type === 'sunbeam') {
|
||||
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,
|
||||
};
|
||||
redrawSunbeam(pixi, g, inst, viewport, 0, Math.max(1, inst.lifetimeMs));
|
||||
return g;
|
||||
}
|
||||
if (inst.type === 'poisonCloud') {
|
||||
const cont = new pixi.Container();
|
||||
const tex = getPoisonParticleTexture(pixi);
|
||||
const particleCount = 520;
|
||||
const particles: PoisonParticleFx[] = [];
|
||||
for (let i = 0; i < particleCount; i += 1) {
|
||||
const s = new pixi.Sprite(tex);
|
||||
s.anchor?.set?.(0.5, 0.5);
|
||||
const kind: 'stem' | 'cap' = hash01(inst.seed ^ 0x50f3757f, i) < 0.44 ? 'stem' : 'cap';
|
||||
const gVar = hash01(inst.seed ^ 0xdead00, i);
|
||||
const tint = gVar < 0.33 ? 0x33ff99 : gVar < 0.66 ? 0x22ee77 : 0x44ffaa;
|
||||
s.tint = tint;
|
||||
s.blendMode = pixi.BLEND_MODES?.ADD ?? 1;
|
||||
particles.push({
|
||||
s,
|
||||
kind,
|
||||
uStem: hash01(inst.seed ^ 0x111111, i),
|
||||
side: hash01(inst.seed ^ 0x222222, i) * 2 - 1,
|
||||
capAng: hash01(inst.seed ^ 0x333333, i) * Math.PI * 2,
|
||||
capDist: Math.sqrt(hash01(inst.seed ^ 0x444444, i)),
|
||||
// Мелкие точки: узкий разброс размера (раньше до ~3.7 давало гигантские блики).
|
||||
sz: 0.22 + hash01(inst.seed ^ 0x555555, i) * 0.75,
|
||||
flick: hash01(inst.seed ^ 0x666666, i) * Math.PI * 2,
|
||||
});
|
||||
cont.addChild(s);
|
||||
}
|
||||
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(skull);
|
||||
}
|
||||
(cont as any).__fx = { particles, skull };
|
||||
cont.blendMode = pixi.BLEND_MODES?.NORMAL ?? 0;
|
||||
redrawPoisonCloud(pixi, 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);
|
||||
@@ -455,6 +546,12 @@ function animateNodes(
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
if (inst.type === 'rain') {
|
||||
const cont = node;
|
||||
// Дождь: стабильная “шторка” + движение капель вниз с лёгким дрейфом.
|
||||
@@ -494,6 +591,28 @@ function animateNodes(
|
||||
redrawLightning(pixi, g, inst, viewport, t, life);
|
||||
}
|
||||
|
||||
if (inst.type === 'sunbeam') {
|
||||
const g = node;
|
||||
const life = Math.max(1, inst.lifetimeMs);
|
||||
if (t >= life) {
|
||||
g.visible = false;
|
||||
continue;
|
||||
}
|
||||
g.visible = true;
|
||||
redrawSunbeam(pixi, g, 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(pixi, cont, inst, viewport, t, life);
|
||||
}
|
||||
|
||||
if (inst.type === 'freeze') {
|
||||
const s = node;
|
||||
const life = Math.max(1, inst.lifetimeMs);
|
||||
@@ -526,14 +645,303 @@ function animateNodes(
|
||||
|
||||
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)));
|
||||
// Пятно льда: сразу на полную яркость и остаётся на сцене (не синхронизируем с длительностью «замершего экрана»).
|
||||
s.visible = true;
|
||||
s.alpha = Math.max(0, Math.min(1, inst.opacity));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hashWaterStroke(inst: Extract<EffectInstance, { type: 'water' }>): number {
|
||||
let h = 2166136261 >>> 0;
|
||||
for (const p of inst.points) {
|
||||
if (!p) continue;
|
||||
h ^= Math.round(p.x * 10000);
|
||||
h = Math.imul(h, 16777619);
|
||||
h ^= Math.round(p.y * 10000);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
function redrawWaterDraft(
|
||||
g: any,
|
||||
inst: Extract<EffectInstance, { type: 'water' }>,
|
||||
viewport: { x: number; y: number; w: number; h: number },
|
||||
halfW: number,
|
||||
) {
|
||||
const { x: vx, y: vy, w, h } = viewport;
|
||||
g.clear();
|
||||
const pts = inst.points;
|
||||
if (pts.length === 0) return;
|
||||
const px = pts.map((p) => ({ x: vx + p.x * w, y: vy + p.y * h }));
|
||||
// Толщина как у итоговой заливки: диаметр = 2 × радиус кисти (см. buildWaterFillSprite).
|
||||
const lineW = Math.max(2, halfW * 2);
|
||||
if (px.length === 1) {
|
||||
const p0 = px[0];
|
||||
if (!p0) return;
|
||||
g.circle(p0.x, p0.y, Math.max(2, halfW));
|
||||
g.fill({ color: 0x5fc3ff, alpha: 0.42 });
|
||||
return;
|
||||
}
|
||||
const pStart = px[0];
|
||||
if (!pStart) return;
|
||||
g.moveTo(pStart.x, pStart.y);
|
||||
for (let i = 1; i < px.length; i += 1) {
|
||||
const pi = px[i];
|
||||
if (!pi) continue;
|
||||
g.lineTo(pi.x, pi.y);
|
||||
}
|
||||
g.stroke({ width: lineW, color: 0x5fc3ff, alpha: 0.48, cap: 'round', join: 'round' });
|
||||
}
|
||||
|
||||
function buildWaterFillSprite(
|
||||
pixi: any,
|
||||
inst: Extract<EffectInstance, { type: 'water' }>,
|
||||
viewport: { x: number; y: number; w: number; h: number },
|
||||
halfW: number,
|
||||
): { sprite: any } | 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;
|
||||
const alpha = Math.max(0.08, Math.min(0.82, inst.opacity));
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.strokeStyle = `rgba(95, 195, 255, ${String(alpha)})`;
|
||||
ctx.fillStyle = `rgba(95, 195, 255, ${String(alpha)})`;
|
||||
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 texture = pixi.Texture.from(canvas);
|
||||
const sprite = new pixi.Sprite(texture);
|
||||
sprite.x = minX - pad / 2;
|
||||
sprite.y = minY - pad / 2;
|
||||
return { sprite };
|
||||
}
|
||||
|
||||
function redrawPoisonCloud(
|
||||
pixi: any,
|
||||
cont: any,
|
||||
inst: Extract<EffectInstance, { type: 'poisonCloud' }>,
|
||||
viewport: { x: number; y: number; w: number; h: number },
|
||||
t: number,
|
||||
life: number,
|
||||
) {
|
||||
void pixi;
|
||||
const fx = (cont as any).__fx as { particles: PoisonParticleFx[]; skull: any };
|
||||
const particles = fx.particles;
|
||||
const skull = fx.skull;
|
||||
const { x: vx, y: vy, w, h } = viewport;
|
||||
const cx = vx + inst.at.x * w;
|
||||
const cy = vy + inst.at.y * h;
|
||||
const R = Math.max(14, inst.radiusN * Math.min(w, h) * 1.15);
|
||||
const inten = Math.max(0, Math.min(1.2, inst.intensity));
|
||||
|
||||
const u = t / Math.max(1e-6, life);
|
||||
const stemPhase = smoothstep01(Math.min(1, u / 0.34));
|
||||
const capPhase = u < 0.14 ? 0 : smoothstep01((u - 0.14) / Math.max(1e-6, 0.44));
|
||||
|
||||
let alphaM = 1;
|
||||
if (u >= 0.66) {
|
||||
alphaM = 1 - smoothstep01((u - 0.66) / Math.max(1e-6, 0.34));
|
||||
}
|
||||
const aGlobal = Math.max(0, Math.min(1, inten * alphaM));
|
||||
if (aGlobal < 0.003) {
|
||||
if (skull) skull.visible = false;
|
||||
for (const p of particles) p.s.visible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const stemH = R * 1.1;
|
||||
const baseHalfW = R * 0.72;
|
||||
const topHalfW = R * 0.1;
|
||||
const stemTopY = cy - stemH * stemPhase;
|
||||
const capCenterY = stemTopY - R * 0.12 * Math.max(0.2, capPhase);
|
||||
const capRad = R * 1.25 * capPhase;
|
||||
const wobble = 0.04 * Math.sin(t * 0.014 + inst.seed * 0.01);
|
||||
const particleR = 0.32;
|
||||
|
||||
for (const p of particles) {
|
||||
const s = p.s;
|
||||
if (p.kind === 'stem') {
|
||||
s.visible = stemPhase > 0.02;
|
||||
if (!s.visible) continue;
|
||||
const frac = p.uStem;
|
||||
const z = frac * stemH * stemPhase;
|
||||
const y = cy - z;
|
||||
const mix = frac;
|
||||
const halfAtY = baseHalfW * (1 - mix) + topHalfW * mix;
|
||||
const spread = halfAtY * (0.82 + 0.18 * Math.sin(p.flick + t * 0.008));
|
||||
s.x = cx + p.side * spread + wobble * R * 0.08;
|
||||
s.y = y + Math.sin(p.flick * 1.7 + t * 0.01) * 2.5;
|
||||
const pr = stemPhase * (0.2 + 0.8 * smoothstep01(frac * 1.15));
|
||||
s.alpha = Math.max(0, Math.min(1, aGlobal * 0.55 * pr * (0.65 + 0.35 * stemPhase)));
|
||||
s.scale?.set?.(p.sz * (0.75 + 0.35 * stemPhase) * particleR);
|
||||
} else {
|
||||
const vis = capPhase;
|
||||
if (vis < 0.02) {
|
||||
s.visible = false;
|
||||
continue;
|
||||
}
|
||||
s.visible = true;
|
||||
const ang = p.capAng + wobble * 0.4;
|
||||
const rd = capRad * Math.min(1, p.capDist * 1.02 + 0.02);
|
||||
s.x = cx + Math.cos(ang) * rd + Math.sin(t * 0.011 + p.flick) * 3;
|
||||
s.y = capCenterY + Math.sin(ang) * rd * 0.48;
|
||||
s.alpha = Math.max(
|
||||
0,
|
||||
Math.min(1, aGlobal * 0.52 * vis * (0.75 + 0.25 * Math.sin(t * 0.019 + p.flick))),
|
||||
);
|
||||
s.scale?.set?.(p.sz * (0.55 + 0.55 * vis) * particleR);
|
||||
}
|
||||
}
|
||||
|
||||
if (skull) {
|
||||
const show = stemPhase > 0.72 && capPhase > 0.28;
|
||||
skull.visible = show;
|
||||
skull.alpha = aGlobal * (show ? 0.94 : 0);
|
||||
skull.x = cx + wobble * R * 0.05;
|
||||
skull.y = capCenterY - capRad * 0.72 - R * 0.28;
|
||||
const fs = Math.max(16, Math.min(86, R * 0.48 + capRad * 0.38));
|
||||
if (skull.style && typeof skull.style === 'object' && 'fontSize' in skull.style) {
|
||||
(skull.style as { fontSize: number }).fontSize = fs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function redrawSunbeam(
|
||||
pixi: any,
|
||||
g: any,
|
||||
inst: Extract<EffectInstance, { type: 'sunbeam' }>,
|
||||
viewport: { x: number; y: number; w: number; h: number },
|
||||
t: number,
|
||||
life: number,
|
||||
) {
|
||||
void pixi;
|
||||
const { x: vx, y: vy, w, h } = viewport;
|
||||
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 width = Math.max(1, inst.widthN * Math.min(w, h));
|
||||
const inten = Math.max(0, Math.min(1.2, inst.intensity));
|
||||
g.clear();
|
||||
|
||||
const lifePos = Math.max(1e-6, life);
|
||||
// Фаза 1: рост сверху вниз; фаза 2: полный луч + лёгкая пульсация; фаза 3: затухание.
|
||||
const revealMs = lifePos * 0.28;
|
||||
const pulseMs = lifePos * 0.47;
|
||||
const fadeMs = lifePos * 0.25;
|
||||
const tRevealEnd = revealMs;
|
||||
const tPulseEnd = revealMs + pulseMs;
|
||||
|
||||
const revealLin = Math.min(1, t / Math.max(1e-6, revealMs));
|
||||
const revealEase = t < tRevealEnd ? smoothstep01(revealLin) : 1;
|
||||
|
||||
let pulseMul = 1;
|
||||
if (t >= tRevealEnd && t < tPulseEnd) {
|
||||
const u = t - tRevealEnd;
|
||||
const pulses = 5;
|
||||
const omega = (pulses * 2 * Math.PI) / Math.max(1e-6, pulseMs);
|
||||
const phase = (inst.seed & 4095) * 0.00153;
|
||||
pulseMul = 1 + 0.1 * Math.sin(u * omega + phase);
|
||||
}
|
||||
|
||||
const vis = t < tPulseEnd ? 1 : 1 - smoothstep01((t - tPulseEnd) / Math.max(1e-6, fadeMs));
|
||||
if (vis < 0.002) return;
|
||||
|
||||
const bx = sx + (ex - sx) * revealEase;
|
||||
const by = sy + (ey - sy) * revealEase;
|
||||
const intenVis = inten * vis * pulseMul;
|
||||
|
||||
// Засветка кадра: нарастает с лучом; в фазе пульса слегка «дышит»; затем гаснет.
|
||||
const veilMod = t >= tRevealEnd && t < tPulseEnd ? pulseMul : 1;
|
||||
const veilA = Math.max(0, Math.min(0.24, 0.22 * revealEase * vis * inten * veilMod));
|
||||
if (veilA > 0.002) {
|
||||
g.rect(vx, vy, w, h);
|
||||
g.fill({ color: 0xfff4b8, alpha: veilA });
|
||||
}
|
||||
|
||||
const minDim = Math.min(w, h);
|
||||
const segLen = Math.hypot(bx - sx, by - sy);
|
||||
if (segLen < 0.8) return;
|
||||
|
||||
const outerW = Math.max(width * 26, minDim * 0.9);
|
||||
g.moveTo(sx, sy);
|
||||
g.lineTo(bx, by);
|
||||
g.stroke({
|
||||
color: 0xffec78,
|
||||
width: outerW,
|
||||
alpha: Math.min(1, 0.034 * intenVis),
|
||||
cap: 'round',
|
||||
});
|
||||
|
||||
const haloLayers: { mult: number; color: number; a: number }[] = [
|
||||
{ mult: 18, color: 0xffe040, a: 0.048 * intenVis },
|
||||
{ mult: 12, color: 0xffea65, a: 0.075 * intenVis },
|
||||
{ mult: 7, color: 0xfff090, a: 0.11 * intenVis },
|
||||
{ mult: 4, color: 0xfffacd, a: 0.16 * intenVis },
|
||||
{ mult: 2.2, color: 0xffffee, a: 0.22 * intenVis },
|
||||
];
|
||||
for (const layer of haloLayers) {
|
||||
const lw = Math.max(width * layer.mult, width + minDim * 0.025);
|
||||
const a = Math.min(1, layer.a);
|
||||
if (a < 0.004) continue;
|
||||
g.moveTo(sx, sy);
|
||||
g.lineTo(bx, by);
|
||||
g.stroke({ color: layer.color, width: lw, alpha: a, cap: 'round' });
|
||||
}
|
||||
|
||||
const coreLayers: { wmul: number; color: number; a: number }[] = [
|
||||
{ wmul: 2.4, color: 0xffffcc, a: Math.min(1, 0.45 * intenVis) },
|
||||
{ wmul: 1.05, color: 0xffffaa, a: Math.min(1, 0.78 * intenVis) },
|
||||
{ wmul: 0.4, color: 0xffffff, a: Math.min(1, 0.98 * intenVis) },
|
||||
];
|
||||
for (const cl of coreLayers) {
|
||||
const lw = Math.max(1.2, width * cl.wmul);
|
||||
g.moveTo(sx, sy);
|
||||
g.lineTo(bx, by);
|
||||
g.stroke({ color: cl.color, width: lw, alpha: cl.a, cap: 'round' });
|
||||
}
|
||||
}
|
||||
|
||||
function redrawLightning(
|
||||
pixi: any,
|
||||
g: any,
|
||||
@@ -615,7 +1023,7 @@ function redrawLightning(
|
||||
}
|
||||
}
|
||||
|
||||
function instanceSig(inst: EffectInstance): string {
|
||||
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;
|
||||
@@ -634,9 +1042,19 @@ function instanceSig(inst: EffectInstance): string {
|
||||
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)}`;
|
||||
}
|
||||
@@ -695,10 +1113,44 @@ function computeSceneShake(
|
||||
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 poisonParticleTextureCache: { 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;
|
||||
|
||||
/** Круглая мягкая точка для частиц яда (не квадрат `Texture.WHITE`). */
|
||||
function getPoisonParticleTexture(pixi: any): any {
|
||||
const key = 'poison_particle_round_v1';
|
||||
if (poisonParticleTextureCache?.key === key) return poisonParticleTextureCache.texture;
|
||||
|
||||
const size = 48;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
const t = pixi.Texture.WHITE;
|
||||
poisonParticleTextureCache = { key, texture: t };
|
||||
return t;
|
||||
}
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const r = size / 2 - 0.5;
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
const grd = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
|
||||
grd.addColorStop(0, 'rgba(255,255,255,1)');
|
||||
grd.addColorStop(0.88, 'rgba(255,255,255,0.95)');
|
||||
grd.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
ctx.fillStyle = grd;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
const tex = pixi.Texture.from(canvas);
|
||||
poisonParticleTextureCache = { key, texture: tex };
|
||||
return tex;
|
||||
}
|
||||
|
||||
function getFogTexture(pixi: any): any {
|
||||
// Кэшируем один раз на процесс. Текстура — “дымка” с мягкой альфой.
|
||||
const key = 'fog_v1';
|
||||
@@ -1049,9 +1501,11 @@ function smoothstep01(x: number): number {
|
||||
}
|
||||
|
||||
function freezeAlpha(t: number, life: number): number {
|
||||
const inMs = 180;
|
||||
const holdMs = 220;
|
||||
const outMs = Math.max(120, life - inMs - holdMs);
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user