feat(effects): add Closing brush as inverse of Opening brush

Allow covering revealed darkness again during darkened scene sessions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-30 17:21:40 +08:00
parent a3a03eb9e3
commit 41b112159f
7 changed files with 108 additions and 19 deletions
+36 -1
View File
@@ -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');
});
+31 -5
View File
@@ -179,6 +179,7 @@ export function ControlApp() {
| 'explosion'
| 'freeze'
| 'exploreBrush'
| 'closeBrush'
| 'eraser';
startN?: { x: number; y: number };
points?: { x: number; y: number; tMs: number }[];
@@ -1038,10 +1039,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',
},
});
}
});
@@ -1190,7 +1198,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: {
@@ -1199,6 +1211,7 @@ export function ControlApp() {
createdAtMs,
points: b.points,
radiusN: tool.radiusN,
mode: b.tool === 'closeBrush' ? 'cover' : 'reveal',
},
});
}
@@ -1600,6 +1613,15 @@ export function ControlApp() {
>
<span className={styles.iconGlyph}>🔦</span>
</Button>
<Button
variant={tool.tool === 'closeBrush' ? 'primary' : 'ghost'}
iconOnly
title={t('control.closerBrush')}
ariaLabel={t('control.closerBrush')}
onClick={() => selectEffectTool('closeBrush')}
>
<span className={styles.iconGlyph}></span>
</Button>
</div>
</div>
) : null}
@@ -1798,12 +1820,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',
},
});
}
@@ -1882,7 +1905,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;
@@ -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')"));
+6 -4
View File
@@ -219,11 +219,11 @@ 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':
'«Презентация» — то, что видят игроки: картинка сцены (с учётом поворота из редактора) или видео по вашим настройкам.\n\nЭффекты с пульта накладываются поверх. Меню и кнопки мастера здесь не показываются — клики по карте, ловушкам и токенам для игроков недоступны.\n\nЕсли у сцены включено «Затемнить сцену», игроки сначала видят полностью чёрный экран. Мастер открывает карту «Кистью Открытия» на пульте.\n\nПри смене сцены с пульта картинка обновляется сама. Перенесите окно на экран для игроков и при необходимости спрячьте панель задач.\n\nПустой или тёмный экран — скорее всего, у сцены нет превью. Добавьте картинку в свойствах сцены в редакторе.',
'«Презентация» — то, что видят игроки: картинка сцены (с учётом поворота из редактора) или видео по вашим настройкам.\n\nЭффекты с пульта накладываются поверх. Меню и кнопки мастера здесь не показываются — клики по карте, ловушкам и токенам для игроков недоступны.\n\nЕсли у сцены включено «Затемнить сцену», игроки сначала видят полностью чёрный экран. Мастер открывает карту «Кистью Открытия» и при необходимости снова закрывает участки «Кистью Закрытия» на пульте.\n\nПри смене сцены с пульта картинка обновляется сама. Перенесите окно на экран для игроков и при необходимости спрячьте панель задач.\n\nПустой или тёмный экран — скорее всего, у сцены нет превью. Добавьте картинку в свойствах сцены в редакторе.',
'help.section.importExport.title': 'Импорт и экспорт',
'help.section.importExport.body':
@@ -563,6 +563,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.darkness': 'Тьма',
'control.darknessControl': 'Управление затемнением',
'control.explorerBrush': 'Кисть Открытия',
'control.closerBrush': 'Кисть Закрытия',
'control.lightning': 'Молния',
'control.sunbeam': 'Луч света',
'control.freeze': 'Заморозка',
@@ -787,11 +788,11 @@ 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, 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':
@@ -1131,6 +1132,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.darkness': 'Darkness',
'control.darknessControl': 'Darkness control',
'control.explorerBrush': 'Opening brush',
'control.closerBrush': 'Closing brush',
'control.lightning': 'Lightning',
'control.sunbeam': 'Sunbeam',
'control.freeze': 'Freeze',
@@ -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]);
+1
View File
@@ -11,6 +11,7 @@ export type EffectToolType =
| 'explosion'
| 'freeze'
| 'exploreBrush'
| 'closeBrush'
| 'eraser';
export type EffectInstanceType =
+17 -2
View File
@@ -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';
}