From 0f01950cc1b9cf50f38d6e658cc4b1928c3296da Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Thu, 30 Jul 2026 17:21:40 +0800 Subject: [PATCH] feat(effects): add Closing brush as inverse of Opening brush Allow covering revealed darkness again during darkened scene sessions. Co-authored-by: Cursor --- app/main/effects/sceneDarknessStore.test.ts | 37 ++++++++++++++++++- app/renderer/control/ControlApp.tsx | 36 +++++++++++++++--- .../control/controlApp.effectsPanel.test.ts | 3 ++ app/renderer/editor/i18n/editorMessages.ts | 10 +++-- .../shared/effects/SceneDarknessOverlay.tsx | 21 +++++++---- app/shared/types/effects.ts | 1 + app/shared/types/sceneDarkness.ts | 19 +++++++++- 7 files changed, 108 insertions(+), 19 deletions(-) diff --git a/app/main/effects/sceneDarknessStore.test.ts b/app/main/effects/sceneDarknessStore.test.ts index 658875f..4ddaacd 100644 --- a/app/main/effects/sceneDarknessStore.test.ts +++ b/app/main/effects/sceneDarknessStore.test.ts @@ -48,9 +48,10 @@ void test('SceneDarknessStore: draft синхронизируется и сбр store.switchScene('scene_a', true); store.dispatch({ kind: 'draft.set', - draft: { points: [{ x: 0.1, y: 0.1, tMs: 1 }], radiusN: 0.05 }, + draft: { points: [{ x: 0.1, y: 0.1, tMs: 1 }], radiusN: 0.05, mode: 'cover' }, }); assert.ok(store.getState().draft); + assert.equal(store.getState().draft?.mode, 'cover'); store.dispatch({ kind: 'stroke.add', stroke: { @@ -59,8 +60,42 @@ void test('SceneDarknessStore: draft синхронизируется и сбр createdAtMs: 100, points: [{ x: 0.1, y: 0.1, tMs: 1 }], radiusN: 0.05, + mode: 'cover', }, }); assert.equal(store.getState().draft, null); assert.equal(store.getState().strokes.length, 1); }); + +void test('SceneDarknessStore: cover-штрих сохраняется в кэше сцены', () => { + const store = new SceneDarknessStore(); + store.switchScene('scene_a', true); + store.dispatch({ + kind: 'stroke.add', + stroke: { + id: 'open1', + seed: 1, + createdAtMs: 100, + points: [{ x: 0.5, y: 0.5, tMs: 100 }], + radiusN: 0.08, + mode: 'reveal', + }, + }); + store.dispatch({ + kind: 'stroke.add', + stroke: { + id: 'close1', + seed: 2, + createdAtMs: 200, + points: [{ x: 0.5, y: 0.5, tMs: 200 }], + radiusN: 0.08, + mode: 'cover', + }, + }); + assert.equal(store.getState().strokes.length, 2); + assert.equal(store.getState().strokes[1]?.mode, 'cover'); + store.switchScene('scene_b', true); + store.switchScene('scene_a', true); + assert.equal(store.getState().strokes.length, 2); + assert.equal(store.getState().strokes[1]?.mode, 'cover'); +}); diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index 8e46730..80204eb 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -197,6 +197,7 @@ export function ControlApp() { | 'explosion' | 'freeze' | 'exploreBrush' + | 'closeBrush' | 'eraser'; startN?: { x: number; y: number }; points?: { x: number; y: number; tMs: number }[]; @@ -1055,10 +1056,17 @@ export function ControlApp() { draftPaintRafRef.current = 0; pushDraftToPixi(); const b = brushRef.current; - if (b?.tool === 'exploreBrush' && b.points) { + if ( + (b?.tool === 'exploreBrush' || b?.tool === 'closeBrush') && + b.points + ) { void sd.dispatch({ kind: 'draft.set', - draft: { points: b.points, radiusN: toolRef.current.radiusN }, + draft: { + points: b.points, + radiusN: toolRef.current.radiusN, + mode: b.tool === 'closeBrush' ? 'cover' : 'reveal', + }, }); } }); @@ -1207,7 +1215,11 @@ export function ControlApp() { }, }); } - if (b.tool === 'exploreBrush' && b.points && b.points.length > 0) { + if ( + (b.tool === 'exploreBrush' || b.tool === 'closeBrush') && + b.points && + b.points.length > 0 + ) { await sd.dispatch({ kind: 'stroke.add', stroke: { @@ -1216,6 +1228,7 @@ export function ControlApp() { createdAtMs, points: b.points, radiusN: tool.radiusN, + mode: b.tool === 'closeBrush' ? 'cover' : 'reveal', }, }); } @@ -1619,6 +1632,15 @@ export function ControlApp() { > 🔦 + ) : null} @@ -1850,12 +1872,13 @@ export function ControlApp() { if (tool.tool === 'water' || tool.tool === 'rain') { extinguishFireAlong([startPt]); } - if (tool.tool === 'exploreBrush') { + if (tool.tool === 'exploreBrush' || tool.tool === 'closeBrush') { void sd.dispatch({ kind: 'draft.set', draft: { points: [startPt], radiusN: tool.radiusN, + mode: tool.tool === 'closeBrush' ? 'cover' : 'reveal', }, }); } @@ -1932,7 +1955,10 @@ export function ControlApp() { scenePanRef.current = null; return; } - if (brushRef.current?.tool === 'exploreBrush') { + if ( + brushRef.current?.tool === 'exploreBrush' || + brushRef.current?.tool === 'closeBrush' + ) { void sd.dispatch({ kind: 'draft.set', draft: null }); } brushRef.current = null; diff --git a/app/renderer/control/controlApp.effectsPanel.test.ts b/app/renderer/control/controlApp.effectsPanel.test.ts index 1e4b951..ee471d3 100644 --- a/app/renderer/control/controlApp.effectsPanel.test.ts +++ b/app/renderer/control/controlApp.effectsPanel.test.ts @@ -87,6 +87,9 @@ void test('ControlApp: эффекты в пульте, иконки с тулт assert.ok(src.includes("t('control.actionEffects')")); assert.ok(src.includes("t('control.darknessControl')")); assert.ok(src.includes("t('control.explorerBrush')")); + assert.ok(src.includes("t('control.closerBrush')")); + assert.ok(src.includes("tool: 'closeBrush'") || src.includes("selectEffectTool('closeBrush')")); + assert.ok(src.includes("mode: b.tool === 'closeBrush' ? 'cover' : 'reveal'") || src.includes("'cover'")); assert.ok(src.includes('SceneDarknessOverlay')); assert.ok(src.includes('useSceneDarknessState')); assert.ok(src.includes("t('control.sunbeam')")); diff --git a/app/renderer/editor/i18n/editorMessages.ts b/app/renderer/editor/i18n/editorMessages.ts index 1679997..82da805 100644 --- a/app/renderer/editor/i18n/editorMessages.ts +++ b/app/renderer/editor/i18n/editorMessages.ts @@ -231,11 +231,11 @@ export const EDITOR_MESSAGES: Record> = { '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': - '«Презентация» — то, что видят игроки: картинка сцены (с учётом поворота из редактора) или видео по вашим настройкам.\n\nЭффекты с пульта накладываются поверх. Меню и кнопки мастера здесь не показываются — клики по карте, ловушкам и токенам для игроков недоступны.\n\nЕсли у сцены включено «Затемнить сцену», игроки сначала видят полностью чёрный экран. Мастер открывает карту «Кистью Открытия» на пульте.\n\nПри смене сцены с пульта картинка обновляется сама. Перенесите окно на экран для игроков и при необходимости спрячьте панель задач.\n\nПустой или тёмный экран — скорее всего, у сцены нет превью. Добавьте картинку в свойствах сцены в редакторе.', + '«Презентация» — то, что видят игроки: картинка сцены (с учётом поворота из редактора) или видео по вашим настройкам.\n\nЭффекты с пульта накладываются поверх. Меню и кнопки мастера здесь не показываются — клики по карте, ловушкам и токенам для игроков недоступны.\n\nЕсли у сцены включено «Затемнить сцену», игроки сначала видят полностью чёрный экран. Мастер открывает карту «Кистью Открытия» и при необходимости снова закрывает участки «Кистью Закрытия» на пульте.\n\nПри смене сцены с пульта картинка обновляется сама. Перенесите окно на экран для игроков и при необходимости спрячьте панель задач.\n\nПустой или тёмный экран — скорее всего, у сцены нет превью. Добавьте картинку в свойствах сцены в редакторе.', 'help.section.importExport.title': 'Импорт и экспорт', 'help.section.importExport.body': @@ -607,6 +607,7 @@ export const EDITOR_MESSAGES: Record> = { 'control.darkness': 'Тьма', 'control.darknessControl': 'Управление затемнением', 'control.explorerBrush': 'Кисть Открытия', + 'control.closerBrush': 'Кисть Закрытия', 'control.lightning': 'Молния', 'control.sunbeam': 'Луч света', 'control.freeze': 'Заморозка', @@ -846,11 +847,11 @@ export const EDITOR_MESSAGES: Record> = { '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, 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.', + '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 🔦 and Closing brush ⬛. Opening brush clears darkness on both screens at once; Closing brush covers revealed areas again. Unrevealed areas stay fully black for players and half-dark on your preview. The state is 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': - 'Presentation is what players see: the scene image (with rotation from the editor) or video according to your settings.\n\nEffects from the control panel draw on top. There are no GM menus or buttons here — players cannot click the map, traps, or tokens.\n\nIf Darken scene is enabled, players first see a fully black screen. The GM reveals the map with the Opening brush on the control panel.\n\nWhen you switch scenes from the control panel, the image updates automatically. Move the window to the display players watch and hide the taskbar if needed.\n\nA blank or dark screen usually means the scene has no preview — add one in scene properties in the editor.', + 'Presentation is what players see: the scene image (with rotation from the editor) or video according to your settings.\n\nEffects from the control panel draw on top. There are no GM menus or buttons here — players cannot click the map, traps, or tokens.\n\nIf Darken scene is enabled, players first see a fully black screen. The GM reveals the map with the Opening brush and can cover areas again with the Closing brush on the control panel.\n\nWhen you switch scenes from the control panel, the image updates automatically. Move the window to the display players watch and hide the taskbar if needed.\n\nA blank or dark screen usually means the scene has no preview — add one in scene properties in the editor.', 'help.section.importExport.title': 'Import and export', 'help.section.importExport.body': @@ -1224,6 +1225,7 @@ export const EDITOR_MESSAGES: Record> = { 'control.darkness': 'Darkness', 'control.darknessControl': 'Darkness control', 'control.explorerBrush': 'Opening brush', + 'control.closerBrush': 'Closing brush', 'control.lightning': 'Lightning', 'control.sunbeam': 'Sunbeam', 'control.freeze': 'Freeze', diff --git a/app/renderer/shared/effects/SceneDarknessOverlay.tsx b/app/renderer/shared/effects/SceneDarknessOverlay.tsx index 984f52b..170c47b 100644 --- a/app/renderer/shared/effects/SceneDarknessOverlay.tsx +++ b/app/renderer/shared/effects/SceneDarknessOverlay.tsx @@ -1,6 +1,11 @@ import React, { useEffect, useRef } from 'react'; -import type { SceneDarknessRevealStroke, SceneDarknessState } from '../../../shared/types'; +import type { + SceneDarknessRevealStroke, + SceneDarknessState, + SceneDarknessStrokeMode, +} from '../../../shared/types'; +import { normalizeSceneDarknessStrokeMode } from '../../../shared/types/sceneDarkness'; export type SceneDarknessOverlayProps = { state: SceneDarknessState | null; @@ -10,14 +15,18 @@ export type SceneDarknessOverlayProps = { style?: React.CSSProperties; }; -function drawRevealStroke( +function drawDarknessStroke( ctx: CanvasRenderingContext2D, - stroke: SceneDarknessRevealStroke | { points: { x: number; y: number }[]; radiusN: number }, + stroke: SceneDarknessRevealStroke | { points: { x: number; y: number }[]; radiusN: number; mode?: SceneDarknessStrokeMode }, w: number, h: number, ): void { const pts = stroke.points; if (pts.length === 0) return; + const mode = normalizeSceneDarknessStrokeMode(stroke.mode); + ctx.globalCompositeOperation = mode === 'cover' ? 'source-over' : 'destination-out'; + ctx.fillStyle = '#000000'; + ctx.strokeStyle = '#000000'; const r = stroke.radiusN * Math.min(w, h); if (pts.length === 1) { const p = pts[0]; @@ -30,7 +39,6 @@ function drawRevealStroke( ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.lineWidth = r * 2; - ctx.strokeStyle = 'rgba(0,0,0,1)'; ctx.beginPath(); const first = pts[0]; if (!first) return; @@ -65,12 +73,11 @@ export function SceneDarknessOverlay({ ctx.fillStyle = '#000000'; ctx.fillRect(0, 0, w, h); - ctx.globalCompositeOperation = 'destination-out'; for (const stroke of state.strokes) { - drawRevealStroke(ctx, stroke, w, h); + drawDarknessStroke(ctx, stroke, w, h); } if (state.draft && state.draft.points.length > 0) { - drawRevealStroke(ctx, state.draft, w, h); + drawDarknessStroke(ctx, state.draft, w, h); } }, [state, viewport]); diff --git a/app/shared/types/effects.ts b/app/shared/types/effects.ts index f08f4ef..e50221f 100644 --- a/app/shared/types/effects.ts +++ b/app/shared/types/effects.ts @@ -11,6 +11,7 @@ export type EffectToolType = | 'explosion' | 'freeze' | 'exploreBrush' + | 'closeBrush' | 'eraser'; export type EffectInstanceType = diff --git a/app/shared/types/sceneDarkness.ts b/app/shared/types/sceneDarkness.ts index cb56989..927fe21 100644 --- a/app/shared/types/sceneDarkness.ts +++ b/app/shared/types/sceneDarkness.ts @@ -1,11 +1,22 @@ import type { NPoint } from './effects'; +/** reveal — снимает тьму (Кисть Открытия); cover — возвращает тьму (Кисть Закрытия). */ +export type SceneDarknessStrokeMode = 'reveal' | 'cover'; + export type SceneDarknessRevealStroke = { id: string; seed: number; createdAtMs: number; points: NPoint[]; radiusN: number; + /** По умолчанию reveal (старые штрихи без поля). */ + mode?: SceneDarknessStrokeMode; +}; + +export type SceneDarknessDraft = { + points: NPoint[]; + radiusN: number; + mode?: SceneDarknessStrokeMode; }; export type SceneDarknessState = { @@ -14,9 +25,13 @@ export type SceneDarknessState = { enabled: boolean; cacheKey: string | null; strokes: SceneDarknessRevealStroke[]; - draft: { points: NPoint[]; radiusN: number } | null; + draft: SceneDarknessDraft | null; }; export type SceneDarknessEvent = - | { kind: 'draft.set'; draft: { points: NPoint[]; radiusN: number } | null } + | { kind: 'draft.set'; draft: SceneDarknessDraft | null } | { kind: 'stroke.add'; stroke: SceneDarknessRevealStroke }; + +export function normalizeSceneDarknessStrokeMode(raw: unknown): SceneDarknessStrokeMode { + return raw === 'cover' ? 'cover' : 'reveal'; +}