diff --git a/app/main/effects/effectsStore.test.ts b/app/main/effects/effectsStore.test.ts new file mode 100644 index 0000000..a089d0e --- /dev/null +++ b/app/main/effects/effectsStore.test.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { EffectsStore } from './effectsStore'; + +void test('pruneExpired: лёд не удаляется по времени', () => { + const store = new EffectsStore(); + const createdAtMs = Date.now() - 365 * 24 * 60 * 60 * 1000; + store.dispatch({ + kind: 'instance.add', + instance: { + id: 'ice_test', + type: 'ice', + seed: 1, + createdAtMs, + at: { x: 0.5, y: 0.5 }, + radiusN: 0.1, + opacity: 0.85, + lifetimeMs: null, + }, + }); + assert.equal(store.getState().instances.length, 1); + assert.equal(store.pruneExpired(), false); + assert.equal(store.getState().instances.length, 1); +}); + +void test('pruneExpired: молния удаляется после lifetime', () => { + const store = new EffectsStore(); + store.dispatch({ + kind: 'instance.add', + instance: { + id: 'lt_test', + type: 'lightning', + seed: 1, + createdAtMs: Date.now() - 10_000, + start: { x: 0, y: 0 }, + end: { x: 0.5, y: 0.5 }, + widthN: 0.05, + intensity: 1, + lifetimeMs: 1000, + }, + }); + assert.equal(store.pruneExpired(), true); + assert.equal(store.getState().instances.length, 0); +}); + +void test('pruneExpired: луч света удаляется после lifetime', () => { + const store = new EffectsStore(); + store.dispatch({ + kind: 'instance.add', + instance: { + id: 'sb_test', + type: 'sunbeam', + seed: 1, + createdAtMs: Date.now() - 10_000, + start: { x: 0.5, y: 0 }, + end: { x: 0.5, y: 0.6 }, + widthN: 0.04, + intensity: 1, + lifetimeMs: 800, + }, + }); + assert.equal(store.pruneExpired(), true); + assert.equal(store.getState().instances.length, 0); +}); + +void test('pruneExpired: облако яда удаляется после lifetime', () => { + const store = new EffectsStore(); + store.dispatch({ + kind: 'instance.add', + instance: { + id: 'pc_test', + type: 'poisonCloud', + seed: 1, + createdAtMs: Date.now() - 20_000, + at: { x: 0.5, y: 0.5 }, + radiusN: 0.08, + intensity: 1, + lifetimeMs: 3000, + }, + }); + assert.equal(store.pruneExpired(), true); + assert.equal(store.getState().instances.length, 0); +}); diff --git a/app/main/effects/effectsStore.ts b/app/main/effects/effectsStore.ts index 98ae763..22b97ee 100644 --- a/app/main/effects/effectsStore.ts +++ b/app/main/effects/effectsStore.ts @@ -45,13 +45,15 @@ export class EffectsStore { const now = nowMs(); const before = this.state.instances.length; const kept = this.state.instances.filter((i) => { - if (i.type === 'lightning') { + // Пятно льда не истекает по таймеру (только «очистить все» или ластик в UI). + if (i.type === 'ice') return true; + if (i.type === 'lightning' || i.type === 'sunbeam' || i.type === 'poisonCloud') { return now - i.createdAtMs < i.lifetimeMs; } if (i.type === 'scorch') { return now - i.createdAtMs < i.lifetimeMs; } - if (i.type === 'fog') { + if (i.type === 'fog' || i.type === 'water') { if (i.lifetimeMs === null) return true; return now - i.createdAtMs < i.lifetimeMs; } diff --git a/app/main/windows/createWindows.editorClose.test.ts b/app/main/windows/createWindows.editorClose.test.ts index 36fc993..ce93bf2 100644 --- a/app/main/windows/createWindows.editorClose.test.ts +++ b/app/main/windows/createWindows.editorClose.test.ts @@ -27,3 +27,9 @@ void test('createWindows: иконка окна (pack PNG, затем window PNG assert.ok(src.includes('app-window-icon.png')); assert.ok(src.includes('app-logo.svg')); }); + +void test('createWindows: пульт поверх экрана просмотра (дочернее окно)', () => { + const src = readCreateWindows(); + assert.ok(src.includes('parent: presentation')); + assert.ok(src.includes("createWindow('control'")); +}); diff --git a/app/main/windows/createWindows.ts b/app/main/windows/createWindows.ts index 7af3427..4398120 100644 --- a/app/main/windows/createWindows.ts +++ b/app/main/windows/createWindows.ts @@ -118,7 +118,12 @@ export function applyDockIconIfNeeded(): void { } } -function createWindow(kind: WindowKind): BrowserWindow { +type CreateWindowOpts = { + /** Дочернее окно (например пульт) держится над родителем (экран просмотра). */ + parent?: BrowserWindow; +}; + +function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow { const icon = resolveWindowIcon(); const win = new BrowserWindow({ width: kind === 'editor' ? 1280 : kind === 'control' ? 1200 : 1280, @@ -126,6 +131,7 @@ function createWindow(kind: WindowKind): BrowserWindow { show: false, backgroundColor: '#09090B', ...(icon ? { icon } : {}), + ...(opts?.parent ? { parent: opts.parent } : {}), webPreferences: { contextIsolation: true, sandbox: true, @@ -175,16 +181,17 @@ export function focusEditorWindow(): void { } export function openMultiWindow() { - if (!windows.has('presentation')) { + let presentation = windows.get('presentation'); + if (!presentation) { const display = screen.getPrimaryDisplay(); const { x, y, width, height } = display.bounds; - const win = createWindow('presentation'); - win.setBounds({ x, y, width, height }); - win.setMenuBarVisibility(false); - win.maximize(); + presentation = createWindow('presentation'); + presentation.setBounds({ x, y, width, height }); + presentation.setMenuBarVisibility(false); + presentation.maximize(); } if (!windows.has('control')) { - createWindow('control'); + createWindow('control', { parent: presentation }); } } diff --git a/app/renderer/control/ControlApp.module.css b/app/renderer/control/ControlApp.module.css index 8a8245e..b161c07 100644 --- a/app/renderer/control/ControlApp.module.css +++ b/app/renderer/control/ControlApp.module.css @@ -42,7 +42,20 @@ .effectsStack { display: flex; flex-direction: column; - gap: 10px; + gap: 12px; +} + +.effectsGroup { + display: flex; + flex-direction: column; + gap: 8px; +} + +.subsectionLabel { + font-size: var(--text-xs); + font-weight: 700; + letter-spacing: 0.02em; + color: var(--text2); } .iconRow { diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index ba5841f..1717f35 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -12,6 +12,12 @@ import { Surface } from '../shared/ui/Surface'; import styles from './ControlApp.module.css'; import { ControlScenePreview } from './ControlScenePreview'; +import { getFreezeEffectLifeMs, playFreezeEffectSound } from './freezeSfx'; +import { getPoisonCloudEffectLifeMs, playPoisonCloudEffectSound } from './poisonCloudSfx'; +import { getSunbeamEffectLifeMs, playSunbeamEffectSound } from './sunbeamSfx'; + +/** Длительность визуала молнии (мс). */ +const LIGHTNING_EFFECT_MS = 180; function formatTime(sec: number): string { if (!Number.isFinite(sec) || sec < 0) return '0:00'; @@ -21,6 +27,21 @@ function formatTime(sec: number): string { return `${String(m)}:${String(r).padStart(2, '0')}`; } +/** Файл из `app/renderer/public/molniya.mp3` — рядом с `control.html` в dev и в dist. */ +function lightningEffectSoundUrl(): string { + return new URL('molniya.mp3', window.location.href).href; +} + +function playLightningEffectSound(): void { + try { + const el = new Audio(lightningEffectSoundUrl()); + el.volume = 0.88; + void el.play().catch(() => undefined); + } catch { + /* ignore */ + } +} + export function ControlApp() { const api = getDndApi(); const [fxState, fx] = useEffectsState(); @@ -36,7 +57,7 @@ export function ControlApp() { const previewHostRef = useRef(null); const previewVideoRef = useRef(null); const brushRef = useRef<{ - tool: 'fog' | 'fire' | 'rain' | 'lightning' | 'freeze' | 'eraser'; + tool: 'fog' | 'fire' | 'rain' | 'water' | 'lightning' | 'sunbeam' | 'poisonCloud' | 'freeze' | 'eraser'; startN?: { x: number; y: number }; points?: { x: number; y: number; tMs: number }[]; } | null>(null); @@ -89,6 +110,21 @@ export function ControlApp() { }; }, []); + const [freezeDraftLifeMs, setFreezeDraftLifeMs] = useState(820); + useEffect(() => { + void getFreezeEffectLifeMs().then(setFreezeDraftLifeMs); + }, []); + + const [sunbeamDraftLifeMs, setSunbeamDraftLifeMs] = useState(600); + useEffect(() => { + void getSunbeamEffectLifeMs().then(setSunbeamDraftLifeMs); + }, []); + + const [poisonDraftLifeMs, setPoisonDraftLifeMs] = useState(1600); + useEffect(() => { + void getPoisonCloudEffectLifeMs().then(setPoisonDraftLifeMs); + }, []); + const project = session?.project ?? null; const currentGraphNodeId = project?.currentGraphNodeId ?? null; const currentScene = @@ -388,6 +424,21 @@ export function ControlApp() { }, }); } + if (b.tool === 'water' && b.points && b.points.length > 0) { + await fx.dispatch({ + kind: 'instance.add', + instance: { + id: `water_${String(createdAtMs)}_${String(seed)}`, + type: 'water', + seed, + createdAtMs, + points: b.points, + radiusN: tool.radiusN, + opacity: Math.max(0.06, Math.min(0.72, tool.intensity * 0.85)), + lifetimeMs: null, + }, + }); + } if (b.tool === 'lightning' && b.startN && b.points && b.points.length > 0) { const last = b.points[b.points.length - 1]; if (last === undefined) return; @@ -404,7 +455,7 @@ export function ControlApp() { end, widthN: Math.max(0.01, tool.radiusN * 0.9), intensity: Math.max(0.9, Math.min(1.2, tool.intensity * 1.35)), - lifetimeMs: 180, + lifetimeMs: LIGHTNING_EFFECT_MS, }, }); await fx.dispatch({ @@ -420,11 +471,55 @@ export function ControlApp() { lifetimeMs: 60_000, }, }); + playLightningEffectSound(); + } + if (b.tool === 'sunbeam' && b.startN && b.points && b.points.length > 0) { + const last = b.points[b.points.length - 1]; + if (last === undefined) return; + const end = { x: last.x, y: last.y }; + const start = { x: end.x, y: 0 }; + const sunbeamLifeMs = await getSunbeamEffectLifeMs(); + await fx.dispatch({ + kind: 'instance.add', + instance: { + id: `sb_${String(createdAtMs)}_${String(seed)}`, + type: 'sunbeam', + seed, + createdAtMs, + start, + end, + widthN: Math.max(0.012, tool.radiusN * 0.95), + intensity: Math.max(0.95, Math.min(1.25, tool.intensity * 1.4)), + lifetimeMs: sunbeamLifeMs, + }, + }); + playSunbeamEffectSound(); + } + if (b.tool === 'poisonCloud' && 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 poisonLifeMs = await getPoisonCloudEffectLifeMs(); + await fx.dispatch({ + kind: 'instance.add', + instance: { + id: `pc_${String(createdAtMs)}_${String(seed)}`, + type: 'poisonCloud', + seed, + createdAtMs, + at, + radiusN: Math.max(0.03, tool.radiusN * 0.95), + intensity: Math.max(0.75, Math.min(1.2, tool.intensity * 1.15)), + lifetimeMs: poisonLifeMs, + }, + }); + void playPoisonCloudEffectSound(poisonLifeMs); } if (b.tool === 'freeze' && 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 freezeLifeMs = await getFreezeEffectLifeMs(); await fx.dispatch({ kind: 'instance.add', instance: { @@ -434,8 +529,8 @@ export function ControlApp() { createdAtMs, at, intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)), - // Быстро появиться → чуть задержаться → плавно исчезнуть. - lifetimeMs: 820, + // Длительность как у zamorozka.mp3 (фазы «замерзания» в PxiEffectsOverlay масштабируются по life). + lifetimeMs: freezeLifeMs, }, }); await fx.dispatch({ @@ -448,9 +543,10 @@ export function ControlApp() { at, radiusN: Math.max(0.03, tool.radiusN * 0.9), opacity: 0.85, - lifetimeMs: 60_000, + lifetimeMs: null, }, }); + playFreezeEffectSound(); } brushRef.current = null; setDraftFxTick((x) => x + 1); @@ -496,6 +592,18 @@ export function ControlApp() { lifetimeMs: null, }; } + if (b.tool === 'water' && b.points && b.points.length > 0) { + return { + id: '__draft__', + type: 'water' as const, + seed, + createdAtMs, + points: b.points, + radiusN: tool.radiusN, + opacity: Math.max(0.06, Math.min(0.55, tool.intensity * 0.72)), + lifetimeMs: null, + }; + } if (b.tool === 'lightning' && b.startN && b.points && b.points.length > 0) { const last = b.points[b.points.length - 1]; if (last === undefined) return null; @@ -508,7 +616,36 @@ export function ControlApp() { end: { x: last.x, y: last.y }, widthN: Math.max(0.01, tool.radiusN * 0.9), intensity: Math.max(0.9, Math.min(1.2, tool.intensity * 1.35)), - lifetimeMs: 180, + lifetimeMs: LIGHTNING_EFFECT_MS, + }; + } + if (b.tool === 'sunbeam' && b.startN && b.points && b.points.length > 0) { + const last = b.points[b.points.length - 1]; + if (last === undefined) return null; + return { + id: '__draft__', + type: 'sunbeam' as const, + seed, + createdAtMs, + start: { x: last.x, y: 0 }, + end: { x: last.x, y: last.y }, + widthN: Math.max(0.012, tool.radiusN * 0.95), + intensity: Math.max(0.95, Math.min(1.25, tool.intensity * 1.4)), + lifetimeMs: sunbeamDraftLifeMs, + }; + } + if (b.tool === 'poisonCloud' && b.points && b.points.length > 0) { + const last = b.points[b.points.length - 1]; + if (last === undefined) return null; + return { + id: '__draft__', + type: 'poisonCloud' as const, + seed, + createdAtMs, + at: { x: last.x, y: last.y }, + radiusN: Math.max(0.03, tool.radiusN * 0.95), + intensity: Math.max(0.75, Math.min(1.2, tool.intensity * 1.15)), + lifetimeMs: poisonDraftLifeMs, }; } if (b.tool === 'freeze' && b.points && b.points.length > 0) { @@ -521,12 +658,20 @@ export function ControlApp() { createdAtMs, at: { x: last.x, y: last.y }, intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)), - lifetimeMs: 240, + lifetimeMs: freezeDraftLifeMs, }; } return null; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [draftFxTick, tool.intensity, tool.radiusN, tool.tool]); + }, [ + draftFxTick, + freezeDraftLifeMs, + poisonDraftLifeMs, + sunbeamDraftLifeMs, + tool.intensity, + tool.radiusN, + tool.tool, + ]); const fxMergedState = useMemo(() => { if (!fxState) return null; @@ -544,83 +689,127 @@ export function ControlApp() {
ЭФФЕКТЫ
-
- - - - - - - +
+
Инструменты
+
+ + +
+
+
+
Эффекты поля
+
+ + + + +
+
+
+
Эффекты действий
+
+ + + + +
Радиус кисти
diff --git a/app/renderer/control/controlApp.effectsPanel.test.ts b/app/renderer/control/controlApp.effectsPanel.test.ts index d585262..03f96c0 100644 --- a/app/renderer/control/controlApp.effectsPanel.test.ts +++ b/app/renderer/control/controlApp.effectsPanel.test.ts @@ -14,9 +14,46 @@ function readControlAppCss(): string { return fs.readFileSync(path.join(here, 'ControlApp.module.css'), 'utf8'); } +void test('ControlApp: звук молнии (public/molniya.mp3)', () => { + const src = readControlApp(); + assert.ok(src.includes('molniya.mp3')); + assert.ok(src.includes('playLightningEffectSound')); +}); + +void test('ControlApp: звук заморозки (public/zamorozka.mp3)', () => { + const src = readControlApp(); + assert.ok(src.includes('zamorozka.mp3')); + assert.ok(src.includes('playFreezeEffectSound')); +}); + +void test('ControlApp: звук луча света (public/luch_sveta.mp3)', () => { + const appSrc = readControlApp(); + const sfxSrc = fs.readFileSync(path.join(here, 'sunbeamSfx.ts'), 'utf8'); + assert.ok(appSrc.includes('playSunbeamEffectSound')); + assert.ok(appSrc.includes('getSunbeamEffectLifeMs')); + assert.ok(sfxSrc.includes('luch_sveta.mp3')); + assert.ok(sfxSrc.includes('playbackRate')); + assert.ok(sfxSrc.includes('SUNBEAM_PLAYBACK_RATE')); +}); + +void test('ControlApp: звук облака яда (public/oblako-yada.mp3)', () => { + const appSrc = readControlApp(); + const sfxSrc = fs.readFileSync(path.join(here, 'poisonCloudSfx.ts'), 'utf8'); + assert.ok(appSrc.includes('getPoisonCloudEffectLifeMs')); + assert.ok(appSrc.includes('playPoisonCloudEffectSound')); + assert.ok(sfxSrc.includes('oblako-yada.mp3')); + assert.ok(sfxSrc.includes('playbackRate')); +}); + void test('ControlApp: эффекты в пульте, иконки с тултипами и подписью для a11y', () => { const src = readControlApp(); assert.ok(src.includes('ЭФФЕКТЫ')); + assert.ok(src.includes('Инструменты')); + assert.ok(src.includes('Эффекты поля')); + assert.ok(src.includes('Эффекты действий')); + assert.ok(src.includes('Луч света')); + assert.ok(src.includes('title="Вода"')); + assert.ok(src.includes('title="Облако яда"')); assert.ok(src.includes('title="Туман"')); assert.ok(src.includes('ariaLabel="Туман"')); assert.ok(src.includes('iconOnly')); diff --git a/app/renderer/control/freezeSfx.ts b/app/renderer/control/freezeSfx.ts new file mode 100644 index 0000000..7f5d7b3 --- /dev/null +++ b/app/renderer/control/freezeSfx.ts @@ -0,0 +1,52 @@ +/** Звук и длительность эффекта «Заморозка» (`public/zamorozka.mp3`). */ + +const DEFAULT_FREEZE_LIFE_MS = 820; + +const FREEZE_SFX_BASE_VOLUME = 0.88; +/** На 25% тише базовой громкости эффекта. */ +export const FREEZE_SFX_VOLUME = FREEZE_SFX_BASE_VOLUME * 0.75; + +export function freezeEffectSoundUrl(): string { + return new URL('zamorozka.mp3', window.location.href).href; +} + +let cachedFreezeSfxDurationMs: number | null = null; + +/** Длительность трека в мс (кэш после первого чтения метаданных). */ +export function getFreezeSfxDurationMs(): Promise { + if (cachedFreezeSfxDurationMs !== null) { + return Promise.resolve(cachedFreezeSfxDurationMs); + } + const url = freezeEffectSoundUrl(); + return new Promise((resolve) => { + const a = new Audio(); + const done = (ms: number): void => { + cachedFreezeSfxDurationMs = ms; + a.removeAttribute('src'); + resolve(ms); + }; + a.addEventListener('loadedmetadata', () => { + const d = a.duration; + done(Number.isFinite(d) && d > 0 ? Math.round(d * 1000) : DEFAULT_FREEZE_LIFE_MS); + }); + a.addEventListener('error', () => done(DEFAULT_FREEZE_LIFE_MS)); + a.src = url; + a.load(); + }); +} + +/** Длительность полноэкранной заморозки и льда — по времени звука, с разумными пределами. */ +export async function getFreezeEffectLifeMs(): Promise { + const raw = await getFreezeSfxDurationMs(); + return Math.min(45_000, Math.max(400, raw)); +} + +export function playFreezeEffectSound(): void { + try { + const el = new Audio(freezeEffectSoundUrl()); + el.volume = FREEZE_SFX_VOLUME; + void el.play().catch(() => undefined); + } catch { + /* ignore */ + } +} diff --git a/app/renderer/control/poisonCloudSfx.ts b/app/renderer/control/poisonCloudSfx.ts new file mode 100644 index 0000000..493e892 --- /dev/null +++ b/app/renderer/control/poisonCloudSfx.ts @@ -0,0 +1,59 @@ +/** Звук и длительность эффекта «Облако яда» (`public/oblako-yada.mp3`). */ + +const POISON_CLOUD_SFX_VOLUME = 0.92; + +/** Запас, если метаданные не прочитались. */ +const DEFAULT_POISON_CLOUD_LIFE_MS = 1600; + +export function poisonCloudEffectSoundUrl(): string { + return new URL('oblako-yada.mp3', window.location.href).href; +} + +let cachedPoisonCloudSfxDurationMs: number | null = null; + +/** Длительность трека в мс (кэш после первого чтения метаданных). */ +export function getPoisonCloudSfxDurationMs(): Promise { + if (cachedPoisonCloudSfxDurationMs !== null) { + return Promise.resolve(cachedPoisonCloudSfxDurationMs); + } + const url = poisonCloudEffectSoundUrl(); + return new Promise((resolve) => { + const a = new Audio(); + const done = (ms: number): void => { + cachedPoisonCloudSfxDurationMs = ms; + a.removeAttribute('src'); + resolve(ms); + }; + a.addEventListener('loadedmetadata', () => { + const d = a.duration; + done(Number.isFinite(d) && d > 0 ? Math.round(d * 1000) : DEFAULT_POISON_CLOUD_LIFE_MS); + }); + a.addEventListener('error', () => done(DEFAULT_POISON_CLOUD_LIFE_MS)); + a.src = url; + a.load(); + }); +} + +/** Длительность визуала = длительности файла (с разумными пределами). */ +export async function getPoisonCloudEffectLifeMs(): Promise { + const raw = await getPoisonCloudSfxDurationMs(); + return Math.min(60_000, Math.max(600, raw)); +} + +/** + * Воспроизведение с подгонкой скорости: фактическое время звука по часам ≈ `lifeMs`, + * чтобы совпасть с анимацией. + */ +export async function playPoisonCloudEffectSound(lifeMs: number): Promise { + try { + const rawMs = await getPoisonCloudSfxDurationMs(); + const target = Math.max(200, lifeMs); + const rate = Math.max(0.25, Math.min(4, rawMs / target)); + const el = new Audio(poisonCloudEffectSoundUrl()); + el.volume = POISON_CLOUD_SFX_VOLUME; + el.playbackRate = rate; + void el.play().catch(() => undefined); + } catch { + /* ignore */ + } +} diff --git a/app/renderer/control/sunbeamSfx.ts b/app/renderer/control/sunbeamSfx.ts new file mode 100644 index 0000000..6b81b78 --- /dev/null +++ b/app/renderer/control/sunbeamSfx.ts @@ -0,0 +1,56 @@ +/** Звук эффекта «Луч света» (`public/luch_sveta.mp3`). */ + +const SUNBEAM_SFX_VOLUME = 0.88; +/** Воспроизведение на 50% быстрее → реальная длительность = файл / 1.5. */ +export const SUNBEAM_PLAYBACK_RATE = 1.5; + +const DEFAULT_SUNBEAM_LIFE_MS = 600; + +export function sunbeamEffectSoundUrl(): string { + return new URL('luch_sveta.mp3', window.location.href).href; +} + +let cachedSunbeamSfxDurationMs: number | null = null; + +/** Длительность файла в мс (как в метаданных, без ускорения). */ +export function getSunbeamSfxDurationMs(): Promise { + if (cachedSunbeamSfxDurationMs !== null) { + return Promise.resolve(cachedSunbeamSfxDurationMs); + } + const url = sunbeamEffectSoundUrl(); + return new Promise((resolve) => { + const a = new Audio(); + const done = (ms: number): void => { + cachedSunbeamSfxDurationMs = ms; + a.removeAttribute('src'); + resolve(ms); + }; + a.addEventListener('loadedmetadata', () => { + const d = a.duration; + done(Number.isFinite(d) && d > 0 ? Math.round(d * 1000) : DEFAULT_SUNBEAM_LIFE_MS); + }); + a.addEventListener('error', () => done(DEFAULT_SUNBEAM_LIFE_MS)); + a.src = url; + a.load(); + }); +} + +/** + * Длительность визуала луча = время проигрывания при ускорении 1.5× (совпадает со звуком). + */ +export async function getSunbeamEffectLifeMs(): Promise { + const raw = await getSunbeamSfxDurationMs(); + const wallMs = raw / SUNBEAM_PLAYBACK_RATE; + return Math.min(60_000, Math.max(400, Math.round(wallMs))); +} + +export function playSunbeamEffectSound(): void { + try { + const el = new Audio(sunbeamEffectSoundUrl()); + el.volume = SUNBEAM_SFX_VOLUME; + el.playbackRate = SUNBEAM_PLAYBACK_RATE; + void el.play().catch(() => undefined); + } catch { + /* ignore */ + } +} diff --git a/app/renderer/public/luch_sveta.mp3 b/app/renderer/public/luch_sveta.mp3 new file mode 100644 index 0000000..a2f915f Binary files /dev/null and b/app/renderer/public/luch_sveta.mp3 differ diff --git a/app/renderer/public/molniya.mp3 b/app/renderer/public/molniya.mp3 new file mode 100644 index 0000000..65ea43c Binary files /dev/null and b/app/renderer/public/molniya.mp3 differ diff --git a/app/renderer/public/oblako-yada.mp3 b/app/renderer/public/oblako-yada.mp3 new file mode 100644 index 0000000..3c318bf Binary files /dev/null and b/app/renderer/public/oblako-yada.mp3 differ diff --git a/app/renderer/public/zamorozka.mp3 b/app/renderer/public/zamorozka.mp3 new file mode 100644 index 0000000..e17df09 Binary files /dev/null and b/app/renderer/public/zamorozka.mp3 differ diff --git a/app/renderer/shared/effects/PxiEffectsOverlay.tsx b/app/renderer/shared/effects/PxiEffectsOverlay.tsx index 70aacf3..1711f1a 100644 --- a/app/renderer/shared/effects/PxiEffectsOverlay.tsx +++ b/app/renderer/shared/effects/PxiEffectsOverlay.tsx @@ -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): number { + let h = 2166136261 >>> 0; + for (const p of inst.points) { + if (!p) continue; + h ^= Math.round(p.x * 10000); + h = Math.imul(h, 16777619); + h ^= Math.round(p.y * 10000); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} + +function redrawWaterDraft( + g: any, + inst: Extract, + viewport: { x: number; y: number; w: number; h: number }, + halfW: number, +) { + const { x: vx, y: vy, w, h } = viewport; + g.clear(); + const pts = inst.points; + if (pts.length === 0) return; + const px = pts.map((p) => ({ x: vx + p.x * w, y: vy + p.y * h })); + // Толщина как у итоговой заливки: диаметр = 2 × радиус кисти (см. buildWaterFillSprite). + const lineW = Math.max(2, halfW * 2); + if (px.length === 1) { + const p0 = px[0]; + if (!p0) return; + g.circle(p0.x, p0.y, Math.max(2, halfW)); + g.fill({ color: 0x5fc3ff, alpha: 0.42 }); + return; + } + const pStart = px[0]; + if (!pStart) return; + g.moveTo(pStart.x, pStart.y); + for (let i = 1; i < px.length; i += 1) { + const pi = px[i]; + if (!pi) continue; + g.lineTo(pi.x, pi.y); + } + g.stroke({ width: lineW, color: 0x5fc3ff, alpha: 0.48, cap: 'round', join: 'round' }); +} + +function buildWaterFillSprite( + pixi: any, + inst: Extract, + viewport: { x: number; y: number; w: number; h: number }, + halfW: number, +): { sprite: any } | 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, + 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, + 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 | 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); diff --git a/app/shared/effectEraserHitTest.test.ts b/app/shared/effectEraserHitTest.test.ts index fee4245..9eb5872 100644 --- a/app/shared/effectEraserHitTest.test.ts +++ b/app/shared/effectEraserHitTest.test.ts @@ -20,6 +20,20 @@ void test('pickEraseTargetId: fire/rain по штриху как туман', () assert.equal(id, 'f1'); }); +void test('pickEraseTargetId: вода по штриху как туман', () => { + const water: EffectInstance = { + ...base, + id: 'w1', + type: 'water', + points: [{ x: 0.4, y: 0.55, tMs: 0 }], + radiusN: 0.06, + opacity: 0.5, + lifetimeMs: null, + }; + const id = pickEraseTargetId([water], { x: 0.41, y: 0.55 }, 0.05); + assert.equal(id, 'w1'); +}); + void test('minDistSqEffectToPoint: молния — расстояние до отрезка', () => { const bolt: EffectInstance = { ...base, @@ -37,6 +51,23 @@ void test('minDistSqEffectToPoint: молния — расстояние до о assert.equal(end, 0); }); +void test('minDistSqEffectToPoint: луч света — как у молнии, отрезок', () => { + const beam: EffectInstance = { + ...base, + id: 'S1', + type: 'sunbeam', + start: { x: 0.5, y: 0 }, + end: { x: 0.5, y: 0.8 }, + widthN: 0.04, + intensity: 1, + lifetimeMs: 220, + }; + const onBeam = minDistSqEffectToPoint(beam, { x: 0.5, y: 0.4 }); + assert.equal(onBeam, 0); + const aside = minDistSqEffectToPoint(beam, { x: 0.52, y: 0.4 }); + assert.ok(aside > 0 && aside < 0.01); +}); + void test('pickEraseTargetId: scorch с учётом inst.radiusN', () => { const sc: EffectInstance = { ...base, diff --git a/app/shared/effectEraserHitTest.ts b/app/shared/effectEraserHitTest.ts index f4fc895..19c622b 100644 --- a/app/shared/effectEraserHitTest.ts +++ b/app/shared/effectEraserHitTest.ts @@ -30,7 +30,8 @@ export function minDistSqEffectToPoint(inst: EffectInstance, p: { x: number; y: switch (inst.type) { case 'fog': case 'fire': - case 'rain': { + case 'rain': + case 'water': { let best = Number.POSITIVE_INFINITY; for (const q of inst.points) { const dx = q.x - p.x; @@ -40,6 +41,7 @@ export function minDistSqEffectToPoint(inst: EffectInstance, p: { x: number; y: return best; } case 'lightning': + case 'sunbeam': return distSqPointToSegment(p.x, p.y, inst.start.x, inst.start.y, inst.end.x, inst.end.y); case 'freeze': { const dx = inst.at.x - p.x; @@ -47,7 +49,8 @@ export function minDistSqEffectToPoint(inst: EffectInstance, p: { x: number; y: return dx * dx + dy * dy; } case 'scorch': - case 'ice': { + case 'ice': + case 'poisonCloud': { const dx = inst.at.x - p.x; const dy = inst.at.y - p.y; return dx * dx + dy * dy; @@ -58,7 +61,7 @@ export function minDistSqEffectToPoint(inst: EffectInstance, p: { x: number; y: } function eraseHitThresholdSq(inst: EffectInstance, toolRadiusN: number): number { - if (inst.type === 'scorch' || inst.type === 'ice') { + if (inst.type === 'scorch' || inst.type === 'ice' || inst.type === 'poisonCloud') { const r = toolRadiusN + inst.radiusN; return r * r; } diff --git a/app/shared/types/effects.ts b/app/shared/types/effects.ts index e72dc42..01e4e01 100644 --- a/app/shared/types/effects.ts +++ b/app/shared/types/effects.ts @@ -1,6 +1,25 @@ -export type EffectToolType = 'fog' | 'fire' | 'rain' | 'lightning' | 'freeze' | 'eraser'; +export type EffectToolType = + | 'fog' + | 'fire' + | 'rain' + | 'water' + | 'lightning' + | 'sunbeam' + | 'poisonCloud' + | 'freeze' + | 'eraser'; -export type EffectInstanceType = 'fog' | 'fire' | 'rain' | 'lightning' | 'freeze' | 'scorch' | 'ice'; +export type EffectInstanceType = + | 'fog' + | 'fire' + | 'rain' + | 'water' + | 'lightning' + | 'sunbeam' + | 'poisonCloud' + | 'freeze' + | 'scorch' + | 'ice'; /** Нормализованные координаты (0..1) относительно области предпросмотра/презентации. */ export type NPoint = { x: number; y: number; tMs: number; pressure?: number }; @@ -38,6 +57,14 @@ export type RainInstance = EffectInstanceBase & { lifetimeMs: number | null; }; +export type WaterInstance = EffectInstanceBase & { + type: 'water'; + points: NPoint[]; + radiusN: number; + opacity: number; + lifetimeMs: number | null; +}; + export type LightningInstance = EffectInstanceBase & { type: 'lightning'; start: { x: number; y: number }; @@ -47,6 +74,25 @@ export type LightningInstance = EffectInstanceBase & { lifetimeMs: number; }; +/** Прямой луч сверху к точке удара (как у молнии геометрия штриха, другой визуал). */ +export type SunbeamInstance = EffectInstanceBase & { + type: 'sunbeam'; + start: { x: number; y: number }; + end: { x: number; y: number }; + widthN: number; + intensity: number; + lifetimeMs: number; +}; + +/** «Облако яда» — ядерный гриб в точке удара. */ +export type PoisonCloudInstance = EffectInstanceBase & { + type: 'poisonCloud'; + at: { x: number; y: number }; + radiusN: number; + intensity: number; + lifetimeMs: number; +}; + export type FreezeInstance = EffectInstanceBase & { type: 'freeze'; at: { x: number; y: number }; @@ -69,14 +115,18 @@ export type IceInstance = EffectInstanceBase & { at: { x: number; y: number }; radiusN: number; opacity: number; - lifetimeMs: number; + /** `null` — пятно не истекает по времени (снимается только «очистить» или ластик). */ + lifetimeMs: number | null; }; export type EffectInstance = | FogInstance | FireInstance | RainInstance + | WaterInstance | LightningInstance + | SunbeamInstance + | PoisonCloudInstance | FreezeInstance | ScorchInstance | IceInstance; diff --git a/package.json b/package.json index f77d0fd..eda230a 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "build:obfuscate": "node scripts/build.mjs --production --obfuscate", "lint": "eslint . --max-warnings 0", "typecheck": "tsc -p tsconfig.eslint.json --noEmit", - "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/project/assetPrune.test.ts app/main/project/zipRead.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/main/license/verifyLicenseToken.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs", + "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/effects/effectsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/zipRead.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/main/license/verifyLicenseToken.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs", "format": "prettier . --check", "format:write": "prettier . --write", "release:info": "node scripts/print-release-info.mjs",