feat(traps): activation VFX/SFX, explosion effect, and help section
Wire mimic/pit/arrow/laser media on activate, poison/explosion via effects, and document the scene editor and traps in Instructions. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -82,3 +82,22 @@ void test('pruneExpired: яд удаляется после lifetime', () => {
|
||||
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: 'ex_test',
|
||||
type: 'explosion',
|
||||
seed: 1,
|
||||
createdAtMs: Date.now() - 20_000,
|
||||
at: { x: 0.5, y: 0.5 },
|
||||
radiusN: 0.08,
|
||||
intensity: 1,
|
||||
lifetimeMs: 4200,
|
||||
},
|
||||
});
|
||||
assert.equal(store.pruneExpired(), true);
|
||||
assert.equal(store.getState().instances.length, 0);
|
||||
});
|
||||
|
||||
@@ -52,6 +52,7 @@ export class EffectsStore {
|
||||
i.type === 'lightning' ||
|
||||
i.type === 'sunbeam' ||
|
||||
i.type === 'poisonCloud' ||
|
||||
i.type === 'explosion' ||
|
||||
i.type === 'darkness'
|
||||
) {
|
||||
return now - i.createdAtMs < i.lifetimeMs;
|
||||
|
||||
@@ -377,6 +377,7 @@ async function main() {
|
||||
registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version));
|
||||
registerHandler(ipcChannels.windows.openMultiWindow, () => {
|
||||
sceneDarknessStore.resetSession();
|
||||
sceneTrapsStore.resetSession();
|
||||
openMultiWindow();
|
||||
const project = projectStore.getOpenProject();
|
||||
if (project) {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { SceneTrapsStore } from './sceneTrapsStore';
|
||||
|
||||
void test('SceneTrapsStore: сохраняет runtime при переключении сцен внутри сессии', () => {
|
||||
const store = new SceneTrapsStore();
|
||||
store.switchScene('scene_a', ['t1']);
|
||||
store.dispatch({ kind: 'activate', trapId: 't1' });
|
||||
assert.equal(store.getState().byId.t1?.status, 'active');
|
||||
assert.equal(store.getState().byId.t1?.revealed, true);
|
||||
|
||||
store.switchScene('scene_b', ['t2']);
|
||||
assert.equal(store.getState().byId.t2?.status, 'inactive');
|
||||
|
||||
store.switchScene('scene_a', ['t1']);
|
||||
assert.equal(store.getState().byId.t1?.status, 'active');
|
||||
assert.equal(store.getState().byId.t1?.revealed, true);
|
||||
});
|
||||
|
||||
void test('SceneTrapsStore: resetSession сбрасывает кэш к дефолту (кнопка «Запустить»)', () => {
|
||||
const store = new SceneTrapsStore();
|
||||
store.switchScene('scene_a', ['t1', 't2']);
|
||||
store.dispatch({ kind: 'reveal', trapId: 't1' });
|
||||
store.dispatch({ kind: 'activate', trapId: 't2' });
|
||||
assert.equal(store.getState().byId.t1?.revealed, true);
|
||||
assert.equal(store.getState().byId.t2?.status, 'active');
|
||||
assert.ok(store.getState().lastActivation);
|
||||
|
||||
store.resetSession();
|
||||
store.switchScene('scene_a', ['t1', 't2']);
|
||||
|
||||
assert.equal(store.getState().byId.t1?.status, 'inactive');
|
||||
assert.equal(store.getState().byId.t1?.revealed, false);
|
||||
assert.equal(store.getState().byId.t2?.status, 'inactive');
|
||||
assert.equal(store.getState().byId.t2?.revealed, false);
|
||||
assert.equal(store.getState().lastActivation, null);
|
||||
});
|
||||
@@ -18,11 +18,12 @@ import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { isSceneDescriptionEmpty } from '../editor/sceneDescriptionHtml';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { RotatedImage } from '../shared/RotatedImage';
|
||||
import { ExplosionVideoOverlay } from '../shared/effects/ExplosionVideoOverlay';
|
||||
import {
|
||||
PixiEffectsOverlay,
|
||||
type PixiEffectsOverlayHandle,
|
||||
} from '../shared/effects/PxiEffectsOverlay';
|
||||
import type { EffectInstance } from '../../shared/types/effects';
|
||||
import type { EffectInstance, ExplosionInstance } from '../../shared/types/effects';
|
||||
import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay';
|
||||
import { useEffectsState } from '../shared/effects/useEffectsState';
|
||||
import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState';
|
||||
@@ -47,6 +48,7 @@ import { clampEffectsSfxGain, getEffectsSfxGain, setEffectsSfxGain } from './eff
|
||||
import { setFireAmbientActive, syncFireAmbientVolume } from './fireAmbientSfx';
|
||||
import { setRainAmbientActive, syncRainAmbientVolume } from './rainAmbientSfx';
|
||||
import { getFreezeEffectLifeMs, playFreezeEffectSound } from './freezeSfx';
|
||||
import { getExplosionEffectLifeMs, playExplosionEffectSound } from './explosionSfx';
|
||||
import { getPoisonCloudEffectLifeMs, playPoisonCloudEffectSound } from './poisonCloudSfx';
|
||||
import { getSunbeamEffectLifeMs, playSunbeamEffectSound } from './sunbeamSfx';
|
||||
|
||||
@@ -168,6 +170,7 @@ export function ControlApp() {
|
||||
| 'lightning'
|
||||
| 'sunbeam'
|
||||
| 'poisonCloud'
|
||||
| 'explosion'
|
||||
| 'freeze'
|
||||
| 'exploreBrush'
|
||||
| 'eraser';
|
||||
@@ -189,6 +192,7 @@ export function ControlApp() {
|
||||
const cursorPosRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const draftPaintRafRef = useRef(0);
|
||||
const effectsOverlayRef = useRef<PixiEffectsOverlayHandle | null>(null);
|
||||
const [explosionDraft, setExplosionDraft] = useState<ExplosionInstance | null>(null);
|
||||
const draftMetaRef = useRef<{ createdAtMs: number; seed: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -257,9 +261,15 @@ export function ControlApp() {
|
||||
const [poisonDraftLifeMs, setPoisonDraftLifeMs] = useState(1600);
|
||||
const poisonDraftLifeMsRef = useRef(poisonDraftLifeMs);
|
||||
poisonDraftLifeMsRef.current = poisonDraftLifeMs;
|
||||
const [explosionDraftLifeMs, setExplosionDraftLifeMs] = useState(4200);
|
||||
const explosionDraftLifeMsRef = useRef(explosionDraftLifeMs);
|
||||
explosionDraftLifeMsRef.current = explosionDraftLifeMs;
|
||||
useEffect(() => {
|
||||
void getPoisonCloudEffectLifeMs().then(setPoisonDraftLifeMs);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
void getExplosionEffectLifeMs().then(setExplosionDraftLifeMs);
|
||||
}, []);
|
||||
|
||||
const hasFireOnScene = useMemo(
|
||||
() => Boolean(fxState?.instances.some((i) => i.type === 'fire' && i.points.length > 0)),
|
||||
@@ -968,6 +978,20 @@ export function ControlApp() {
|
||||
lifetimeMs: poisonDraftLifeMsRef.current,
|
||||
};
|
||||
}
|
||||
if (b.tool === 'explosion' && b.points && b.points.length > 0) {
|
||||
const last = b.points[b.points.length - 1];
|
||||
if (last === undefined) return null;
|
||||
return {
|
||||
id: '__draft__',
|
||||
type: 'explosion',
|
||||
seed,
|
||||
createdAtMs,
|
||||
at: { x: last.x, y: last.y },
|
||||
radiusN: Math.max(0.03, t.radiusN * 0.95),
|
||||
intensity: Math.max(0.75, Math.min(1.2, t.intensity * 1.15)),
|
||||
lifetimeMs: explosionDraftLifeMsRef.current,
|
||||
};
|
||||
}
|
||||
if (b.tool === 'freeze' && b.points && b.points.length > 0) {
|
||||
const last = b.points[b.points.length - 1];
|
||||
if (last === undefined) return null;
|
||||
@@ -985,12 +1009,15 @@ export function ControlApp() {
|
||||
}
|
||||
|
||||
function pushDraftToPixi(): void {
|
||||
effectsOverlayRef.current?.setDraft(buildDraftEffectInstance());
|
||||
const draft = buildDraftEffectInstance();
|
||||
effectsOverlayRef.current?.setDraft(draft);
|
||||
setExplosionDraft(draft?.type === 'explosion' ? draft : null);
|
||||
}
|
||||
|
||||
function clearDraftFromPixi(): void {
|
||||
draftMetaRef.current = null;
|
||||
effectsOverlayRef.current?.setDraft(null);
|
||||
setExplosionDraft(null);
|
||||
}
|
||||
|
||||
function scheduleDraftRepaint(): void {
|
||||
@@ -1270,6 +1297,26 @@ export function ControlApp() {
|
||||
});
|
||||
void playPoisonCloudEffectSound(poisonLifeMs);
|
||||
}
|
||||
if (b.tool === 'explosion' && 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 explosionLifeMs = await getExplosionEffectLifeMs();
|
||||
await fx.dispatch({
|
||||
kind: 'instance.add',
|
||||
instance: {
|
||||
id: `ex_${String(createdAtMs)}_${String(seed)}`,
|
||||
type: 'explosion',
|
||||
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: explosionLifeMs,
|
||||
},
|
||||
});
|
||||
void playExplosionEffectSound(explosionLifeMs);
|
||||
}
|
||||
if (b.tool === 'freeze' && b.points && b.points.length > 0) {
|
||||
const last = b.points[b.points.length - 1];
|
||||
if (last === undefined) return;
|
||||
@@ -1523,6 +1570,17 @@ export function ControlApp() {
|
||||
>
|
||||
<span className={styles.iconGlyph}>☣️</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={tool.tool === 'explosion' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title={t('control.explosion')}
|
||||
ariaLabel={t('control.explosion')}
|
||||
onClick={() =>
|
||||
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'explosion' } })
|
||||
}
|
||||
>
|
||||
<span className={styles.iconGlyph}>💥</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{isDarkenScene ? (
|
||||
@@ -1671,6 +1729,11 @@ export function ControlApp() {
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<ExplosionVideoOverlay
|
||||
state={fxState}
|
||||
draft={explosionDraft}
|
||||
viewport={previewContentRect}
|
||||
/>
|
||||
{previewContentRect ? (
|
||||
<SceneDarknessOverlay state={sdState} overlayAlpha={0.5} viewport={previewContentRect} />
|
||||
) : null}
|
||||
@@ -1829,7 +1892,53 @@ export function ControlApp() {
|
||||
viewport={previewContentRect}
|
||||
mode="control"
|
||||
onReveal={(trapId) => void sceneTrapsApi.dispatch({ kind: 'reveal', trapId })}
|
||||
onActivate={(trapId) => void sceneTrapsApi.dispatch({ kind: 'activate', trapId })}
|
||||
onActivate={(trapId) => {
|
||||
void (async () => {
|
||||
await sceneTrapsApi.dispatch({ kind: 'activate', trapId });
|
||||
const trap = (currentScene?.traps ?? []).find((t) => t.id === trapId);
|
||||
if (trap?.type === 'poison') {
|
||||
// Тот же VFX/SFX, что у инструмента «Облако яда» на пульте эффектов.
|
||||
const createdAtMs = Date.now();
|
||||
const seed = Math.floor(Math.random() * 1_000_000_000);
|
||||
const poisonLifeMs = await getPoisonCloudEffectLifeMs();
|
||||
await fx.dispatch({
|
||||
kind: 'instance.add',
|
||||
instance: {
|
||||
id: `trap_pc_${trapId}_${String(createdAtMs)}`,
|
||||
type: 'poisonCloud',
|
||||
seed,
|
||||
createdAtMs,
|
||||
at: { x: trap.nx, y: trap.ny },
|
||||
radiusN: Math.max(0.04, trap.sizeN * 1.15),
|
||||
intensity: 1.05,
|
||||
lifetimeMs: poisonLifeMs,
|
||||
},
|
||||
});
|
||||
void playPoisonCloudEffectSound(poisonLifeMs);
|
||||
return;
|
||||
}
|
||||
if (trap?.type === 'explosion') {
|
||||
// Тот же VFX/SFX, что у инструмента «Взрыв» на пульте эффектов.
|
||||
const createdAtMs = Date.now();
|
||||
const seed = Math.floor(Math.random() * 1_000_000_000);
|
||||
const explosionLifeMs = await getExplosionEffectLifeMs();
|
||||
await fx.dispatch({
|
||||
kind: 'instance.add',
|
||||
instance: {
|
||||
id: `trap_ex_${trapId}_${String(createdAtMs)}`,
|
||||
type: 'explosion',
|
||||
seed,
|
||||
createdAtMs,
|
||||
at: { x: trap.nx, y: trap.ny },
|
||||
radiusN: Math.max(0.04, trap.sizeN * 1.15),
|
||||
intensity: 1.05,
|
||||
lifetimeMs: explosionLifeMs,
|
||||
},
|
||||
});
|
||||
void playExplosionEffectSound(explosionLifeMs);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
onDisarm={(trapId) => void sceneTrapsApi.dispatch({ kind: 'disarm', trapId })}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1908,11 +2017,9 @@ export function ControlApp() {
|
||||
onLayoutChange={(layout) => {
|
||||
void materialsApi.dispatch({ kind: 'layout.set', layout });
|
||||
}}
|
||||
legendMarkers={
|
||||
activeMaterial.legend?.enabled
|
||||
? (activeMaterial.legend.markers ?? [])
|
||||
: undefined
|
||||
}
|
||||
{...(activeMaterial.legend?.enabled
|
||||
? { legendMarkers: activeMaterial.legend.markers ?? [] }
|
||||
: {})}
|
||||
/>
|
||||
) : null}
|
||||
{showMaterial && activeMaterial?.legend?.enabled ? (
|
||||
|
||||
@@ -46,6 +46,34 @@ void test('ControlApp: звук облака яда (public/oblako-yada.mp3)', (
|
||||
assert.ok(sfxSrc.includes('playbackRate'));
|
||||
});
|
||||
|
||||
void test('ControlApp: активация ловушки «яд» спавнит poisonCloud как на пульте эффектов', () => {
|
||||
const src = readControlApp();
|
||||
assert.ok(src.includes("trap?.type === 'poison'"));
|
||||
assert.ok(src.includes("type: 'poisonCloud'"));
|
||||
assert.ok(src.includes('trap_pc_'));
|
||||
assert.ok(src.includes('playPoisonCloudEffectSound(poisonLifeMs)'));
|
||||
});
|
||||
|
||||
void test('ControlApp: эффект «взрыв» + ловушка используют explosion webm/mp3', () => {
|
||||
const appSrc = readControlApp();
|
||||
const sfxSrc = fs.readFileSync(path.join(here, 'explosionSfx.ts'), 'utf8');
|
||||
assert.ok(appSrc.includes("title={t('control.explosion')}"));
|
||||
assert.ok(appSrc.includes("tool: 'explosion'"));
|
||||
assert.ok(appSrc.includes("type: 'explosion'"));
|
||||
assert.ok(appSrc.includes('getExplosionEffectLifeMs'));
|
||||
assert.ok(appSrc.includes('playExplosionEffectSound'));
|
||||
assert.ok(appSrc.includes("trap?.type === 'explosion'"));
|
||||
assert.ok(appSrc.includes('trap_ex_'));
|
||||
assert.ok(sfxSrc.includes('explosion.mp3'));
|
||||
assert.ok(sfxSrc.includes('aerial-debris-smoke.webm'));
|
||||
assert.ok(appSrc.includes('ExplosionVideoOverlay'));
|
||||
const videoOverlaySrc = fs.readFileSync(
|
||||
path.join(here, '..', 'shared', 'effects', 'ExplosionVideoOverlay.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(videoOverlaySrc.includes('explosionEffectVideoUrl'));
|
||||
});
|
||||
|
||||
void test('ControlApp: эффекты в пульте, иконки с тултипами и подписью для a11y', () => {
|
||||
const src = readControlApp();
|
||||
assert.ok(src.includes("t('control.instruments')"));
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/** Звук и длительность эффекта «Взрыв» (`public/explosion.mp3` + WebM с альфой). */
|
||||
|
||||
import { getEffectsSfxGain } from './effectsSfxGain';
|
||||
|
||||
const EXPLOSION_SFX_VOLUME = 0.95;
|
||||
|
||||
/** Запас, если метаданные не прочитались (длина webm ~3.97 с). */
|
||||
const DEFAULT_EXPLOSION_LIFE_MS = 4200;
|
||||
|
||||
export function explosionEffectSoundUrl(): string {
|
||||
return new URL('explosion.mp3', window.location.href).href;
|
||||
}
|
||||
|
||||
/** VP8/VP9 WebM с `alpha_mode=1` — рендер через HTML `<video>`, не Pixi. */
|
||||
export function explosionEffectVideoUrl(): string {
|
||||
return new URL('vfx/explosion/aerial-debris-smoke.webm', window.location.href).href;
|
||||
}
|
||||
|
||||
let cachedExplosionSfxDurationMs: number | null = null;
|
||||
|
||||
/** Длительность трека в мс (кэш после первого чтения метаданных). */
|
||||
export function getExplosionSfxDurationMs(): Promise<number> {
|
||||
if (cachedExplosionSfxDurationMs !== null) {
|
||||
return Promise.resolve(cachedExplosionSfxDurationMs);
|
||||
}
|
||||
const url = explosionEffectSoundUrl();
|
||||
return new Promise((resolve) => {
|
||||
const a = new Audio();
|
||||
const done = (ms: number): void => {
|
||||
cachedExplosionSfxDurationMs = ms;
|
||||
a.removeAttribute('src');
|
||||
resolve(ms);
|
||||
};
|
||||
a.addEventListener('loadedmetadata', () => {
|
||||
const d = a.duration;
|
||||
done(Number.isFinite(d) && d > 0 ? Math.round(d * 1000) : DEFAULT_EXPLOSION_LIFE_MS);
|
||||
});
|
||||
a.addEventListener('error', () => done(DEFAULT_EXPLOSION_LIFE_MS));
|
||||
a.src = url;
|
||||
a.load();
|
||||
});
|
||||
}
|
||||
|
||||
/** Длительность визуала ≈ max(звук, webm), с разумными пределами. */
|
||||
export async function getExplosionEffectLifeMs(): Promise<number> {
|
||||
const sfxMs = await getExplosionSfxDurationMs();
|
||||
const raw = Math.max(sfxMs, DEFAULT_EXPLOSION_LIFE_MS);
|
||||
return Math.min(60_000, Math.max(600, raw));
|
||||
}
|
||||
|
||||
export async function playExplosionEffectSound(lifeMs: number): Promise<void> {
|
||||
try {
|
||||
const rawMs = await getExplosionSfxDurationMs();
|
||||
const target = Math.max(200, lifeMs);
|
||||
const rate = Math.max(0.25, Math.min(4, rawMs / target));
|
||||
const el = new Audio(explosionEffectSoundUrl());
|
||||
el.volume = Math.max(0, Math.min(1, EXPLOSION_SFX_VOLUME * getEffectsSfxGain()));
|
||||
el.playbackRate = rate;
|
||||
void el.play().catch(() => undefined);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -274,7 +274,7 @@ export function EditorApp() {
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedGraphNodeId(null);
|
||||
}, [state.project?.id]);
|
||||
}, [state.project?.id, state.openingProjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.project || !selectedGraphNodeId) return;
|
||||
|
||||
@@ -279,7 +279,7 @@ export function MaterialsManagerModal({
|
||||
onDelete={onDelete}
|
||||
onReorder={onReorder}
|
||||
onRotate={onRotate}
|
||||
onLegendChange={onLegendChange}
|
||||
{...(onLegendChange ? { onLegendChange } : {})}
|
||||
/>
|
||||
</div>
|
||||
</>,
|
||||
|
||||
@@ -7,6 +7,7 @@ export const HELP_SECTION_IDS = [
|
||||
'graph',
|
||||
'sideStorylines',
|
||||
'sceneProps',
|
||||
'sceneEditor',
|
||||
'campaignAudio',
|
||||
'materials',
|
||||
'npcs',
|
||||
|
||||
@@ -170,7 +170,11 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.sceneProps.title': 'Свойства сцены',
|
||||
'help.section.sceneProps.body':
|
||||
'Выберите сцену в списке слева — справа откроются её свойства.\n\n«Название сцены» — для мастера. «Описание» — заметки мастера с форматированием: рядом с подписью нажмите карандаш, откроется редактор (жирный, курсив, заголовки, списки, ссылки). Под подписью видно фрагмент текста или «описание отсутствует», если поле пустое. Во время сессии описание открывается с пульта в отдельном окне (см. «Пульт управления»), а не в блоке «Сюжетная линия».\n\nЕсли у сцены на карте есть карточка с синей меткой «ПОБОЧНАЯ», появится поле «Название побочной линии».\n\nКартинка или видео для игроков:\n\n1) В «Превью сцены» нажмите «Загрузить» или «Изменить».\n\n2) Выберите файл (PNG, JPG, WebP, GIF и др.).\n\n3) Для картинки можно «Повернуть» (шаг 90°). «Очистить» — убрать превью.\n\n4) Для картинки можно включить «Затемнить сцену»: при показе игроки сначала увидят карту в полной темноте, а вы снимете её с пульта «Кистью Открытия» (см. «Эффекты»).\n\nДля видео включите «Автостарт», если ролик должен сам начаться на экране игроков. На видео-сценах эффекты кистью недоступны.\n\n«Аудио сцены» — музыка и звуки этого эпизода. «Загрузить» добавляет файлы. У каждого трека: «Авто» (играть при входе в сцену) и «Цикл». Корзина удаляет трек.\n\nСвязи между сценами задаются на карте, а не здесь — см. «Граф сцен».',
|
||||
'Выберите сцену в списке слева — справа откроются её свойства.\n\n«Название сцены» — для мастера. «Описание» — заметки мастера с форматированием: рядом с подписью нажмите карандаш, откроется редактор (жирный, курсив, заголовки, списки, ссылки). Под подписью видно фрагмент текста или «описание отсутствует», если поле пустое. Во время сессии описание открывается с пульта в отдельном окне (см. «Пульт управления»), а не в блоке «Сюжетная линия».\n\nЕсли у сцены на карте есть карточка с синей меткой «ПОБОЧНАЯ», появится поле «Название побочной линии».\n\nКартинка или видео для игроков:\n\n1) В «Превью сцены» нажмите «Загрузить» или «Изменить».\n\n2) Выберите файл (PNG, JPG, WebP, GIF и др.).\n\n3) Для картинки можно «Повернуть» (шаг 90°). «Очистить» — убрать превью.\n\n4) Для картинки можно включить «Затемнить сцену»: при показе игроки сначала увидят карту в полной темноте, а вы снимете её с пульта «Кистью Открытия» (см. «Эффекты»).\n\n5) Для картинки доступна кнопка «Редактор сцены» — расстановка ловушек на карте (см. «Редактор сцены и ловушки»).\n\nДля видео включите «Автостарт», если ролик должен сам начаться на экране игроков. На видео-сценах эффекты кистью и редактор сцены недоступны.\n\n«Аудио сцены» — музыка и звуки этого эпизода. «Загрузить» добавляет файлы. У каждого трека: «Авто» (играть при входе в сцену) и «Цикл». Корзина удаляет трек.\n\nСвязи между сценами задаются на карте, а не здесь — см. «Граф сцен».',
|
||||
|
||||
'help.section.sceneEditor.title': 'Редактор сцены и ловушки',
|
||||
'help.section.sceneEditor.body':
|
||||
'«Редактор сцены» — отдельное окно, где на картинке сцены расставляют ловушки. Он доступен только для сцен с изображением (не с видео).\n\nОткрыть:\n\n1) Выберите сцену в списке слева.\n\n2) В «Свойствах сцены» загрузите картинку, если её ещё нет.\n\n3) Нажмите «Редактор сцены».\n\nСлева — палитра «Ловушки», справа — карта сцены.\n\nРасставить ловушку:\n\n1) Перетащите тип из палитры на нужное место карты.\n\n2) Перетащите маркер, чтобы сдвинуть его; потяните за уголок выделенного маркера — изменить размер.\n\n3) Delete / Backspace или кнопка «Удалить» — убрать выбранную ловушку.\n\nНавигация по карте: колесо мыши — зум; средняя кнопка мыши или Space+ЛКМ — сдвиг вида.\n\nТипы ловушек: Мимик, Взрыв, Яд, Пропасть, Стрела, Лазер и Свободная (универсальный маркер). Расстановка сохраняется в проекте вместе со сценой.\n\nВо время сессии:\n\n1) На пульте в «Предпросмотр экрана» вы видите все ловушки текущей сцены. Пока они скрыты от игроков, маркеры у вас слегка приглушены.\n\n2) На презентации маркеры появляются только после проявления или срабатывания.\n\n3) Правый клик по маркеру на пульте:\n• «Проявить» — показать игрокам без срабатывания.\n• «Активировать» — проявить и запустить эффект: у Мимика, Пропасти, Стрелы и Лазера — анимация и звук; у Яда и Взрыва — как соответствующие эффекты с пульта (облако яда / взрыв); у Свободной — короткая вспышка.\n• «Обезвредить» — показать как обезвреженную.\n\nСостояние ловушек (проявлены / сработали / обезврежены) сбрасывается при новом запуске сессии. Сами маркеры на карте остаются.',
|
||||
|
||||
'help.section.campaignAudio.title': 'Аудио игры',
|
||||
'help.section.campaignAudio.body':
|
||||
@@ -190,7 +194,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.controlPanel.title': 'Пульт управления',
|
||||
'help.section.controlPanel.body':
|
||||
'Пульт — ваш стол во время игры. Игроки смотрят на презентацию, вы управляете всем отсюда.\n\nСлева сверху — «Инструменты», затем эффекты и ход сюжета; справа — мини-копия экрана игроков, варианты переходов и музыка.\n\nВ «Инструменты» кнопка с книгой открывает отдельное окно с оформленным описанием текущей сцены. Если описания нет, кнопка неактивна — при наведении подсказка «Описание отсутствует». Кнопка материалов (иконка карты) открывает окно со списком материалов кампании — подробнее в разделе «Материалы». Кнопка НПС (цветная иконка человека) открывает окно персонажей — см. раздел «НПС».\n\n«Предпросмотр экрана» показывает то же, что видят игроки. На сценах с картинкой здесь же рисуют эффекты — они сразу появляются на большом экране.\n\n«Сюжетная линия» — цепочка пройденных эпизодов. Текущая сцена помечена «ТЕКУЩАЯ СЦЕНА». Клик по прошлому шагу переносит партию к тому месту на карте и добавляет новый шаг в историю.\n\n«Выключить демонстрацию» — закончить показ и вернуться в редактор.',
|
||||
'Пульт — ваш стол во время игры. Игроки смотрят на презентацию, вы управляете всем отсюда.\n\nСлева сверху — «Инструменты», затем эффекты и ход сюжета; справа — мини-копия экрана игроков, варианты переходов и музыка.\n\nВ «Инструменты» кнопка с книгой открывает отдельное окно с оформленным описанием текущей сцены. Если описания нет, кнопка неактивна — при наведении подсказка «Описание отсутствует». Кнопка материалов (иконка карты) открывает окно со списком материалов кампании — подробнее в разделе «Материалы». Кнопка НПС (цветная иконка человека) открывает окно персонажей — см. раздел «НПС».\n\n«Предпросмотр экрана» показывает то же, что видят игроки. На сценах с картинкой здесь же рисуют эффекты — они сразу появляются на большом экране. Если на сцене расставлены ловушки, они видны на предпросмотре: правый клик по маркеру — проявить, активировать или обезвредить (см. «Редактор сцены и ловушки»).\n\n«Сюжетная линия» — цепочка пройденных эпизодов. Текущая сцена помечена «ТЕКУЩАЯ СЦЕНА». Клик по прошлому шагу переносит партию к тому месту на карте и добавляет новый шаг в историю.\n\n«Выключить демонстрацию» — закончить показ и вернуться в редактор.',
|
||||
|
||||
'help.section.transitions.title': 'Переходы между сценами',
|
||||
'help.section.transitions.body':
|
||||
@@ -202,7 +206,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.effects.title': 'Эффекты поля и действий',
|
||||
'help.section.effects.body':
|
||||
'Эффекты работают на сценах с картинкой, не на видео. Рисуйте в «Предпросмотр экрана» — игроки увидят то же на презентации.\n\nВыберите инструмент слева:\n• Эффекты поля (туман, дождь, огонь, вода) — зажмите левую кнопку и ведите по карте.\n• Эффекты действий (молния, луч света, заморозка, тьма, облако яда) — короткий клик или штрих; у некоторых есть звук.\n\nЕсли у сцены в свойствах включено «Затемнить сцену», появится блок «Управление затемнением» с «Кистью Открытия» 🔦. Водите по предпросмотру — тьма снимается на обоих экранах сразу. У игроков нераскрытое остаётся чёрным, у вас на пульте — полузатемнённым. Уже открытые участки сохраняются, пока идёт показ и вы снова попадаете на ту же карточку сцены на карте. Это не то же самое, что эффект «Тьма» 🌑 в блоке действий.\n\nЛастик 🧹 — для эффектов поля (туман, дождь, огонь, вода) водите кистью, как «Кистью Открытия» для затемнения: стирается только пройденный участок. Эффекты действий (молния, луч и т.д.) убираются целиком при клике или проведении по ним. «Очистить эффекты» — снять всё сразу.\n\n«Радиус кисти» под панелью — чем больше число, тем шире мазок.',
|
||||
'Эффекты работают на сценах с картинкой, не на видео. Рисуйте в «Предпросмотр экрана» — игроки увидят то же на презентации.\n\nВыберите инструмент слева:\n• Эффекты поля (туман, дождь, огонь, вода) — зажмите левую кнопку и ведите по карте.\n• Эффекты действий (молния, луч света, заморозка, тьма, облако яда, взрыв) — короткий клик или штрих; у некоторых есть звук.\n\nЕсли у сцены в свойствах включено «Затемнить сцену», появится блок «Управление затемнением» с «Кистью Открытия» 🔦. Водите по предпросмотру — тьма снимается на обоих экранах сразу. У игроков нераскрытое остаётся чёрным, у вас на пульте — полузатемнённым. Уже открытые участки сохраняются, пока идёт показ и вы снова попадаете на ту же карточку сцены на карте. Это не то же самое, что эффект «Тьма» 🌑 в блоке действий.\n\nЛастик 🧹 — для эффектов поля (туман, дождь, огонь, вода) водите кистью, как «Кистью Открытия» для затемнения: стирается только пройденный участок. Эффекты действий (молния, луч и т.д.) убираются целиком при клике или проведении по ним. «Очистить эффекты» — снять всё сразу.\n\n«Радиус кисти» под панелью — чем больше число, тем шире мазок.',
|
||||
|
||||
'help.section.presentation.title': 'Экран презентации',
|
||||
'help.section.presentation.body':
|
||||
@@ -540,6 +544,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.sunbeam': 'Луч света',
|
||||
'control.freeze': 'Заморозка',
|
||||
'control.poisonCloud': 'Облако яда',
|
||||
'control.explosion': 'Взрыв',
|
||||
'control.brushRadius': 'Радиус кисти',
|
||||
'control.effectsSound': 'Звук эффектов',
|
||||
'control.storyLine': 'СЮЖЕТНАЯ ЛИНИЯ',
|
||||
@@ -710,7 +715,11 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.sceneProps.title': 'Scene properties',
|
||||
'help.section.sceneProps.body':
|
||||
'Select a scene in the left list — its properties open on the right.\n\nScene title is for the GM. Description is GM notes with formatting: click the pencil next to the label to open the editor (bold, italic, headings, lists, links). Below the label you see a text preview, or “no description” when empty. During a session, open the description from the control panel in a separate window (see Control panel) — it is not shown inside the Storyline list.\n\nIf the scene has a graph card with a blue SIDE badge, a Side storyline title field appears.\n\nImage or video for players:\n\n1) Under Scene preview, click Upload or Change.\n\n2) Pick a file (PNG, JPG, WebP, GIF, etc.).\n\n3) For images, use Rotate (90° steps). Clear removes the preview.\n\n4) For images, enable Darken scene so players start in full darkness and you reveal the map with the Opening brush on the control panel (see Effects).\n\nFor video, enable Autostart if the clip should start on its own on the player screen. Brush effects are not available on video scenes.\n\nScene audio — music and sounds for this episode. Upload adds files. Per track: Auto (play when entering the scene) and Loop. The trash icon removes a track.\n\nLinks between scenes are drawn on the map, not here — see Scene graph.',
|
||||
'Select a scene in the left list — its properties open on the right.\n\nScene title is for the GM. Description is GM notes with formatting: click the pencil next to the label to open the editor (bold, italic, headings, lists, links). Below the label you see a text preview, or “no description” when empty. During a session, open the description from the control panel in a separate window (see Control panel) — it is not shown inside the Storyline list.\n\nIf the scene has a graph card with a blue SIDE badge, a Side storyline title field appears.\n\nImage or video for players:\n\n1) Under Scene preview, click Upload or Change.\n\n2) Pick a file (PNG, JPG, WebP, GIF, etc.).\n\n3) For images, use Rotate (90° steps). Clear removes the preview.\n\n4) For images, enable Darken scene so players start in full darkness and you reveal the map with the Opening brush on the control panel (see Effects).\n\n5) For images, Scene editor opens trap placement on the map (see Scene editor and traps).\n\nFor video, enable Autostart if the clip should start on its own on the player screen. Brush effects and the scene editor are not available on video scenes.\n\nScene audio — music and sounds for this episode. Upload adds files. Per track: Auto (play when entering the scene) and Loop. The trash icon removes a track.\n\nLinks between scenes are drawn on the map, not here — see Scene graph.',
|
||||
|
||||
'help.section.sceneEditor.title': 'Scene editor and traps',
|
||||
'help.section.sceneEditor.body':
|
||||
'Scene editor is a separate window for placing traps on the scene image. It is available only for image scenes (not video).\n\nOpen it:\n\n1) Select a scene in the left list.\n\n2) In Scene properties, upload an image if the scene has none yet.\n\n3) Click Scene editor.\n\nOn the left is the Traps palette; on the right is the scene map.\n\nPlace a trap:\n\n1) Drag a type from the palette onto the map.\n\n2) Drag a marker to move it; drag the corner handle of the selected marker to resize.\n\n3) Delete / Backspace or the Delete button removes the selected trap.\n\nMap navigation: mouse wheel zooms; middle mouse button or Space+left-drag pans the view.\n\nTrap types: Mimic, Explosion, Poison, Pit, Arrow, Laser, and Freeform (a generic marker). Placement is saved with the scene in the project.\n\nDuring a session:\n\n1) On the control panel Screen preview you see every trap on the current scene. While still hidden from players, markers look slightly muted on your side.\n\n2) On presentation, markers appear only after reveal or activation.\n\n3) Right-click a marker on the control panel:\n• Reveal — show it to players without triggering.\n• Activate — reveal and play the effect: Mimic, Pit, Arrow, and Laser play animation and sound; Poison and Explosion use the matching control-panel effects (poison cloud / explosion); Freeform shows a short flash.\n• Disarm — show it as disarmed.\n\nTrap runtime state (revealed / triggered / disarmed) resets when you start a new session. Markers placed on the map remain.',
|
||||
|
||||
'help.section.campaignAudio.title': 'Game audio',
|
||||
'help.section.campaignAudio.body':
|
||||
@@ -730,7 +739,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.controlPanel.title': 'Control panel',
|
||||
'help.section.controlPanel.body':
|
||||
'The control panel is your desk during play. Players watch presentation; you run everything from here.\n\nTop-left: Tools, then effects and storyline. On the right: a mini copy of the player screen, branch options, and music.\n\nUnder Tools, the book button opens a separate window with the current scene’s formatted description. If there is no description, the button is disabled — the tooltip reads “No description”. The materials button (map icon) opens a window with the campaign materials list — see the Materials section for details. The NPCs button (colored person icon) opens the characters window — see the NPCs section.\n\nScreen preview shows what players see. On image scenes, paint effects here — they appear on the big screen right away.\n\nStoryline lists episodes you have visited. The current scene is marked CURRENT SCENE. Click an earlier step to move the party back to that spot and add a new history entry.\n\nStop presentation ends the show and unlocks the editor.',
|
||||
'The control panel is your desk during play. Players watch presentation; you run everything from here.\n\nTop-left: Tools, then effects and storyline. On the right: a mini copy of the player screen, branch options, and music.\n\nUnder Tools, the book button opens a separate window with the current scene’s formatted description. If there is no description, the button is disabled — the tooltip reads “No description”. The materials button (map icon) opens a window with the campaign materials list — see the Materials section for details. The NPCs button (colored person icon) opens the characters window — see the NPCs section.\n\nScreen preview shows what players see. On image scenes, paint effects here — they appear on the big screen right away. If the scene has traps, they appear on the preview: right-click a marker to reveal, activate, or disarm (see Scene editor and traps).\n\nStoryline lists episodes you have visited. The current scene is marked CURRENT SCENE. Click an earlier step to move the party back to that spot and add a new history entry.\n\nStop presentation ends the show and unlocks the editor.',
|
||||
|
||||
'help.section.transitions.title': 'Scene transitions',
|
||||
'help.section.transitions.body':
|
||||
@@ -742,7 +751,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.effects.title': 'Field and action effects',
|
||||
'help.section.effects.body':
|
||||
'Effects work on image scenes, not video. Paint in Screen preview — players see the same on presentation.\n\nPick a tool on the left:\n• Field effects (fog, rain, fire, water) — hold the left button and brush on the map.\n• Action effects (lightning, sunbeam, freeze, darkness, poison cloud) — click or short stroke; some include sound.\n\nIf Darken scene is enabled in scene properties, a Darkness control section appears with the Opening brush 🔦. Brush on the preview to clear darkness on both screens at once. Unrevealed areas stay fully black for players and half-dark on your preview. Revealed areas are remembered while the show runs and you return to the same graph card. This is not the same as the Darkness 🌑 action effect.\n\nEraser 🧹 — for field effects (fog, rain, fire, water), brush like the Opening brush for darkness: only the stroke area is erased. Action effects (lightning, sunbeam, etc.) are removed whole when you click or drag over them. Clear effects removes everything at once.\n\nBrush radius under the panel — higher values mean a wider stroke.',
|
||||
'Effects work on image scenes, not video. Paint in Screen preview — players see the same on presentation.\n\nPick a tool on the left:\n• Field effects (fog, rain, fire, water) — hold the left button and brush on the map.\n• Action effects (lightning, sunbeam, freeze, darkness, poison cloud, explosion) — click or short stroke; some include sound.\n\nIf Darken scene is enabled in scene properties, a Darkness control section appears with the Opening brush 🔦. Brush on the preview to clear darkness on both screens at once. Unrevealed areas stay fully black for players and half-dark on your preview. Revealed areas are remembered while the show runs and you return to the same graph card. This is not the same as the Darkness 🌑 action effect.\n\nEraser 🧹 — for field effects (fog, rain, fire, water), brush like the Opening brush for darkness: only the stroke area is erased. Action effects (lightning, sunbeam, etc.) are removed whole when you click or drag over them. Clear effects removes everything at once.\n\nBrush radius under the panel — higher values mean a wider stroke.',
|
||||
|
||||
'help.section.presentation.title': 'Presentation screen',
|
||||
'help.section.presentation.body':
|
||||
@@ -1080,6 +1089,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.sunbeam': 'Sunbeam',
|
||||
'control.freeze': 'Freeze',
|
||||
'control.poisonCloud': 'Poison cloud',
|
||||
'control.explosion': 'Explosion',
|
||||
'control.brushRadius': 'Brush radius',
|
||||
'control.effectsSound': 'Effects sound',
|
||||
'control.storyLine': 'STORYLINE',
|
||||
|
||||
@@ -34,3 +34,11 @@ void test('ipc router: project.list does not require license', () => {
|
||||
const routerSrc = fs.readFileSync(path.join(here, '..', '..', '..', 'main', 'ipc', 'router.ts'), 'utf8');
|
||||
assert.match(routerSrc, /if \(channel === ipcChannels\.project\.list\) return false/);
|
||||
});
|
||||
|
||||
void test('projectState: openProject не выбирает сцену (selectedSceneId: null)', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'projectState.ts'), 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/const openProject = async[\s\S]+?openingProjectId: id, selectedSceneId: null[\s\S]+?selectedSceneId: null,\s*openingProjectId: null/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@ type Actions = {
|
||||
setMaterialRotation: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => Promise<void>;
|
||||
setMaterialLegend: (
|
||||
materialId: MaterialId,
|
||||
legend: import('../../shared/types').MaterialLegend | null,
|
||||
legend: import('../../../shared/types').MaterialLegend | null,
|
||||
) => Promise<void>;
|
||||
pickMaterialImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>;
|
||||
updateScene: (
|
||||
@@ -286,7 +286,8 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
invalidateAssetUrlCache();
|
||||
|
||||
const job = (async () => {
|
||||
setState((s) => ({ ...s, openingProjectId: id }));
|
||||
// При открытии не выбираем сцену: список/граф/инспектор без выделения.
|
||||
setState((s) => ({ ...s, openingProjectId: id, selectedSceneId: null }));
|
||||
try {
|
||||
const res = await api.invoke(ipcChannels.project.open, { projectId: id });
|
||||
if (projectDataEpochRef.current !== epoch) {
|
||||
@@ -296,7 +297,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
setState((s) => ({
|
||||
...s,
|
||||
project: res.project,
|
||||
selectedSceneId: res.project.currentSceneId,
|
||||
selectedSceneId: null,
|
||||
openingProjectId: null,
|
||||
}));
|
||||
} catch {
|
||||
@@ -538,7 +539,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
|
||||
const setMaterialLegend = async (
|
||||
materialId: MaterialId,
|
||||
legend: import('../../shared/types').MaterialLegend | null,
|
||||
legend: import('../../../shared/types').MaterialLegend | null,
|
||||
) => {
|
||||
const res = await api.invoke(ipcChannels.project.setMaterialLegend, { materialId, legend });
|
||||
// Список проектов не меняется — не дергаем refreshProjects на каждое обновление легенды.
|
||||
@@ -562,7 +563,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
previewVideoAutostart?: boolean;
|
||||
previewRotationDeg?: 0 | 90 | 180 | 270;
|
||||
darkenScene?: boolean;
|
||||
traps?: import('../../shared/types').SceneTrap[];
|
||||
traps?: import('../../../shared/types').SceneTrap[];
|
||||
settings?: Partial<Scene['settings']>;
|
||||
media?: Partial<Scene['media']>;
|
||||
layout?: { x: number; y: number };
|
||||
@@ -969,7 +970,8 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
setState((s) => ({
|
||||
...s,
|
||||
project: res.project,
|
||||
selectedSceneId: res.project?.currentSceneId ?? null,
|
||||
// Восстановление открытого проекта — без автовыбора сцены в редакторе.
|
||||
selectedSceneId: null,
|
||||
}));
|
||||
} catch {
|
||||
if (projectDataEpochRef.current !== epoch) return;
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,6 +3,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import { computeTimeSec } from '../../main/video/videoPlaybackStore';
|
||||
import type { SessionState } from '../../shared/ipc/contracts';
|
||||
|
||||
import { ExplosionVideoOverlay } from './effects/ExplosionVideoOverlay';
|
||||
import { PixiEffectsOverlay } from './effects/PxiEffectsOverlay';
|
||||
import { SceneDarknessOverlay } from './effects/SceneDarknessOverlay';
|
||||
import { useEffectsState } from './effects/useEffectsState';
|
||||
@@ -169,6 +170,9 @@ export function PresentationView({
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{showEffects && scene?.previewAssetType !== 'video' ? (
|
||||
<ExplosionVideoOverlay state={fxState} viewport={contentRect} />
|
||||
) : null}
|
||||
{scene?.previewAssetType === 'image' && contentRect ? (
|
||||
<SceneTrapsOverlay
|
||||
traps={scene.traps ?? []}
|
||||
@@ -186,9 +190,9 @@ export function PresentationView({
|
||||
embedded
|
||||
assetId={activeMaterial.assetId}
|
||||
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
|
||||
legendMarkers={
|
||||
activeMaterial.legend?.enabled ? (activeMaterial.legend.markers ?? []) : undefined
|
||||
}
|
||||
{...(activeMaterial.legend?.enabled
|
||||
? { legendMarkers: activeMaterial.legend.markers ?? [] }
|
||||
: {})}
|
||||
/>
|
||||
) : null}
|
||||
{activeMaterial?.legend?.enabled ? (
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
.layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video {
|
||||
position: absolute;
|
||||
height: auto;
|
||||
max-width: none;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
background: transparent;
|
||||
/* WebM VP8/VP9 с alpha_mode=1 — без чёрной подложки */
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/** HTML `<video>` для эффекта «Взрыв» — WebM с альфой (Pixi video/webp давали чёрный фон). */
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { EffectInstance, EffectsState, ExplosionInstance } from '../../../shared/types/effects';
|
||||
|
||||
import styles from './ExplosionVideoOverlay.module.css';
|
||||
|
||||
function explosionEffectVideoUrl(): string {
|
||||
return new URL('vfx/explosion/aerial-debris-smoke.webm', window.location.href).href;
|
||||
}
|
||||
|
||||
type Viewport = { x: number; y: number; w: number; h: number };
|
||||
|
||||
type Props = {
|
||||
state: EffectsState | null;
|
||||
viewport: Viewport | null | undefined;
|
||||
/** Draft с пульта (пока ведём кисть) — тоже через video с альфой. */
|
||||
draft?: ExplosionInstance | null;
|
||||
};
|
||||
|
||||
function isLiveExplosion(inst: EffectInstance, nowMs: number): inst is ExplosionInstance {
|
||||
if (inst.type !== 'explosion') return false;
|
||||
return nowMs - inst.createdAtMs < inst.lifetimeMs;
|
||||
}
|
||||
|
||||
export function ExplosionVideoOverlay({ state, viewport, draft = null }: Props) {
|
||||
const [nowMs, setNowMs] = useState(() => state?.serverNowMs ?? Date.now());
|
||||
|
||||
const explosions = useMemo(() => {
|
||||
const list = (state?.instances ?? []).filter((i): i is ExplosionInstance =>
|
||||
isLiveExplosion(i, nowMs),
|
||||
);
|
||||
if (draft && draft.type === 'explosion') {
|
||||
return [...list.filter((i) => i.id !== '__draft__'), draft];
|
||||
}
|
||||
return list;
|
||||
}, [draft, nowMs, state?.instances]);
|
||||
|
||||
useEffect(() => {
|
||||
if (explosions.length === 0) return;
|
||||
const id = window.setInterval(() => {
|
||||
setNowMs(state?.serverNowMs ?? Date.now());
|
||||
}, 100);
|
||||
return () => window.clearInterval(id);
|
||||
}, [explosions.length, state?.serverNowMs]);
|
||||
|
||||
useEffect(() => {
|
||||
setNowMs(state?.serverNowMs ?? Date.now());
|
||||
}, [state?.revision, state?.serverNowMs]);
|
||||
|
||||
if (!viewport || explosions.length === 0) return null;
|
||||
const minDim = Math.min(viewport.w, viewport.h);
|
||||
|
||||
return (
|
||||
<div className={styles.layer} aria-hidden>
|
||||
{explosions.map((inst) => {
|
||||
const sizePx = Math.max(16, inst.radiusN * minDim);
|
||||
const width = Math.max(sizePx * 4.2, minDim * 0.18);
|
||||
const isDraft = inst.id === '__draft__';
|
||||
return (
|
||||
<video
|
||||
key={inst.id}
|
||||
className={styles.video}
|
||||
style={{
|
||||
left: viewport.x + inst.at.x * viewport.w,
|
||||
top: viewport.y + inst.at.y * viewport.h,
|
||||
width,
|
||||
opacity: Math.max(0.35, Math.min(1, inst.intensity)),
|
||||
}}
|
||||
src={explosionEffectVideoUrl()}
|
||||
autoPlay={!isDraft}
|
||||
muted
|
||||
playsInline
|
||||
loop={false}
|
||||
preload="auto"
|
||||
ref={
|
||||
isDraft
|
||||
? (el) => {
|
||||
if (!el) return;
|
||||
el.pause();
|
||||
try {
|
||||
el.currentTime = 0.05;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -57,6 +57,9 @@ function vfxPacksForTool(tool: EffectToolType): readonly VfxFramePack[] {
|
||||
return ['sunbeam'];
|
||||
case 'poisonCloud':
|
||||
return ['poisonCloud'];
|
||||
case 'explosion':
|
||||
// Визуал — HTML `<video>` с альфой (ExplosionVideoOverlay), не кадровый Pixi-pack.
|
||||
return [];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
@@ -78,6 +81,8 @@ function vfxPacksForInstanceType(type: EffectInstanceType): readonly VfxFramePac
|
||||
return ['sunbeam'];
|
||||
case 'poisonCloud':
|
||||
return ['poisonCloud'];
|
||||
case 'explosion':
|
||||
return [];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
@@ -769,6 +774,13 @@ function createInstanceNode(
|
||||
redrawPoisonCloud(cont, inst, viewport, 0, Math.max(1, inst.lifetimeMs));
|
||||
return cont;
|
||||
}
|
||||
if (inst.type === 'explosion') {
|
||||
// Визуал рисует ExplosionVideoOverlay (WebM с альфой). Pixi-нода — только placeholder.
|
||||
const cont = new pixi.Container();
|
||||
cont.visible = false;
|
||||
(cont as any).__fx = { id: inst.id, type: inst.type };
|
||||
return cont;
|
||||
}
|
||||
if (inst.type === 'freeze') {
|
||||
const tex = getFreezeScreenTexture(pixi, inst.seed, viewport);
|
||||
const s = new pixi.Sprite(tex);
|
||||
@@ -988,6 +1000,12 @@ function animateNodes(
|
||||
redrawPoisonCloud(cont, inst, viewport, t, life);
|
||||
}
|
||||
|
||||
if (inst.type === 'explosion') {
|
||||
// Визуал — ExplosionVideoOverlay; Pixi-placeholder остаётся скрытым.
|
||||
node.visible = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inst.type === 'freeze') {
|
||||
const s = node;
|
||||
const life = Math.max(1, inst.lifetimeMs);
|
||||
@@ -1776,6 +1794,9 @@ function instanceContentSig(inst: EffectInstance): string {
|
||||
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 === 'explosion') {
|
||||
return `ex:${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)}`;
|
||||
}
|
||||
@@ -1913,6 +1934,11 @@ function relayoutInstanceNode(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (inst.type === 'explosion') {
|
||||
node.visible = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (inst.type === 'freeze') {
|
||||
const fx = (node as any).__fx ?? {};
|
||||
if (fx.vw !== viewport.w || fx.vh !== viewport.h) {
|
||||
|
||||
@@ -98,3 +98,14 @@
|
||||
transform: scale(1.6);
|
||||
}
|
||||
}
|
||||
|
||||
/** Одноразовый VFX ловушки на слое оверлея (не внутри круглого маркера).
|
||||
* Якорь кадра задаётся inline transform из trapActivationAnchorTransform. */
|
||||
.trapMediaFx {
|
||||
position: absolute;
|
||||
height: auto;
|
||||
max-width: none;
|
||||
pointer-events: none;
|
||||
z-index: 6;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,14 @@ import { createPortal } from 'react-dom';
|
||||
import type { SceneTrap, SceneTrapsState } from '../../../shared/types';
|
||||
import { defaultTrapRuntime } from '../../../shared/types/sceneTraps';
|
||||
|
||||
import {
|
||||
isTrapMediaActivationKind,
|
||||
playTrapActivationSound,
|
||||
trapActivationAnchorTransform,
|
||||
trapActivationLifeMs,
|
||||
trapActivationVideoUrl,
|
||||
type TrapMediaActivationKind,
|
||||
} from './trapActivation';
|
||||
import { TrapGlyph } from './TrapGlyph';
|
||||
import styles from './SceneTrapsOverlay.module.css';
|
||||
|
||||
@@ -18,6 +26,12 @@ type Props = {
|
||||
onDisarm?: (trapId: string) => void;
|
||||
};
|
||||
|
||||
type ActivationFx = {
|
||||
trapId: string;
|
||||
token: number;
|
||||
kind: TrapMediaActivationKind | 'flash';
|
||||
};
|
||||
|
||||
function menuPosition(clientX: number, clientY: number): { x: number; y: number } {
|
||||
const menuW = 200;
|
||||
const menuH = 140;
|
||||
@@ -38,15 +52,28 @@ export function SceneTrapsOverlay({
|
||||
onDisarm,
|
||||
}: Props) {
|
||||
const [menu, setMenu] = useState<{ trapId: string; x: number; y: number } | null>(null);
|
||||
const [flashToken, setFlashToken] = useState<{ trapId: string; token: number } | null>(null);
|
||||
const [activationFx, setActivationFx] = useState<ActivationFx | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const act = session?.lastActivation;
|
||||
if (!act) return;
|
||||
setFlashToken(act);
|
||||
const t = window.setTimeout(() => setFlashToken(null), 750);
|
||||
const trap = traps.find((t) => t.id === act.trapId);
|
||||
// poison/explosion: VFX/SFX через effects store (ControlApp), без локального flash/video.
|
||||
if (trap?.type === 'poison' || trap?.type === 'explosion') {
|
||||
setActivationFx(null);
|
||||
return;
|
||||
}
|
||||
const kind: ActivationFx['kind'] =
|
||||
trap && isTrapMediaActivationKind(trap.type) ? trap.type : 'flash';
|
||||
setActivationFx({ trapId: act.trapId, token: act.token, kind });
|
||||
if (kind !== 'flash' && mode === 'control') {
|
||||
// SFX только на пульте — иначе при двух окнах звук удвоится (как у прочих эффектов).
|
||||
playTrapActivationSound(kind);
|
||||
}
|
||||
const ms = kind !== 'flash' ? trapActivationLifeMs(kind) : 750;
|
||||
const t = window.setTimeout(() => setActivationFx(null), ms);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [session?.lastActivation?.token, session?.lastActivation?.trapId]);
|
||||
}, [session?.lastActivation?.token, session?.lastActivation?.trapId, traps, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
@@ -62,6 +89,16 @@ export function SceneTrapsOverlay({
|
||||
if (!viewport || traps.length === 0) return null;
|
||||
const minDim = Math.min(viewport.w, viewport.h);
|
||||
|
||||
const mediaTrap =
|
||||
activationFx && activationFx.kind !== 'flash'
|
||||
? traps.find((t) => t.id === activationFx.trapId)
|
||||
: undefined;
|
||||
const mediaSizePx = mediaTrap ? Math.max(16, mediaTrap.sizeN * minDim) : 0;
|
||||
/** Видео 16:9, центрируем на ловушке; ширина ~4× маркера, не меньше 18% кадра. */
|
||||
const mediaFxW = mediaTrap ? Math.max(mediaSizePx * 4.2, minDim * 0.18) : 0;
|
||||
const mediaKind =
|
||||
activationFx && activationFx.kind !== 'flash' ? activationFx.kind : null;
|
||||
|
||||
return (
|
||||
<div className={styles.layer}>
|
||||
{traps.map((trap) => {
|
||||
@@ -96,10 +133,34 @@ export function SceneTrapsOverlay({
|
||||
>
|
||||
<TrapGlyph type={trap.type} status={rt.status} size={Math.max(14, sizePx * 0.55)} />
|
||||
{trap.label ? <div className={styles.label}>{trap.label}</div> : null}
|
||||
{flashToken?.trapId === trap.id ? <div className={styles.flash} /> : null}
|
||||
{activationFx?.trapId === trap.id && activationFx.kind === 'flash' ? (
|
||||
<div className={styles.flash} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{mediaTrap && activationFx && mediaKind ? (
|
||||
<video
|
||||
key={activationFx.token}
|
||||
className={styles.trapMediaFx}
|
||||
style={{
|
||||
left: viewport.x + mediaTrap.nx * viewport.w,
|
||||
top: viewport.y + mediaTrap.ny * viewport.h,
|
||||
width: mediaFxW,
|
||||
transform: trapActivationAnchorTransform(mediaKind),
|
||||
}}
|
||||
src={trapActivationVideoUrl(mediaKind)}
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
preload="auto"
|
||||
onEnded={() => {
|
||||
setActivationFx((cur) =>
|
||||
cur?.token === activationFx.token && cur.kind === mediaKind ? null : cur,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{menu && mode === 'control'
|
||||
? createPortal(
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Простые SVG-иконки ловушек (MVP). */
|
||||
|
||||
import type { SceneTrapStatus, SceneTrapType } from '../../shared/types';
|
||||
import type { SceneTrapStatus, SceneTrapType } from '../../../shared/types';
|
||||
|
||||
const TYPE_COLOR: Record<SceneTrapType, string> = {
|
||||
mimic: '#c4783a',
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Реэкспорт для совместимости; канонический модуль — `trapActivation.ts`. */
|
||||
export {
|
||||
MIMIC_ACTIVATION_MS,
|
||||
mimicActivationVideoUrl,
|
||||
playMimicActivationSound,
|
||||
} from './trapActivation';
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const publicRoot = path.resolve(here, '../../public');
|
||||
|
||||
void test('trap activation assets exist in public/ (mimic/pit/arrow/laser; explosion shared with effects)', () => {
|
||||
const video = path.join(publicRoot, 'vfx/mimic/tentacle-at-camera.webm');
|
||||
const sfx = path.join(publicRoot, 'mimic.mp3');
|
||||
assert.ok(fs.existsSync(video), `missing ${video}`);
|
||||
assert.ok(fs.existsSync(sfx), `missing ${sfx}`);
|
||||
assert.ok(fs.statSync(video).size > 10_000, 'mimic webm too small');
|
||||
assert.ok(fs.statSync(sfx).size > 1_000, 'mimic mp3 too small');
|
||||
|
||||
const pitVideo = path.join(publicRoot, 'vfx/pit/infinity-portal.webm');
|
||||
const pitSfx = path.join(publicRoot, 'pit.mp3');
|
||||
assert.ok(fs.existsSync(pitVideo), `missing ${pitVideo}`);
|
||||
assert.ok(fs.existsSync(pitSfx), `missing ${pitSfx}`);
|
||||
assert.ok(fs.statSync(pitVideo).size > 10_000, 'pit webm too small');
|
||||
assert.ok(fs.statSync(pitSfx).size > 1_000, 'pit mp3 too small');
|
||||
|
||||
const arrowVideo = path.join(publicRoot, 'vfx/arrow/arrows-grounding.webm');
|
||||
const arrowSfx = path.join(publicRoot, 'arrow.mp3');
|
||||
assert.ok(fs.existsSync(arrowVideo), `missing ${arrowVideo}`);
|
||||
assert.ok(fs.existsSync(arrowSfx), `missing ${arrowSfx}`);
|
||||
assert.ok(fs.statSync(arrowVideo).size > 10_000, 'arrow webm too small');
|
||||
assert.ok(fs.statSync(arrowSfx).size > 1_000, 'arrow mp3 too small');
|
||||
|
||||
const laserVideo = path.join(publicRoot, 'vfx/laser/eye-lasers.webm');
|
||||
const laserSfx = path.join(publicRoot, 'laser.mp3');
|
||||
assert.ok(fs.existsSync(laserVideo), `missing ${laserVideo}`);
|
||||
assert.ok(fs.existsSync(laserSfx), `missing ${laserSfx}`);
|
||||
assert.ok(fs.statSync(laserVideo).size > 10_000, 'laser webm too small');
|
||||
assert.ok(fs.statSync(laserSfx).size > 1_000, 'laser mp3 too small');
|
||||
|
||||
const exVideo = path.join(publicRoot, 'vfx/explosion/aerial-debris-smoke.webm');
|
||||
const exSfx = path.join(publicRoot, 'explosion.mp3');
|
||||
assert.ok(fs.existsSync(exVideo), `missing ${exVideo}`);
|
||||
assert.ok(fs.existsSync(exSfx), `missing ${exSfx}`);
|
||||
});
|
||||
|
||||
void test('trapActivation registry covers mimic + pit + arrow + laser (explosion via effects)', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'trapActivation.ts'), 'utf8');
|
||||
assert.match(src, /vfx\/mimic\/tentacle-at-camera\.webm/);
|
||||
assert.match(src, /mimic\.mp3/);
|
||||
assert.match(src, /vfx\/pit\/infinity-portal\.webm/);
|
||||
assert.match(src, /pit\.mp3/);
|
||||
assert.match(src, /vfx\/arrow\/arrows-grounding\.webm/);
|
||||
assert.match(src, /arrow\.mp3/);
|
||||
assert.match(src, /vfx\/laser\/eye-lasers\.webm/);
|
||||
assert.match(src, /laser\.mp3/);
|
||||
assert.match(src, /playTrapActivationSound/);
|
||||
assert.match(src, /isTrapMediaActivationKind/);
|
||||
assert.ok(!src.includes("videoPath: 'vfx/explosion"), 'explosion should not be local trap media');
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/** Одноразовая активация ловушек с собственным VFX/SFX (`public/vfx/<type>/…` + `public/<type>.mp3`).
|
||||
* Яд/взрыв идут через effects store (как инструменты пульта) — см. ControlApp.
|
||||
*/
|
||||
|
||||
import type { SceneTrapType } from '../../../shared/types';
|
||||
import { getEffectsSfxGain } from '../../control/effectsSfxGain';
|
||||
|
||||
export type TrapMediaActivationKind = 'mimic' | 'pit' | 'arrow' | 'laser';
|
||||
|
||||
type TrapMediaSpec = {
|
||||
videoPath: string;
|
||||
soundPath: string;
|
||||
/** Длительность видео + запас на старт (мс). */
|
||||
lifeMs: number;
|
||||
sfxVolume: number;
|
||||
/**
|
||||
* Точка кадра (0…1), которая совпадает с центром ловушки.
|
||||
* По умолчанию центр; у лазера — правый конец луча.
|
||||
*/
|
||||
anchorNx?: number;
|
||||
anchorNy?: number;
|
||||
};
|
||||
|
||||
const TRAP_MEDIA: Record<TrapMediaActivationKind, TrapMediaSpec> = {
|
||||
mimic: {
|
||||
videoPath: 'vfx/mimic/tentacle-at-camera.webm',
|
||||
soundPath: 'mimic.mp3',
|
||||
lifeMs: 4500,
|
||||
sfxVolume: 0.9,
|
||||
},
|
||||
pit: {
|
||||
videoPath: 'vfx/pit/infinity-portal.webm',
|
||||
soundPath: 'pit.mp3',
|
||||
lifeMs: 6000,
|
||||
sfxVolume: 0.9,
|
||||
},
|
||||
arrow: {
|
||||
videoPath: 'vfx/arrow/arrows-grounding.webm',
|
||||
soundPath: 'arrow.mp3',
|
||||
lifeMs: 2000,
|
||||
sfxVolume: 0.9,
|
||||
},
|
||||
laser: {
|
||||
videoPath: 'vfx/laser/eye-lasers.webm',
|
||||
soundPath: 'laser.mp3',
|
||||
lifeMs: 1800,
|
||||
sfxVolume: 0.9,
|
||||
// правый кончик луча в кадре ≈ (0.82, 0.39)
|
||||
anchorNx: 0.82,
|
||||
anchorNy: 0.39,
|
||||
},
|
||||
};
|
||||
|
||||
export function isTrapMediaActivationKind(type: SceneTrapType): type is TrapMediaActivationKind {
|
||||
return type === 'mimic' || type === 'pit' || type === 'arrow' || type === 'laser';
|
||||
}
|
||||
|
||||
export function trapActivationLifeMs(kind: TrapMediaActivationKind): number {
|
||||
return TRAP_MEDIA[kind].lifeMs;
|
||||
}
|
||||
|
||||
export function trapActivationVideoUrl(kind: TrapMediaActivationKind): string {
|
||||
return new URL(TRAP_MEDIA[kind].videoPath, window.location.href).href;
|
||||
}
|
||||
|
||||
/** CSS transform, чтобы `anchor` кадра оказался в точке позиционирования. */
|
||||
export function trapActivationAnchorTransform(kind: TrapMediaActivationKind): string {
|
||||
const spec = TRAP_MEDIA[kind];
|
||||
const ax = Math.max(0, Math.min(1, spec.anchorNx ?? 0.5));
|
||||
const ay = Math.max(0, Math.min(1, spec.anchorNy ?? 0.5));
|
||||
return `translate(${(-ax * 100).toFixed(2)}%, ${(-ay * 100).toFixed(2)}%)`;
|
||||
}
|
||||
|
||||
export function playTrapActivationSound(kind: TrapMediaActivationKind): void {
|
||||
try {
|
||||
const spec = TRAP_MEDIA[kind];
|
||||
const el = new Audio(new URL(spec.soundPath, window.location.href).href);
|
||||
el.volume = Math.max(0, Math.min(1, spec.sfxVolume * getEffectsSfxGain()));
|
||||
void el.play().catch(() => undefined);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated используй trapActivation* */
|
||||
export const MIMIC_ACTIVATION_MS = TRAP_MEDIA.mimic.lifeMs;
|
||||
export function mimicActivationVideoUrl(): string {
|
||||
return trapActivationVideoUrl('mimic');
|
||||
}
|
||||
export function playMimicActivationSound(): void {
|
||||
playTrapActivationSound('mimic');
|
||||
}
|
||||
@@ -52,7 +52,8 @@ export function minDistSqEffectToPoint(inst: EffectInstance, p: { x: number; y:
|
||||
case 'scorch':
|
||||
case 'ice':
|
||||
case 'shadow':
|
||||
case 'poisonCloud': {
|
||||
case 'poisonCloud':
|
||||
case 'explosion': {
|
||||
const dx = inst.at.x - p.x;
|
||||
const dy = inst.at.y - p.y;
|
||||
return dx * dx + dy * dy;
|
||||
@@ -67,7 +68,8 @@ function eraseHitThresholdSq(inst: EffectInstance, toolRadiusN: number): number
|
||||
inst.type === 'scorch' ||
|
||||
inst.type === 'ice' ||
|
||||
inst.type === 'shadow' ||
|
||||
inst.type === 'poisonCloud'
|
||||
inst.type === 'poisonCloud' ||
|
||||
inst.type === 'explosion'
|
||||
) {
|
||||
const r = toolRadiusN + inst.radiusN;
|
||||
return r * r;
|
||||
|
||||
@@ -7,6 +7,7 @@ export type EffectToolType =
|
||||
| 'lightning'
|
||||
| 'sunbeam'
|
||||
| 'poisonCloud'
|
||||
| 'explosion'
|
||||
| 'freeze'
|
||||
| 'exploreBrush'
|
||||
| 'eraser';
|
||||
@@ -20,6 +21,7 @@ export type EffectInstanceType =
|
||||
| 'lightning'
|
||||
| 'sunbeam'
|
||||
| 'poisonCloud'
|
||||
| 'explosion'
|
||||
| 'freeze'
|
||||
| 'scorch'
|
||||
| 'ice'
|
||||
@@ -104,6 +106,15 @@ export type PoisonCloudInstance = EffectInstanceBase & {
|
||||
lifetimeMs: number;
|
||||
};
|
||||
|
||||
/** «Взрыв» — one-shot webm в точке удара (тот же ассет, что у ловушки). */
|
||||
export type ExplosionInstance = EffectInstanceBase & {
|
||||
type: 'explosion';
|
||||
at: { x: number; y: number };
|
||||
radiusN: number;
|
||||
intensity: number;
|
||||
lifetimeMs: number;
|
||||
};
|
||||
|
||||
export type FreezeInstance = EffectInstanceBase & {
|
||||
type: 'freeze';
|
||||
at: { x: number; y: number };
|
||||
@@ -148,6 +159,7 @@ export type EffectInstance =
|
||||
| LightningInstance
|
||||
| SunbeamInstance
|
||||
| PoisonCloudInstance
|
||||
| ExplosionInstance
|
||||
| FreezeInstance
|
||||
| ScorchInstance
|
||||
| IceInstance
|
||||
|
||||
+1
-1
@@ -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/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs",
|
||||
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs",
|
||||
"format": "prettier . --check",
|
||||
"format:write": "prettier . --write",
|
||||
"postinstall": "patch-package",
|
||||
|
||||
Reference in New Issue
Block a user