feat(effects): вода, облако яда, луч света; пульт и окна демонстрации

- Поле: вода (сплошная заливка по штриху, превью кистью), туман/огонь/дождь без изменений логики.

- Действия: облако яда (частицы, круглая текстура, звук oblako-yada.mp3, длительность как у трека), луч света и заморозка со звуками из public/.

- Пульт: инструменты воды и яда, синхрон SFX, тесты панели и ластика.

- Окно управления: дочернее от окна просмотра (Z-order).

- Типы эффектов, effectsStore prune, hit-test ластика.

Made-with: Cursor
This commit is contained in:
Ivan Fontosh
2026-04-20 11:03:57 +08:00
parent 726c89e104
commit 20c838da7d
19 changed files with 1154 additions and 111 deletions
+84
View File
@@ -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);
});
+4 -2
View File
@@ -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;
}
@@ -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'"));
});
+14 -7
View File
@@ -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 });
}
}