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:
@@ -46,8 +46,8 @@ export class EffectsStore {
|
|||||||
const before = this.state.instances.length;
|
const before = this.state.instances.length;
|
||||||
const kept = this.state.instances.filter((i) => {
|
const kept = this.state.instances.filter((i) => {
|
||||||
// Пятно льда не истекает по таймеру (только «очистить все» или ластик в UI).
|
// Пятно льда не истекает по таймеру (только «очистить все» или ластик в UI).
|
||||||
if (i.type === 'ice') return true;
|
if (i.type === 'ice' || i.type === 'shadow') return true;
|
||||||
if (i.type === 'lightning' || i.type === 'sunbeam' || i.type === 'poisonCloud') {
|
if (i.type === 'lightning' || i.type === 'sunbeam' || i.type === 'poisonCloud' || i.type === 'darkness') {
|
||||||
return now - i.createdAtMs < i.lifetimeMs;
|
return now - i.createdAtMs < i.lifetimeMs;
|
||||||
}
|
}
|
||||||
if (i.type === 'scorch') {
|
if (i.type === 'scorch') {
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function ControlApp() {
|
|||||||
const previewHostRef = useRef<HTMLDivElement | null>(null);
|
const previewHostRef = useRef<HTMLDivElement | null>(null);
|
||||||
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
|
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const brushRef = useRef<{
|
const brushRef = useRef<{
|
||||||
tool: 'fog' | 'fire' | 'rain' | 'water' | 'lightning' | 'sunbeam' | 'poisonCloud' | 'freeze' | 'eraser';
|
tool: 'fog' | 'fire' | 'rain' | 'water' | 'darkness' | 'lightning' | 'sunbeam' | 'poisonCloud' | 'freeze' | 'eraser';
|
||||||
startN?: { x: number; y: number };
|
startN?: { x: number; y: number };
|
||||||
points?: { x: number; y: number; tMs: number }[];
|
points?: { x: number; y: number; tMs: number }[];
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
@@ -133,6 +133,11 @@ export function ControlApp() {
|
|||||||
void getFreezeEffectLifeMs().then(setFreezeDraftLifeMs);
|
void getFreezeEffectLifeMs().then(setFreezeDraftLifeMs);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const [darknessDraftLifeMs, setDarknessDraftLifeMs] = useState(820);
|
||||||
|
useEffect(() => {
|
||||||
|
void getFreezeEffectLifeMs().then(setDarknessDraftLifeMs);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [sunbeamDraftLifeMs, setSunbeamDraftLifeMs] = useState(600);
|
const [sunbeamDraftLifeMs, setSunbeamDraftLifeMs] = useState(600);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void getSunbeamEffectLifeMs().then(setSunbeamDraftLifeMs);
|
void getSunbeamEffectLifeMs().then(setSunbeamDraftLifeMs);
|
||||||
@@ -714,6 +719,37 @@ export function ControlApp() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (b.tool === 'darkness' && b.points && b.points.length > 0) {
|
||||||
|
const last = b.points[b.points.length - 1];
|
||||||
|
if (last === undefined) return;
|
||||||
|
const at = { x: last.x, y: last.y };
|
||||||
|
const darknessLifeMs = await getFreezeEffectLifeMs();
|
||||||
|
await fx.dispatch({
|
||||||
|
kind: 'instance.add',
|
||||||
|
instance: {
|
||||||
|
id: `dk_${String(createdAtMs)}_${String(seed)}`,
|
||||||
|
type: 'darkness',
|
||||||
|
seed,
|
||||||
|
createdAtMs,
|
||||||
|
at,
|
||||||
|
intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)),
|
||||||
|
lifetimeMs: darknessLifeMs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await fx.dispatch({
|
||||||
|
kind: 'instance.add',
|
||||||
|
instance: {
|
||||||
|
id: `sh_${String(createdAtMs)}_${String(seed)}`,
|
||||||
|
type: 'shadow',
|
||||||
|
seed: seed ^ 0x0d4a0001,
|
||||||
|
createdAtMs,
|
||||||
|
at,
|
||||||
|
radiusN: Math.max(0.03, tool.radiusN * 0.9),
|
||||||
|
opacity: 1,
|
||||||
|
lifetimeMs: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
if (b.tool === 'lightning' && b.startN && b.points && b.points.length > 0) {
|
if (b.tool === 'lightning' && b.startN && b.points && b.points.length > 0) {
|
||||||
const last = b.points[b.points.length - 1];
|
const last = b.points[b.points.length - 1];
|
||||||
if (last === undefined) return;
|
if (last === undefined) return;
|
||||||
@@ -879,6 +915,19 @@ export function ControlApp() {
|
|||||||
lifetimeMs: null,
|
lifetimeMs: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (b.tool === 'darkness' && b.points && b.points.length > 0) {
|
||||||
|
const last = b.points[b.points.length - 1];
|
||||||
|
if (last === undefined) return null;
|
||||||
|
return {
|
||||||
|
id: '__draft__',
|
||||||
|
type: 'darkness' as const,
|
||||||
|
seed,
|
||||||
|
createdAtMs,
|
||||||
|
at: { x: last.x, y: last.y },
|
||||||
|
intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)),
|
||||||
|
lifetimeMs: darknessDraftLifeMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
if (b.tool === 'lightning' && b.startN && b.points && b.points.length > 0) {
|
if (b.tool === 'lightning' && b.startN && b.points && b.points.length > 0) {
|
||||||
const last = b.points[b.points.length - 1];
|
const last = b.points[b.points.length - 1];
|
||||||
if (last === undefined) return null;
|
if (last === undefined) return null;
|
||||||
@@ -940,6 +989,7 @@ export function ControlApp() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [
|
}, [
|
||||||
draftFxTick,
|
draftFxTick,
|
||||||
|
darknessDraftLifeMs,
|
||||||
freezeDraftLifeMs,
|
freezeDraftLifeMs,
|
||||||
poisonDraftLifeMs,
|
poisonDraftLifeMs,
|
||||||
sunbeamDraftLifeMs,
|
sunbeamDraftLifeMs,
|
||||||
@@ -1073,6 +1123,15 @@ export function ControlApp() {
|
|||||||
>
|
>
|
||||||
<span className={styles.iconGlyph}>❄️</span>
|
<span className={styles.iconGlyph}>❄️</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={tool.tool === 'darkness' ? 'primary' : 'ghost'}
|
||||||
|
iconOnly
|
||||||
|
title={t('control.darkness')}
|
||||||
|
ariaLabel={t('control.darkness')}
|
||||||
|
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'darkness' } })}
|
||||||
|
>
|
||||||
|
<span className={styles.iconGlyph}>🌑</span>
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={tool.tool === 'poisonCloud' ? 'primary' : 'ghost'}
|
variant={tool.tool === 'poisonCloud' ? 'primary' : 'ghost'}
|
||||||
iconOnly
|
iconOnly
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ void test('ControlApp: эффекты в пульте, иконки с тулт
|
|||||||
assert.ok(src.includes("t('control.actionEffects')"));
|
assert.ok(src.includes("t('control.actionEffects')"));
|
||||||
assert.ok(src.includes("t('control.sunbeam')"));
|
assert.ok(src.includes("t('control.sunbeam')"));
|
||||||
assert.ok(src.includes("title={t('control.water')}"));
|
assert.ok(src.includes("title={t('control.water')}"));
|
||||||
|
assert.ok(src.includes("title={t('control.darkness')}"));
|
||||||
assert.ok(src.includes("title={t('control.poisonCloud')}"));
|
assert.ok(src.includes("title={t('control.poisonCloud')}"));
|
||||||
assert.ok(src.includes("title={t('control.fog')}"));
|
assert.ok(src.includes("title={t('control.fog')}"));
|
||||||
assert.ok(src.includes("ariaLabel={t('control.fog')}"));
|
assert.ok(src.includes("ariaLabel={t('control.fog')}"));
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
|
|
||||||
'help.section.effects.title': 'Эффекты поля и действий',
|
'help.section.effects.title': 'Эффекты поля и действий',
|
||||||
'help.section.effects.body':
|
'help.section.effects.body':
|
||||||
'Эффекты работают на сценах с картинкой, не на видео. Рисуйте в «Предпросмотр экрана» — игроки увидят то же на презентации.\n\nВыберите инструмент слева:\n• Эффекты поля (туман, дождь, огонь, вода) — зажмите левую кнопку и ведите по карте.\n• Эффекты действий (молния, луч света, заморозка, облако яда) — короткий клик или штрих; у некоторых есть звук.\n\nЛастик 🧹 — клик или проведение по эффекту, чтобы убрать. «Очистить эффекты» — снять всё сразу.\n\n«Радиус кисти» под панелью — чем больше число, тем шире мазок.',
|
'Эффекты работают на сценах с картинкой, не на видео. Рисуйте в «Предпросмотр экрана» — игроки увидят то же на презентации.\n\nВыберите инструмент слева:\n• Эффекты поля (туман, дождь, огонь, вода) — зажмите левую кнопку и ведите по карте.\n• Эффекты действий (молния, луч света, заморозка, тьма, облако яда) — короткий клик или штрих; у некоторых есть звук.\n\nЛастик 🧹 — клик или проведение по эффекту, чтобы убрать. «Очистить эффекты» — снять всё сразу.\n\n«Радиус кисти» под панелью — чем больше число, тем шире мазок.',
|
||||||
|
|
||||||
'help.section.presentation.title': 'Экран презентации',
|
'help.section.presentation.title': 'Экран презентации',
|
||||||
'help.section.presentation.body':
|
'help.section.presentation.body':
|
||||||
@@ -317,6 +317,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'control.rain': 'Дождь',
|
'control.rain': 'Дождь',
|
||||||
'control.fire': 'Огонь',
|
'control.fire': 'Огонь',
|
||||||
'control.water': 'Вода',
|
'control.water': 'Вода',
|
||||||
|
'control.darkness': 'Тьма',
|
||||||
'control.lightning': 'Молния',
|
'control.lightning': 'Молния',
|
||||||
'control.sunbeam': 'Луч света',
|
'control.sunbeam': 'Луч света',
|
||||||
'control.freeze': 'Заморозка',
|
'control.freeze': 'Заморозка',
|
||||||
@@ -504,7 +505,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
|
|
||||||
'help.section.effects.title': 'Field and action effects',
|
'help.section.effects.title': 'Field and action effects',
|
||||||
'help.section.effects.body':
|
'help.section.effects.body':
|
||||||
'Effects work on image scenes, not video. Paint in Screen preview — players see the same on presentation.\n\nPick a tool on the left:\n• Field effects (fog, rain, fire, water) — hold the left button and brush on the map.\n• Action effects (lightning, sunbeam, freeze, poison cloud) — click or short stroke; some include sound.\n\nEraser 🧹 — click or drag over an effect to remove it. Clear effects removes everything at once.\n\nBrush radius under the panel — higher values mean a wider stroke.',
|
'Effects work on image scenes, not video. Paint in Screen preview — players see the same on presentation.\n\nPick a tool on the left:\n• Field effects (fog, rain, fire, water) — hold the left button and brush on the map.\n• Action effects (lightning, sunbeam, freeze, darkness, poison cloud) — click or short stroke; some include sound.\n\nEraser 🧹 — click or drag over an effect to remove it. Clear effects removes everything at once.\n\nBrush radius under the panel — higher values mean a wider stroke.',
|
||||||
|
|
||||||
'help.section.presentation.title': 'Presentation screen',
|
'help.section.presentation.title': 'Presentation screen',
|
||||||
'help.section.presentation.body':
|
'help.section.presentation.body':
|
||||||
@@ -635,6 +636,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'control.rain': 'Rain',
|
'control.rain': 'Rain',
|
||||||
'control.fire': 'Fire',
|
'control.fire': 'Fire',
|
||||||
'control.water': 'Water',
|
'control.water': 'Water',
|
||||||
|
'control.darkness': 'Darkness',
|
||||||
'control.lightning': 'Lightning',
|
'control.lightning': 'Lightning',
|
||||||
'control.sunbeam': 'Sunbeam',
|
'control.sunbeam': 'Sunbeam',
|
||||||
'control.freeze': 'Freeze',
|
'control.freeze': 'Freeze',
|
||||||
|
|||||||
@@ -358,6 +358,19 @@ function createInstanceNode(
|
|||||||
c.blendMode = pixi.BLEND_MODES?.NORMAL ?? 0;
|
c.blendMode = pixi.BLEND_MODES?.NORMAL ?? 0;
|
||||||
return c;
|
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') {
|
if (inst.type === 'lightning') {
|
||||||
const g = new pixi.Graphics();
|
const g = new pixi.Graphics();
|
||||||
g.blendMode = pixi.BLEND_MODES?.ADD ?? 1;
|
g.blendMode = pixi.BLEND_MODES?.ADD ?? 1;
|
||||||
@@ -479,6 +492,21 @@ function createInstanceNode(
|
|||||||
(s as any).__fx = { id: inst.id, type: inst.type };
|
(s as any).__fx = { id: inst.id, type: inst.type };
|
||||||
return s;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,6 +674,28 @@ function animateNodes(
|
|||||||
s.alpha = freezeAlpha(t, life) * Math.max(0, Math.min(1.2, inst.intensity));
|
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') {
|
if (inst.type === 'scorch') {
|
||||||
const s = node;
|
const s = node;
|
||||||
const life = Math.max(1, inst.lifetimeMs);
|
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;
|
let h = 2166136261 >>> 0;
|
||||||
for (const p of inst.points) {
|
for (const p of inst.points) {
|
||||||
if (!p) continue;
|
if (!p) continue;
|
||||||
@@ -1069,12 +1119,18 @@ function instanceSig(inst: EffectInstance, viewport: { x: number; y: number; w:
|
|||||||
if (inst.type === 'freeze') {
|
if (inst.type === 'freeze') {
|
||||||
return `fr:${Math.round(inst.at.x * 1000)}:${Math.round(inst.at.y * 1000)}:${Math.round(inst.intensity * 1000)}`;
|
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') {
|
if (inst.type === 'scorch') {
|
||||||
return `sc:${Math.round(inst.at.x * 1000)}:${Math.round(inst.at.y * 1000)}:${Math.round(inst.radiusN * 1000)}`;
|
return `sc:${Math.round(inst.at.x * 1000)}:${Math.round(inst.at.y * 1000)}:${Math.round(inst.radiusN * 1000)}`;
|
||||||
}
|
}
|
||||||
if (inst.type === 'ice') {
|
if (inst.type === 'ice') {
|
||||||
return `ice:${Math.round(inst.at.x * 1000)}:${Math.round(inst.at.y * 1000)}:${Math.round(inst.radiusN * 1000)}`;
|
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 — на случай, если добавим новый тип инстанса и забудем сюда.
|
// Exhaustive guard — на случай, если добавим новый тип инстанса и забудем сюда.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const _exhaustive: never = inst;
|
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 poisonParticleTextureCache: { key: string; texture: any } | null = null;
|
||||||
let scorchTextureCache: { key: string; texture: any } | null = null;
|
let scorchTextureCache: { key: string; texture: any } | null = null;
|
||||||
let iceTextureCache: { 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 freezeScreenTextureCache: Map<string, any> | null = null;
|
||||||
|
let darknessScreenTextureCache: Map<string, any> | null = null;
|
||||||
|
|
||||||
/** Круглая мягкая точка для частиц яда (не квадрат `Texture.WHITE`). */
|
/** Круглая мягкая точка для частиц яда (не квадрат `Texture.WHITE`). */
|
||||||
function getPoisonParticleTexture(pixi: any): any {
|
function getPoisonParticleTexture(pixi: any): any {
|
||||||
@@ -1506,6 +1564,41 @@ function getScorchTexture(pixi: any): any {
|
|||||||
return 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 {
|
function smoothstep01(x: number): number {
|
||||||
const t = Math.max(0, Math.min(1, x));
|
const t = Math.max(0, Math.min(1, x));
|
||||||
return t * t * (3 - 2 * t);
|
return t * t * (3 - 2 * t);
|
||||||
@@ -1664,6 +1757,77 @@ function getFreezeScreenTexture(
|
|||||||
return tex;
|
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 {
|
function clamp255(v: number): number {
|
||||||
if (!Number.isFinite(v)) return 0;
|
if (!Number.isFinite(v)) return 0;
|
||||||
if (v < 0) return 0;
|
if (v < 0) return 0;
|
||||||
|
|||||||
@@ -34,6 +34,33 @@ void test('pickEraseTargetId: вода по штриху как туман', ()
|
|||||||
assert.equal(id, 'w1');
|
assert.equal(id, 'w1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('pickEraseTargetId: тьма как заморозка — точка удара', () => {
|
||||||
|
const darkness: EffectInstance = {
|
||||||
|
...base,
|
||||||
|
id: 'd1',
|
||||||
|
type: 'darkness',
|
||||||
|
at: { x: 0.4, y: 0.55 },
|
||||||
|
intensity: 1,
|
||||||
|
lifetimeMs: 820,
|
||||||
|
};
|
||||||
|
const id = pickEraseTargetId([darkness], { x: 0.41, y: 0.55 }, 0.05);
|
||||||
|
assert.equal(id, 'd1');
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('pickEraseTargetId: shadow с учётом inst.radiusN', () => {
|
||||||
|
const shadow: EffectInstance = {
|
||||||
|
...base,
|
||||||
|
id: 's1',
|
||||||
|
type: 'shadow',
|
||||||
|
at: { x: 0.5, y: 0.5 },
|
||||||
|
radiusN: 0.08,
|
||||||
|
opacity: 1,
|
||||||
|
lifetimeMs: null,
|
||||||
|
};
|
||||||
|
const id = pickEraseTargetId([shadow], { x: 0.59, y: 0.5 }, 0.02);
|
||||||
|
assert.equal(id, 's1');
|
||||||
|
});
|
||||||
|
|
||||||
void test('minDistSqEffectToPoint: молния — расстояние до отрезка', () => {
|
void test('minDistSqEffectToPoint: молния — расстояние до отрезка', () => {
|
||||||
const bolt: EffectInstance = {
|
const bolt: EffectInstance = {
|
||||||
...base,
|
...base,
|
||||||
|
|||||||
@@ -43,13 +43,15 @@ export function minDistSqEffectToPoint(inst: EffectInstance, p: { x: number; y:
|
|||||||
case 'lightning':
|
case 'lightning':
|
||||||
case 'sunbeam':
|
case 'sunbeam':
|
||||||
return distSqPointToSegment(p.x, p.y, inst.start.x, inst.start.y, inst.end.x, inst.end.y);
|
return distSqPointToSegment(p.x, p.y, inst.start.x, inst.start.y, inst.end.x, inst.end.y);
|
||||||
case 'freeze': {
|
case 'freeze':
|
||||||
|
case 'darkness': {
|
||||||
const dx = inst.at.x - p.x;
|
const dx = inst.at.x - p.x;
|
||||||
const dy = inst.at.y - p.y;
|
const dy = inst.at.y - p.y;
|
||||||
return dx * dx + dy * dy;
|
return dx * dx + dy * dy;
|
||||||
}
|
}
|
||||||
case 'scorch':
|
case 'scorch':
|
||||||
case 'ice':
|
case 'ice':
|
||||||
|
case 'shadow':
|
||||||
case 'poisonCloud': {
|
case 'poisonCloud': {
|
||||||
const dx = inst.at.x - p.x;
|
const dx = inst.at.x - p.x;
|
||||||
const dy = inst.at.y - p.y;
|
const dy = inst.at.y - p.y;
|
||||||
@@ -61,7 +63,7 @@ export function minDistSqEffectToPoint(inst: EffectInstance, p: { x: number; y:
|
|||||||
}
|
}
|
||||||
|
|
||||||
function eraseHitThresholdSq(inst: EffectInstance, toolRadiusN: number): number {
|
function eraseHitThresholdSq(inst: EffectInstance, toolRadiusN: number): number {
|
||||||
if (inst.type === 'scorch' || inst.type === 'ice' || inst.type === 'poisonCloud') {
|
if (inst.type === 'scorch' || inst.type === 'ice' || inst.type === 'shadow' || inst.type === 'poisonCloud') {
|
||||||
const r = toolRadiusN + inst.radiusN;
|
const r = toolRadiusN + inst.radiusN;
|
||||||
return r * r;
|
return r * r;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export type EffectToolType =
|
|||||||
| 'fire'
|
| 'fire'
|
||||||
| 'rain'
|
| 'rain'
|
||||||
| 'water'
|
| 'water'
|
||||||
|
| 'darkness'
|
||||||
| 'lightning'
|
| 'lightning'
|
||||||
| 'sunbeam'
|
| 'sunbeam'
|
||||||
| 'poisonCloud'
|
| 'poisonCloud'
|
||||||
@@ -14,12 +15,14 @@ export type EffectInstanceType =
|
|||||||
| 'fire'
|
| 'fire'
|
||||||
| 'rain'
|
| 'rain'
|
||||||
| 'water'
|
| 'water'
|
||||||
|
| 'darkness'
|
||||||
| 'lightning'
|
| 'lightning'
|
||||||
| 'sunbeam'
|
| 'sunbeam'
|
||||||
| 'poisonCloud'
|
| 'poisonCloud'
|
||||||
| 'freeze'
|
| 'freeze'
|
||||||
| 'scorch'
|
| 'scorch'
|
||||||
| 'ice';
|
| 'ice'
|
||||||
|
| 'shadow';
|
||||||
|
|
||||||
/** Нормализованные координаты (0..1) относительно области предпросмотра/презентации. */
|
/** Нормализованные координаты (0..1) относительно области предпросмотра/презентации. */
|
||||||
export type NPoint = { x: number; y: number; tMs: number; pressure?: number };
|
export type NPoint = { x: number; y: number; tMs: number; pressure?: number };
|
||||||
@@ -65,6 +68,13 @@ export type WaterInstance = EffectInstanceBase & {
|
|||||||
lifetimeMs: number | null;
|
lifetimeMs: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DarknessInstance = EffectInstanceBase & {
|
||||||
|
type: 'darkness';
|
||||||
|
at: { x: number; y: number };
|
||||||
|
intensity: number;
|
||||||
|
lifetimeMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type LightningInstance = EffectInstanceBase & {
|
export type LightningInstance = EffectInstanceBase & {
|
||||||
type: 'lightning';
|
type: 'lightning';
|
||||||
start: { x: number; y: number };
|
start: { x: number; y: number };
|
||||||
@@ -119,17 +129,28 @@ export type IceInstance = EffectInstanceBase & {
|
|||||||
lifetimeMs: number | null;
|
lifetimeMs: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ShadowInstance = EffectInstanceBase & {
|
||||||
|
/** Внутренний инстанс: тёмное пятно после эффекта «Тьма». */
|
||||||
|
type: 'shadow';
|
||||||
|
at: { x: number; y: number };
|
||||||
|
radiusN: number;
|
||||||
|
opacity: number;
|
||||||
|
lifetimeMs: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type EffectInstance =
|
export type EffectInstance =
|
||||||
| FogInstance
|
| FogInstance
|
||||||
| FireInstance
|
| FireInstance
|
||||||
| RainInstance
|
| RainInstance
|
||||||
| WaterInstance
|
| WaterInstance
|
||||||
|
| DarknessInstance
|
||||||
| LightningInstance
|
| LightningInstance
|
||||||
| SunbeamInstance
|
| SunbeamInstance
|
||||||
| PoisonCloudInstance
|
| PoisonCloudInstance
|
||||||
| FreezeInstance
|
| FreezeInstance
|
||||||
| ScorchInstance
|
| ScorchInstance
|
||||||
| IceInstance;
|
| IceInstance
|
||||||
|
| ShadowInstance;
|
||||||
|
|
||||||
export type EffectToolState = {
|
export type EffectToolState = {
|
||||||
tool: EffectToolType;
|
tool: EffectToolType;
|
||||||
|
|||||||
Reference in New Issue
Block a user