feat(effects): add Darkness action effect like freeze
Adds a fullscreen darkness vignette on click and a permanent shadow spot with a black radial gradient (40% edge, 100% center). Includes control panel UI, i18n, eraser support, and tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -358,6 +358,19 @@ function createInstanceNode(
|
||||
c.blendMode = pixi.BLEND_MODES?.NORMAL ?? 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 g = new pixi.Graphics();
|
||||
g.blendMode = pixi.BLEND_MODES?.ADD ?? 1;
|
||||
@@ -479,6 +492,21 @@ function createInstanceNode(
|
||||
(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;
|
||||
}
|
||||
|
||||
@@ -646,6 +674,28 @@ function animateNodes(
|
||||
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);
|
||||
@@ -663,7 +713,7 @@ function animateNodes(
|
||||
}
|
||||
}
|
||||
|
||||
function hashWaterStroke(inst: Extract<EffectInstance, { type: 'water' }>): number {
|
||||
function hashWaterStroke(inst: { points: { x: number; y: number }[] }): number {
|
||||
let h = 2166136261 >>> 0;
|
||||
for (const p of inst.points) {
|
||||
if (!p) continue;
|
||||
@@ -1069,12 +1119,18 @@ function instanceSig(inst: EffectInstance, viewport: { x: number; y: number; w:
|
||||
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;
|
||||
@@ -1127,7 +1183,9 @@ 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 shadowTextureCache: { key: string; texture: any } | null = null;
|
||||
let freezeScreenTextureCache: Map<string, any> | null = null;
|
||||
let darknessScreenTextureCache: Map<string, any> | null = null;
|
||||
|
||||
/** Круглая мягкая точка для частиц яда (не квадрат `Texture.WHITE`). */
|
||||
function getPoisonParticleTexture(pixi: any): any {
|
||||
@@ -1506,6 +1564,41 @@ function getScorchTexture(pixi: any): any {
|
||||
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);
|
||||
@@ -1664,6 +1757,77 @@ function getFreezeScreenTexture(
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user