feat(effects): вода, облако яда, луч света; пульт и окна демонстрации
- Поле: вода (сплошная заливка по штриху, превью кистью), туман/огонь/дождь без изменений логики. - Действия: облако яда (частицы, круглая текстура, звук oblako-yada.mp3, длительность как у трека), луч света и заморозка со звуками из public/. - Пульт: инструменты воды и яда, синхрон SFX, тесты панели и ластика. - Окно управления: дочернее от окна просмотра (Z-order). - Типы эффектов, effectsStore prune, hit-test ластика. Made-with: Cursor
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
@@ -45,13 +45,15 @@ export class EffectsStore {
|
|||||||
const now = nowMs();
|
const now = nowMs();
|
||||||
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) => {
|
||||||
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;
|
return now - i.createdAtMs < i.lifetimeMs;
|
||||||
}
|
}
|
||||||
if (i.type === 'scorch') {
|
if (i.type === 'scorch') {
|
||||||
return now - i.createdAtMs < i.lifetimeMs;
|
return now - i.createdAtMs < i.lifetimeMs;
|
||||||
}
|
}
|
||||||
if (i.type === 'fog') {
|
if (i.type === 'fog' || i.type === 'water') {
|
||||||
if (i.lifetimeMs === null) return true;
|
if (i.lifetimeMs === null) return true;
|
||||||
return now - i.createdAtMs < i.lifetimeMs;
|
return now - i.createdAtMs < i.lifetimeMs;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,3 +27,9 @@ void test('createWindows: иконка окна (pack PNG, затем window PNG
|
|||||||
assert.ok(src.includes('app-window-icon.png'));
|
assert.ok(src.includes('app-window-icon.png'));
|
||||||
assert.ok(src.includes('app-logo.svg'));
|
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'"));
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 icon = resolveWindowIcon();
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
width: kind === 'editor' ? 1280 : kind === 'control' ? 1200 : 1280,
|
width: kind === 'editor' ? 1280 : kind === 'control' ? 1200 : 1280,
|
||||||
@@ -126,6 +131,7 @@ function createWindow(kind: WindowKind): BrowserWindow {
|
|||||||
show: false,
|
show: false,
|
||||||
backgroundColor: '#09090B',
|
backgroundColor: '#09090B',
|
||||||
...(icon ? { icon } : {}),
|
...(icon ? { icon } : {}),
|
||||||
|
...(opts?.parent ? { parent: opts.parent } : {}),
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
sandbox: true,
|
sandbox: true,
|
||||||
@@ -175,16 +181,17 @@ export function focusEditorWindow(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function openMultiWindow() {
|
export function openMultiWindow() {
|
||||||
if (!windows.has('presentation')) {
|
let presentation = windows.get('presentation');
|
||||||
|
if (!presentation) {
|
||||||
const display = screen.getPrimaryDisplay();
|
const display = screen.getPrimaryDisplay();
|
||||||
const { x, y, width, height } = display.bounds;
|
const { x, y, width, height } = display.bounds;
|
||||||
const win = createWindow('presentation');
|
presentation = createWindow('presentation');
|
||||||
win.setBounds({ x, y, width, height });
|
presentation.setBounds({ x, y, width, height });
|
||||||
win.setMenuBarVisibility(false);
|
presentation.setMenuBarVisibility(false);
|
||||||
win.maximize();
|
presentation.maximize();
|
||||||
}
|
}
|
||||||
if (!windows.has('control')) {
|
if (!windows.has('control')) {
|
||||||
createWindow('control');
|
createWindow('control', { parent: presentation });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,20 @@
|
|||||||
.effectsStack {
|
.effectsStack {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.iconRow {
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ import { Surface } from '../shared/ui/Surface';
|
|||||||
|
|
||||||
import styles from './ControlApp.module.css';
|
import styles from './ControlApp.module.css';
|
||||||
import { ControlScenePreview } from './ControlScenePreview';
|
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 {
|
function formatTime(sec: number): string {
|
||||||
if (!Number.isFinite(sec) || sec < 0) return '0:00';
|
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')}`;
|
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() {
|
export function ControlApp() {
|
||||||
const api = getDndApi();
|
const api = getDndApi();
|
||||||
const [fxState, fx] = useEffectsState();
|
const [fxState, fx] = useEffectsState();
|
||||||
@@ -36,7 +57,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' | 'lightning' | 'freeze' | 'eraser';
|
tool: 'fog' | 'fire' | 'rain' | 'water' | '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);
|
||||||
@@ -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 project = session?.project ?? null;
|
||||||
const currentGraphNodeId = project?.currentGraphNodeId ?? null;
|
const currentGraphNodeId = project?.currentGraphNodeId ?? null;
|
||||||
const currentScene =
|
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) {
|
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;
|
||||||
@@ -404,7 +455,7 @@ export function ControlApp() {
|
|||||||
end,
|
end,
|
||||||
widthN: Math.max(0.01, tool.radiusN * 0.9),
|
widthN: Math.max(0.01, tool.radiusN * 0.9),
|
||||||
intensity: Math.max(0.9, Math.min(1.2, tool.intensity * 1.35)),
|
intensity: Math.max(0.9, Math.min(1.2, tool.intensity * 1.35)),
|
||||||
lifetimeMs: 180,
|
lifetimeMs: LIGHTNING_EFFECT_MS,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await fx.dispatch({
|
await fx.dispatch({
|
||||||
@@ -420,11 +471,55 @@ export function ControlApp() {
|
|||||||
lifetimeMs: 60_000,
|
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) {
|
if (b.tool === 'freeze' && 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;
|
||||||
const at = { x: last.x, y: last.y };
|
const at = { x: last.x, y: last.y };
|
||||||
|
const freezeLifeMs = await getFreezeEffectLifeMs();
|
||||||
await fx.dispatch({
|
await fx.dispatch({
|
||||||
kind: 'instance.add',
|
kind: 'instance.add',
|
||||||
instance: {
|
instance: {
|
||||||
@@ -434,8 +529,8 @@ export function ControlApp() {
|
|||||||
createdAtMs,
|
createdAtMs,
|
||||||
at,
|
at,
|
||||||
intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)),
|
intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)),
|
||||||
// Быстро появиться → чуть задержаться → плавно исчезнуть.
|
// Длительность как у zamorozka.mp3 (фазы «замерзания» в PxiEffectsOverlay масштабируются по life).
|
||||||
lifetimeMs: 820,
|
lifetimeMs: freezeLifeMs,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await fx.dispatch({
|
await fx.dispatch({
|
||||||
@@ -448,9 +543,10 @@ export function ControlApp() {
|
|||||||
at,
|
at,
|
||||||
radiusN: Math.max(0.03, tool.radiusN * 0.9),
|
radiusN: Math.max(0.03, tool.radiusN * 0.9),
|
||||||
opacity: 0.85,
|
opacity: 0.85,
|
||||||
lifetimeMs: 60_000,
|
lifetimeMs: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
playFreezeEffectSound();
|
||||||
}
|
}
|
||||||
brushRef.current = null;
|
brushRef.current = null;
|
||||||
setDraftFxTick((x) => x + 1);
|
setDraftFxTick((x) => x + 1);
|
||||||
@@ -496,6 +592,18 @@ export function ControlApp() {
|
|||||||
lifetimeMs: null,
|
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) {
|
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;
|
||||||
@@ -508,7 +616,36 @@ export function ControlApp() {
|
|||||||
end: { x: last.x, y: last.y },
|
end: { x: last.x, y: last.y },
|
||||||
widthN: Math.max(0.01, tool.radiusN * 0.9),
|
widthN: Math.max(0.01, tool.radiusN * 0.9),
|
||||||
intensity: Math.max(0.9, Math.min(1.2, tool.intensity * 1.35)),
|
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) {
|
if (b.tool === 'freeze' && b.points && b.points.length > 0) {
|
||||||
@@ -521,12 +658,20 @@ export function ControlApp() {
|
|||||||
createdAtMs,
|
createdAtMs,
|
||||||
at: { x: last.x, y: last.y },
|
at: { x: last.x, y: last.y },
|
||||||
intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)),
|
intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)),
|
||||||
lifetimeMs: 240,
|
lifetimeMs: freezeDraftLifeMs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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(() => {
|
const fxMergedState = useMemo(() => {
|
||||||
if (!fxState) return null;
|
if (!fxState) return null;
|
||||||
@@ -544,52 +689,9 @@ export function ControlApp() {
|
|||||||
<div className={styles.sectionLabel}>ЭФФЕКТЫ</div>
|
<div className={styles.sectionLabel}>ЭФФЕКТЫ</div>
|
||||||
<div className={styles.spacer8} />
|
<div className={styles.spacer8} />
|
||||||
<div className={styles.effectsStack}>
|
<div className={styles.effectsStack}>
|
||||||
|
<div className={styles.effectsGroup}>
|
||||||
|
<div className={styles.subsectionLabel}>Инструменты</div>
|
||||||
<div className={styles.iconRow}>
|
<div className={styles.iconRow}>
|
||||||
<Button
|
|
||||||
variant={tool.tool === 'fog' ? 'primary' : 'ghost'}
|
|
||||||
iconOnly
|
|
||||||
title="Туман"
|
|
||||||
ariaLabel="Туман"
|
|
||||||
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'fog' } })}
|
|
||||||
>
|
|
||||||
<span className={styles.iconGlyph}>🌫️</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={tool.tool === 'fire' ? 'primary' : 'ghost'}
|
|
||||||
iconOnly
|
|
||||||
title="Огонь"
|
|
||||||
ariaLabel="Огонь"
|
|
||||||
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'fire' } })}
|
|
||||||
>
|
|
||||||
<span className={styles.iconGlyph}>🔥</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={tool.tool === 'rain' ? 'primary' : 'ghost'}
|
|
||||||
iconOnly
|
|
||||||
title="Дождь"
|
|
||||||
ariaLabel="Дождь"
|
|
||||||
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'rain' } })}
|
|
||||||
>
|
|
||||||
<span className={styles.iconGlyph}>🌧️</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={tool.tool === 'lightning' ? 'primary' : 'ghost'}
|
|
||||||
iconOnly
|
|
||||||
title="Молния"
|
|
||||||
ariaLabel="Молния"
|
|
||||||
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'lightning' } })}
|
|
||||||
>
|
|
||||||
<span className={styles.iconGlyph}>⚡</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={tool.tool === 'freeze' ? 'primary' : 'ghost'}
|
|
||||||
iconOnly
|
|
||||||
title="Заморозка"
|
|
||||||
ariaLabel="Заморозка"
|
|
||||||
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'freeze' } })}
|
|
||||||
>
|
|
||||||
<span className={styles.iconGlyph}>❄️</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant={tool.tool === 'eraser' ? 'primary' : 'ghost'}
|
variant={tool.tool === 'eraser' ? 'primary' : 'ghost'}
|
||||||
iconOnly
|
iconOnly
|
||||||
@@ -622,6 +724,93 @@ export function ControlApp() {
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.effectsGroup}>
|
||||||
|
<div className={styles.subsectionLabel}>Эффекты поля</div>
|
||||||
|
<div className={styles.iconRow}>
|
||||||
|
<Button
|
||||||
|
variant={tool.tool === 'fog' ? 'primary' : 'ghost'}
|
||||||
|
iconOnly
|
||||||
|
title="Туман"
|
||||||
|
ariaLabel="Туман"
|
||||||
|
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'fog' } })}
|
||||||
|
>
|
||||||
|
<span className={styles.iconGlyph}>🌫️</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={tool.tool === 'rain' ? 'primary' : 'ghost'}
|
||||||
|
iconOnly
|
||||||
|
title="Дождь"
|
||||||
|
ariaLabel="Дождь"
|
||||||
|
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'rain' } })}
|
||||||
|
>
|
||||||
|
<span className={styles.iconGlyph}>🌧️</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={tool.tool === 'fire' ? 'primary' : 'ghost'}
|
||||||
|
iconOnly
|
||||||
|
title="Огонь"
|
||||||
|
ariaLabel="Огонь"
|
||||||
|
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'fire' } })}
|
||||||
|
>
|
||||||
|
<span className={styles.iconGlyph}>🔥</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={tool.tool === 'water' ? 'primary' : 'ghost'}
|
||||||
|
iconOnly
|
||||||
|
title="Вода"
|
||||||
|
ariaLabel="Вода"
|
||||||
|
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'water' } })}
|
||||||
|
>
|
||||||
|
<span className={styles.iconGlyph}>💧</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.effectsGroup}>
|
||||||
|
<div className={styles.subsectionLabel}>Эффекты действий</div>
|
||||||
|
<div className={styles.iconRow}>
|
||||||
|
<Button
|
||||||
|
variant={tool.tool === 'lightning' ? 'primary' : 'ghost'}
|
||||||
|
iconOnly
|
||||||
|
title="Молния"
|
||||||
|
ariaLabel="Молния"
|
||||||
|
onClick={() =>
|
||||||
|
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'lightning' } })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className={styles.iconGlyph}>⚡</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={tool.tool === 'sunbeam' ? 'primary' : 'ghost'}
|
||||||
|
iconOnly
|
||||||
|
title="Луч света"
|
||||||
|
ariaLabel="Луч света"
|
||||||
|
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'sunbeam' } })}
|
||||||
|
>
|
||||||
|
<span className={styles.iconGlyph}>☀️</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={tool.tool === 'freeze' ? 'primary' : 'ghost'}
|
||||||
|
iconOnly
|
||||||
|
title="Заморозка"
|
||||||
|
ariaLabel="Заморозка"
|
||||||
|
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'freeze' } })}
|
||||||
|
>
|
||||||
|
<span className={styles.iconGlyph}>❄️</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={tool.tool === 'poisonCloud' ? 'primary' : 'ghost'}
|
||||||
|
iconOnly
|
||||||
|
title="Облако яда"
|
||||||
|
ariaLabel="Облако яда"
|
||||||
|
onClick={() =>
|
||||||
|
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'poisonCloud' } })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className={styles.iconGlyph}>☣️</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className={styles.radiusRow}>
|
<div className={styles.radiusRow}>
|
||||||
<div className={styles.radiusLabel}>Радиус кисти</div>
|
<div className={styles.radiusLabel}>Радиус кисти</div>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -14,9 +14,46 @@ function readControlAppCss(): string {
|
|||||||
return fs.readFileSync(path.join(here, 'ControlApp.module.css'), 'utf8');
|
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', () => {
|
void test('ControlApp: эффекты в пульте, иконки с тултипами и подписью для a11y', () => {
|
||||||
const src = readControlApp();
|
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('Луч света'));
|
||||||
|
assert.ok(src.includes('title="Вода"'));
|
||||||
|
assert.ok(src.includes('title="Облако яда"'));
|
||||||
assert.ok(src.includes('title="Туман"'));
|
assert.ok(src.includes('title="Туман"'));
|
||||||
assert.ok(src.includes('ariaLabel="Туман"'));
|
assert.ok(src.includes('ariaLabel="Туман"'));
|
||||||
assert.ok(src.includes('iconOnly'));
|
assert.ok(src.includes('iconOnly'));
|
||||||
|
|||||||
@@ -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<number> {
|
||||||
|
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<number> {
|
||||||
|
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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<number> {
|
||||||
|
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<number> {
|
||||||
|
const raw = await getPoisonCloudSfxDurationMs();
|
||||||
|
return Math.min(60_000, Math.max(600, raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Воспроизведение с подгонкой скорости: фактическое время звука по часам ≈ `lifeMs`,
|
||||||
|
* чтобы совпасть с анимацией.
|
||||||
|
*/
|
||||||
|
export async function playPoisonCloudEffectSound(lifeMs: number): Promise<void> {
|
||||||
|
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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<number> {
|
||||||
|
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<number> {
|
||||||
|
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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -4,6 +4,17 @@ import type { EffectsState, EffectInstance } from '../../../shared/types/effects
|
|||||||
|
|
||||||
import styles from './PxiEffectsOverlay.module.css';
|
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 = {
|
type Props = {
|
||||||
state: EffectsState | null;
|
state: EffectsState | null;
|
||||||
interactive?: boolean;
|
interactive?: boolean;
|
||||||
@@ -173,7 +184,7 @@ function syncNodes(
|
|||||||
}
|
}
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
for (const inst of state.instances) {
|
for (const inst of state.instances) {
|
||||||
const sig = instanceSig(inst);
|
const sig = instanceSig(inst, viewport);
|
||||||
const existing = nodes.get(inst.id);
|
const existing = nodes.get(inst.id);
|
||||||
if (existing && (existing as any).__sig === sig) continue;
|
if (existing && (existing as any).__sig === sig) continue;
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -317,6 +328,26 @@ function createInstanceNode(
|
|||||||
c.alpha = Math.max(0, Math.min(1, inst.opacity));
|
c.alpha = Math.max(0, Math.min(1, inst.opacity));
|
||||||
return c;
|
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') {
|
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;
|
||||||
@@ -331,6 +362,66 @@ function createInstanceNode(
|
|||||||
redrawLightning(pixi, g, inst, viewport, 0, Math.max(1, inst.lifetimeMs));
|
redrawLightning(pixi, g, inst, viewport, 0, Math.max(1, inst.lifetimeMs));
|
||||||
return g;
|
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') {
|
if (inst.type === 'freeze') {
|
||||||
const tex = getFreezeScreenTexture(pixi, inst.seed, viewport);
|
const tex = getFreezeScreenTexture(pixi, inst.seed, viewport);
|
||||||
const s = new pixi.Sprite(tex);
|
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') {
|
if (inst.type === 'rain') {
|
||||||
const cont = node;
|
const cont = node;
|
||||||
// Дождь: стабильная “шторка” + движение капель вниз с лёгким дрейфом.
|
// Дождь: стабильная “шторка” + движение капель вниз с лёгким дрейфом.
|
||||||
@@ -494,6 +591,28 @@ function animateNodes(
|
|||||||
redrawLightning(pixi, g, inst, viewport, t, life);
|
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') {
|
if (inst.type === 'freeze') {
|
||||||
const s = node;
|
const s = node;
|
||||||
const life = Math.max(1, inst.lifetimeMs);
|
const life = Math.max(1, inst.lifetimeMs);
|
||||||
@@ -526,14 +645,303 @@ function animateNodes(
|
|||||||
|
|
||||||
if (inst.type === 'ice') {
|
if (inst.type === 'ice') {
|
||||||
const s = node;
|
const s = node;
|
||||||
const life = Math.max(1, inst.lifetimeMs);
|
// Пятно льда: сразу на полную яркость и остаётся на сцене (не синхронизируем с длительностью «замершего экрана»).
|
||||||
const fade = 1 - t / life;
|
s.visible = true;
|
||||||
s.visible = fade > 0;
|
s.alpha = Math.max(0, Math.min(1, inst.opacity));
|
||||||
s.alpha = Math.max(0, Math.min(1, inst.opacity * (0.35 + 0.65 * fade)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
function redrawLightning(
|
||||||
pixi: any,
|
pixi: any,
|
||||||
g: 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') {
|
if (inst.type === 'fog') {
|
||||||
const last = inst.points[inst.points.length - 1];
|
const last = inst.points[inst.points.length - 1];
|
||||||
const lx = last ? Math.round(last.x * 1000) : 0;
|
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;
|
const ly = last ? Math.round(last.y * 1000) : 0;
|
||||||
return `rain:${inst.points.length}:${lx}:${ly}:${Math.round(inst.radiusN * 1000)}`;
|
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') {
|
if (inst.type === 'lightning') {
|
||||||
return `lt:${Math.round(inst.end.x * 1000)}:${Math.round(inst.end.y * 1000)}:${Math.round(inst.widthN * 1000)}`;
|
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') {
|
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)}`;
|
||||||
}
|
}
|
||||||
@@ -695,10 +1113,44 @@ function computeSceneShake(
|
|||||||
let fogTextureCache: { key: string; texture: any } | null = null;
|
let fogTextureCache: { key: string; texture: any } | null = null;
|
||||||
let fireTextureCache: { key: string; texture: any } | null = null;
|
let fireTextureCache: { key: string; texture: any } | null = null;
|
||||||
let rainTextureCache: { 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 scorchTextureCache: { key: string; texture: any } | null = null;
|
||||||
let iceTextureCache: { key: string; texture: any } | null = null;
|
let iceTextureCache: { key: string; texture: any } | null = null;
|
||||||
let freezeScreenTextureCache: Map<string, 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 {
|
function getFogTexture(pixi: any): any {
|
||||||
// Кэшируем один раз на процесс. Текстура — “дымка” с мягкой альфой.
|
// Кэшируем один раз на процесс. Текстура — “дымка” с мягкой альфой.
|
||||||
const key = 'fog_v1';
|
const key = 'fog_v1';
|
||||||
@@ -1049,9 +1501,11 @@ function smoothstep01(x: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function freezeAlpha(t: number, life: number): number {
|
function freezeAlpha(t: number, life: number): number {
|
||||||
const inMs = 180;
|
const L = Math.max(1, life);
|
||||||
const holdMs = 220;
|
// Те же доли, что при life=820 (180 / 220 / остаток), масштабируются под длину звука.
|
||||||
const outMs = Math.max(120, life - inMs - holdMs);
|
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) return smoothstep01(t / inMs);
|
||||||
if (t <= inMs + holdMs) return 1;
|
if (t <= inMs + holdMs) return 1;
|
||||||
return 1 - smoothstep01((t - inMs - holdMs) / outMs);
|
return 1 - smoothstep01((t - inMs - holdMs) / outMs);
|
||||||
|
|||||||
@@ -20,6 +20,20 @@ void test('pickEraseTargetId: fire/rain по штриху как туман', ()
|
|||||||
assert.equal(id, 'f1');
|
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: молния — расстояние до отрезка', () => {
|
void test('minDistSqEffectToPoint: молния — расстояние до отрезка', () => {
|
||||||
const bolt: EffectInstance = {
|
const bolt: EffectInstance = {
|
||||||
...base,
|
...base,
|
||||||
@@ -37,6 +51,23 @@ void test('minDistSqEffectToPoint: молния — расстояние до о
|
|||||||
assert.equal(end, 0);
|
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', () => {
|
void test('pickEraseTargetId: scorch с учётом inst.radiusN', () => {
|
||||||
const sc: EffectInstance = {
|
const sc: EffectInstance = {
|
||||||
...base,
|
...base,
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ export function minDistSqEffectToPoint(inst: EffectInstance, p: { x: number; y:
|
|||||||
switch (inst.type) {
|
switch (inst.type) {
|
||||||
case 'fog':
|
case 'fog':
|
||||||
case 'fire':
|
case 'fire':
|
||||||
case 'rain': {
|
case 'rain':
|
||||||
|
case 'water': {
|
||||||
let best = Number.POSITIVE_INFINITY;
|
let best = Number.POSITIVE_INFINITY;
|
||||||
for (const q of inst.points) {
|
for (const q of inst.points) {
|
||||||
const dx = q.x - p.x;
|
const dx = q.x - p.x;
|
||||||
@@ -40,6 +41,7 @@ export function minDistSqEffectToPoint(inst: EffectInstance, p: { x: number; y:
|
|||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
case 'lightning':
|
case 'lightning':
|
||||||
|
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': {
|
||||||
const dx = inst.at.x - p.x;
|
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;
|
return dx * dx + dy * dy;
|
||||||
}
|
}
|
||||||
case 'scorch':
|
case 'scorch':
|
||||||
case 'ice': {
|
case 'ice':
|
||||||
|
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;
|
||||||
return dx * dx + dy * dy;
|
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 {
|
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;
|
const r = toolRadiusN + inst.radiusN;
|
||||||
return r * r;
|
return r * r;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) относительно области предпросмотра/презентации. */
|
/** Нормализованные координаты (0..1) относительно области предпросмотра/презентации. */
|
||||||
export type NPoint = { x: number; y: number; tMs: number; pressure?: number };
|
export type NPoint = { x: number; y: number; tMs: number; pressure?: number };
|
||||||
@@ -38,6 +57,14 @@ export type RainInstance = EffectInstanceBase & {
|
|||||||
lifetimeMs: number | null;
|
lifetimeMs: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WaterInstance = EffectInstanceBase & {
|
||||||
|
type: 'water';
|
||||||
|
points: NPoint[];
|
||||||
|
radiusN: number;
|
||||||
|
opacity: number;
|
||||||
|
lifetimeMs: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type LightningInstance = EffectInstanceBase & {
|
export type LightningInstance = EffectInstanceBase & {
|
||||||
type: 'lightning';
|
type: 'lightning';
|
||||||
start: { x: number; y: number };
|
start: { x: number; y: number };
|
||||||
@@ -47,6 +74,25 @@ export type LightningInstance = EffectInstanceBase & {
|
|||||||
lifetimeMs: number;
|
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 & {
|
export type FreezeInstance = EffectInstanceBase & {
|
||||||
type: 'freeze';
|
type: 'freeze';
|
||||||
at: { x: number; y: number };
|
at: { x: number; y: number };
|
||||||
@@ -69,14 +115,18 @@ export type IceInstance = EffectInstanceBase & {
|
|||||||
at: { x: number; y: number };
|
at: { x: number; y: number };
|
||||||
radiusN: number;
|
radiusN: number;
|
||||||
opacity: number;
|
opacity: number;
|
||||||
lifetimeMs: number;
|
/** `null` — пятно не истекает по времени (снимается только «очистить» или ластик). */
|
||||||
|
lifetimeMs: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EffectInstance =
|
export type EffectInstance =
|
||||||
| FogInstance
|
| FogInstance
|
||||||
| FireInstance
|
| FireInstance
|
||||||
| RainInstance
|
| RainInstance
|
||||||
|
| WaterInstance
|
||||||
| LightningInstance
|
| LightningInstance
|
||||||
|
| SunbeamInstance
|
||||||
|
| PoisonCloudInstance
|
||||||
| FreezeInstance
|
| FreezeInstance
|
||||||
| ScorchInstance
|
| ScorchInstance
|
||||||
| IceInstance;
|
| IceInstance;
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
||||||
"lint": "eslint . --max-warnings 0",
|
"lint": "eslint . --max-warnings 0",
|
||||||
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
"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": "prettier . --check",
|
||||||
"format:write": "prettier . --write",
|
"format:write": "prettier . --write",
|
||||||
"release:info": "node scripts/print-release-info.mjs",
|
"release:info": "node scripts/print-release-info.mjs",
|
||||||
|
|||||||
Reference in New Issue
Block a user