feat: save games and edit scene darkness

Persist show state to local saves; author map darkness in scene editor with bake-to-scene and help updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-10-06 11:41:42 +08:00
parent f6c301a12b
commit 877fbaa8e7
43 changed files with 2086 additions and 72 deletions
@@ -160,6 +160,50 @@
margin-top: 4px;
}
.modalFooterWrap {
flex-wrap: wrap;
}
.saveList {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 240px;
overflow: auto;
}
.saveRow {
text-align: left;
padding: 8px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
}
.saveRowSelected {
border-color: var(--scene-list-selected-border);
background: var(--scene-list-selected-bg);
}
.saveNameInput {
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--color-surface);
color: inherit;
font: inherit;
}
.saveError {
color: var(--color-danger);
font-size: 13px;
}
.descriptionViewBody {
min-height: 360px;
max-height: min(560px, calc(100vh - 200px));
+197 -14
View File
@@ -5,6 +5,7 @@ import { pickEraseTargetId } from '../../shared/effectEraserHitTest';
import { fitAspectRect } from '../../shared/geometry/fitAspectRect';
import { ipcChannels } from '../../shared/ipc/contracts';
import type { SessionState } from '../../shared/ipc/contracts';
import type { GameSaveListItem } from '../../shared/types/gameSave';
import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures';
import {
isNodeInMainStoryline,
@@ -204,6 +205,14 @@ export function ControlApp() {
placementId: string;
} | null>(null);
const [stopConfirmOpen, setStopConfirmOpen] = useState(false);
const [stopDirty, setStopDirty] = useState(false);
const [saveOpen, setSaveOpen] = useState(false);
const [saveEndAfter, setSaveEndAfter] = useState(false);
const [saveItems, setSaveItems] = useState<GameSaveListItem[]>([]);
const [saveName, setSaveName] = useState('');
const [saveSelectedId, setSaveSelectedId] = useState<string | null>(null);
const [saveError, setSaveError] = useState('');
const [saveBusy, setSaveBusy] = useState(false);
const [sceneView, sceneViewApi] = useSceneViewState();
const [sceneViewDraft, setSceneViewDraft] = useState<SceneViewCamera | null>(null);
const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
@@ -1156,26 +1165,74 @@ export function ControlApp() {
};
}, [npcMapSpawnDrag.dragging, npcMapSpawnDragApi, snapNormActive]);
const openStopConfirm = useCallback(() => {
void api.invoke(ipcChannels.gameSave.isDirty, {}).then(
({ dirty }) => {
setStopDirty(dirty);
setStopConfirmOpen(true);
},
() => {
setStopDirty(true);
setStopConfirmOpen(true);
},
);
}, [api]);
const openSaveModal = useCallback((endAfter: boolean) => {
setSaveEndAfter(endAfter);
setSaveName('');
setSaveSelectedId(null);
setSaveError('');
setSaveOpen(true);
void api.invoke(ipcChannels.gameSave.list, {}).then(
({ saves }) => setSaveItems(saves),
() => setSaveItems([]),
);
}, [api]);
const commitSave = useCallback(() => {
if (saveBusy) return;
setSaveBusy(true);
setSaveError('');
void api
.invoke(ipcChannels.gameSave.write, { id: saveSelectedId, name: saveName })
.then(() => {
setSaveOpen(false);
setSaveBusy(false);
if (saveEndAfter) {
void api.invoke(ipcChannels.windows.closeMultiWindow, {});
}
})
.catch((err: unknown) => {
setSaveBusy(false);
setSaveError(err instanceof Error ? err.message : String(err));
});
}, [api, saveBusy, saveEndAfter, saveName, saveSelectedId]);
useEffect(() => {
return api.on(ipcChannels.windows.closeMultiWindowConfirmRequested, () => {
setStopConfirmOpen(true);
openStopConfirm();
});
}, [api]);
}, [api, openStopConfirm]);
useEffect(() => {
if (npcMapSpawnDrag.dragging) return;
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
e.preventDefault();
if (saveOpen) {
setSaveOpen(false);
return;
}
if (stopConfirmOpen) {
setStopConfirmOpen(false);
return;
}
setStopConfirmOpen(true);
openStopConfirm();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [npcMapSpawnDrag.dragging, stopConfirmOpen]);
}, [npcMapSpawnDrag.dragging, openStopConfirm, saveOpen, stopConfirmOpen]);
useEffect(() => {
if (!markersInteractive) {
@@ -2142,6 +2199,21 @@ export function ControlApp() {
<span className={styles.iconGlyph}>⬛</span>
</Button>
</div>
{session?.currentSceneId ? (
<Button
onClick={() => {
const sceneId = session.currentSceneId;
if (!sceneId) return;
void api.invoke(ipcChannels.project.updateScene, {
sceneId,
patch: { darknessStrokes: sdState.strokes },
});
}}
data-testid="bake-darkness-to-scene"
>
{t('control.bakeDarknessToScene')}
</Button>
) : null}
</div>
) : null}
<div className={styles.radiusRow}>
@@ -2514,7 +2586,24 @@ export function ControlApp() {
: t('control.showPlayers')}
</Button>
) : null}
<Button onClick={() => setStopConfirmOpen(true)}>{t('control.stopPresentation')}</Button>
<Button onClick={openStopConfirm}>{t('control.stopPresentation')}</Button>
<Button
variant="ghost"
iconOnly
title={t('control.saveGame')}
ariaLabel={t('control.saveGame')}
data-testid="control-save-game"
onClick={() => openSaveModal(false)}
>
<span className={styles.iconGlyph} aria-hidden>
<svg viewBox="0 0 24 24" width="18" height="18">
<path
fill="currentColor"
d="M4 3h11.2L20 7.8V20a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm2 2v5h9V6.6L13.4 5H6zm1 9v5h10v-5H7zm2 1h6v3H9v-3z"
/>
</svg>
</span>
</Button>
</div>
</div>
<div className={styles.spacer10} />
@@ -3138,7 +3227,7 @@ export function ControlApp() {
<Button
variant="primary"
disabled={!session?.project?.currentGraphNodeId}
onClick={() => setStopConfirmOpen(true)}
onClick={openStopConfirm}
>
{t('control.stopPresentation')}
</Button>
@@ -3749,18 +3838,112 @@ export function ControlApp() {
×
</button>
</div>
<div>{t('control.stopConfirm.body')}</div>
<div className={styles.modalFooter}>
<div>{stopDirty ? t('control.stopConfirm.dirtyBody') : t('control.stopConfirm.body')}</div>
<div className={[styles.modalFooter, stopDirty ? styles.modalFooterWrap : ''].filter(Boolean).join(' ')}>
<Button onClick={() => setStopConfirmOpen(false)}>{t('common.cancel')}</Button>
<Button
variant="primary"
data-testid="stop-confirm-accept"
{stopDirty ? (
<Button
data-testid="stop-confirm-discard"
onClick={() => {
setStopConfirmOpen(false);
void api.invoke(ipcChannels.windows.closeMultiWindow, {});
}}
>
{t('control.stopConfirm.discard')}
</Button>
) : null}
{stopDirty ? (
<Button
variant="primary"
data-testid="stop-confirm-save-and-end"
onClick={() => {
setStopConfirmOpen(false);
openSaveModal(true);
}}
>
{t('control.stopConfirm.saveAndEnd')}
</Button>
) : (
<Button
variant="primary"
data-testid="stop-confirm-accept"
onClick={() => {
setStopConfirmOpen(false);
void api.invoke(ipcChannels.windows.closeMultiWindow, {});
}}
>
{t('control.stopConfirm.confirm')}
</Button>
)}
</div>
</div>
</>,
document.body,
)
: null}
{saveOpen
? createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalBackdrop}
onClick={() => {
if (!saveBusy) setSaveOpen(false);
}}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog} data-testid="save-game-modal">
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('control.saveGame.title')}</div>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalClose}
onClick={() => {
setStopConfirmOpen(false);
void api.invoke(ipcChannels.windows.closeMultiWindow, {});
if (!saveBusy) setSaveOpen(false);
}}
>
{t('control.stopConfirm.confirm')}
×
</button>
</div>
<input
className={styles.saveNameInput}
value={saveName}
placeholder={t('control.saveGame.name')}
aria-label={t('control.saveGame.name')}
onChange={(e) => {
setSaveName(e.currentTarget.value);
setSaveSelectedId(null);
}}
/>
<div className={styles.saveList}>
{saveItems.length === 0 ? <div>{t('control.saveGame.empty')}</div> : null}
{saveItems.map((item) => (
<button
key={item.id}
type="button"
className={[
styles.saveRow,
saveSelectedId === item.id ? styles.saveRowSelected : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => {
setSaveSelectedId(item.id);
setSaveName(item.name);
}}
>
{item.name}
</button>
))}
</div>
{saveError ? <div className={styles.saveError}>{saveError}</div> : null}
<div className={styles.modalFooter}>
<Button onClick={() => setSaveOpen(false)} disabled={saveBusy}>
{t('common.cancel')}
</Button>
<Button variant="primary" data-testid="save-game-commit" disabled={saveBusy} onClick={commitSave}>
{t('control.saveGame.commit')}
</Button>
</div>
</div>
@@ -97,6 +97,8 @@ void test('ControlApp: эффекты в пульте, иконки с тулт
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("t('control.bakeDarknessToScene')"));
assert.ok(src.includes('bake-darkness-to-scene'));
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'));
@@ -326,7 +328,10 @@ void test('ControlApp: Escape → своя модалка подтвержден
assert.ok(src.includes('data-testid="control-help-tool"'));
assert.match(src, /data-testid="control-help-tool"[\s\S]{0,180}iconOnly|iconOnly[\s\S]{0,180}data-testid="control-help-tool"/);
assert.ok(src.includes("section: 'controlPanel'"));
assert.equal((src.match(/windows\.closeMultiWindow,/g) ?? []).length, 1);
assert.equal((src.match(/windows\.closeMultiWindow,/g) ?? []).length, 3);
assert.ok(src.includes('data-testid="control-save-game"'));
assert.ok(src.includes('data-testid="stop-confirm-save-and-end"'));
assert.ok(src.includes('data-testid="stop-confirm-discard"'));
assert.ok(!src.includes('showMessageBox'));
assert.ok(!src.includes('window.confirm'));
});
+34
View File
@@ -385,6 +385,11 @@
font: inherit;
}
.fileMenuItem:disabled {
opacity: 0.45;
cursor: default;
}
.fileMenuItemDanger {
text-align: left;
padding: 10px;
@@ -525,6 +530,30 @@
box-sizing: border-box;
}
.saveList {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 240px;
overflow: auto;
}
.saveRow {
text-align: left;
padding: 8px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
}
.saveRowSelected {
border-color: var(--scene-list-selected-border);
background: var(--scene-list-selected-bg);
}
.licenseBlockTitle {
font-weight: 900;
font-size: var(--text-lg);
@@ -921,6 +950,11 @@
width: 100%;
}
.actionsRowHalf > button {
min-width: 0;
width: 100%;
}
.actionsRowHalf > span {
display: flex;
min-width: 0;
+128 -23
View File
@@ -16,6 +16,7 @@ import {
} from '../../shared/ipc/contracts';
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
import type { GameSaveListItem } from '../../shared/types/gameSave';
import { PROJECT_ZIP_EXTENSION } from '../../shared/project/projectZipExtension';
import type {
AssetId,
@@ -162,6 +163,10 @@ export function EditorApp() {
const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false);
const [playersManagerOpen, setPlayersManagerOpen] = useState(false);
const [launchPlayersOpen, setLaunchPlayersOpen] = useState(false);
const [gameSaves, setGameSaves] = useState<GameSaveListItem[]>([]);
const [loadSaveOpen, setLoadSaveOpen] = useState(false);
const [loadSaveId, setLoadSaveId] = useState<string | null>(null);
const [loadSaveError, setLoadSaveError] = useState('');
const [playlistsManagerOpen, setPlaylistsManagerOpen] = useState(false);
const [presentationSettingsOpen, setPresentationSettingsOpen] = useState(false);
const [gameSystemsOpen, setGameSystemsOpen] = useState(false);
@@ -387,6 +392,47 @@ export function EditorApp() {
});
}, []);
const refreshGameSaves = useCallback(() => {
if (!state.project) {
setGameSaves([]);
return;
}
void getDndApi()
.invoke(ipcChannels.gameSave.list, {})
.then(
({ saves }) => setGameSaves(saves),
() => setGameSaves([]),
);
}, [state.project]);
useEffect(() => {
refreshGameSaves();
}, [refreshGameSaves]);
const launchFromSave = useCallback(
(saveId: string) => {
if (!licenseActive || launching) return;
stopAllEditorAudio();
setLaunching(true);
setRunMenuOpen(false);
setLoadSaveError('');
void (async () => {
try {
const res = await getDndApi().invoke(ipcChannels.windows.openMultiWindow, { saveId });
if (res.project) actions.noteProject(res.project);
setLoadSaveOpen(false);
} catch (err) {
setLaunching(false);
const message = err instanceof Error ? err.message : String(err);
setLoadSaveError(
message.includes('GAME_SAVE_CARD_MISSING') ? t('top.loadSave.cardMissing') : message,
);
}
})();
},
[licenseActive, launching, t, actions],
);
const launchFromGraphNode = useCallback(
(graphNodeId: GraphNodeId, playerIds?: string[]) => {
if (!licenseActive || launching) return;
@@ -943,6 +989,7 @@ export function EditorApp() {
data-testid="run-menu-btn"
onClick={() => {
if (runDisabled || launching) return;
refreshGameSaves();
setRunMenuOpen((v) => !v);
}}
>
@@ -1219,7 +1266,6 @@ export function EditorApp() {
previewVideoAutostart={sc?.previewVideoAutostart ?? false}
previewVideoLoop={sc?.settings.loopVideo ?? false}
previewRotationDeg={sc?.previewRotationDeg ?? 0}
darkenScene={sc?.darkenScene ?? false}
previewBusy={previewBusy}
previewBusyText={previewBusyText}
mediaAssets={sceneMediaAssets}
@@ -1233,7 +1279,6 @@ export function EditorApp() {
onPreviewVideoLoopChange={(next) =>
void actions.updateScene(sid, { settings: { loopVideo: next } })
}
onDarkenSceneChange={(next) => void actions.updateScene(sid, { darkenScene: next })}
onTitleChange={(title) => void actions.updateScene(sid, { title })}
onDescriptionChange={(description) =>
void actions.updateScene(sid, { description })
@@ -1692,6 +1737,69 @@ export function EditorApp() {
launchFromGraphNode(graphStartGraphNodeId, playerIds);
}}
/>
{loadSaveOpen
? createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalBackdrop}
onClick={() => {
if (!launching) setLoadSaveOpen(false);
}}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog} data-testid="load-save-modal">
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('top.loadSave.title')}</div>
<button
type="button"
className={styles.modalClose}
aria-label={t('common.close')}
onClick={() => {
if (!launching) setLoadSaveOpen(false);
}}
>
×
</button>
</div>
<div className={styles.saveList}>
{gameSaves.map((item) => (
<button
key={item.id}
type="button"
className={[
styles.saveRow,
loadSaveId === item.id ? styles.saveRowSelected : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => setLoadSaveId(item.id)}
>
{item.name}
</button>
))}
</div>
{loadSaveError ? <div className={styles.fieldError}>{loadSaveError}</div> : null}
<div className={styles.modalFooter}>
<Button onClick={() => setLoadSaveOpen(false)} disabled={launching}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
data-testid="load-save-commit"
disabled={!loadSaveId || launching}
onClick={() => {
if (loadSaveId) launchFromSave(loadSaveId);
}}
>
{t('top.loadSave.action')}
</Button>
</div>
</div>
</>,
document.body,
)
: null}
</>
) : null}
{USERS_BRANCH_FEATURES_ENABLED && runMenuOpen && runMenuPos
@@ -1714,6 +1822,22 @@ export function EditorApp() {
>
{t('top.runWithPlayers')}
</button>
<button
type="button"
className={styles.fileMenuItem}
role="menuitem"
data-testid="run-load-save-item"
disabled={gameSaves.length === 0}
onClick={() => {
if (gameSaves.length === 0) return;
setRunMenuOpen(false);
setLoadSaveId(null);
setLoadSaveError('');
setLoadSaveOpen(true);
}}
>
{t('top.loadSave')}
</button>
</div>,
document.body,
)
@@ -2494,7 +2618,6 @@ type SceneInspectorProps = {
previewVideoAutostart: boolean;
previewVideoLoop: boolean;
previewRotationDeg: 0 | 90 | 180 | 270;
darkenScene: boolean;
previewBusy: boolean;
previewBusyText: string;
mediaAssets: MediaAsset[];
@@ -2502,7 +2625,6 @@ type SceneInspectorProps = {
onAudioRefsChange: (next: SceneAudioRef[]) => void;
onPreviewVideoAutostartChange: (next: boolean) => void;
onPreviewVideoLoopChange: (next: boolean) => void;
onDarkenSceneChange: (next: boolean) => void;
onTitleChange: (v: string) => void;
onDescriptionChange: (v: string) => void;
onImportPreview: () => void;
@@ -2575,7 +2697,6 @@ function SceneInspector({
previewVideoAutostart,
previewVideoLoop,
previewRotationDeg,
darkenScene,
previewBusy,
previewBusyText,
mediaAssets,
@@ -2583,7 +2704,6 @@ function SceneInspector({
onAudioRefsChange,
onPreviewVideoAutostartChange,
onPreviewVideoLoopChange,
onDarkenSceneChange,
onTitleChange,
onDescriptionChange,
onImportPreview,
@@ -2737,8 +2857,7 @@ function SceneInspector({
</div>
) : null}
{previewAssetId && (previewAssetType === 'image' || previewAssetType === 'video') ? (
<>
<div className={styles.spacer6} />
<div className={styles.actionsRowHalf}>
<Button
onClick={() => {
const next = ((previewRotationDeg + 90) % 360) as 0 | 90 | 180 | 270;
@@ -2747,20 +2866,6 @@ function SceneInspector({
>
{t('scene.rotate')}
</Button>
</>
) : null}
{previewAssetId && (previewAssetType === 'image' || previewAssetType === 'video') ? (
<>
<div className={styles.spacer6} />
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={darkenScene}
onChange={(e) => onDarkenSceneChange(e.target.checked)}
/>
<span className={styles.spanSm}>{t('scene.darkenScene')}</span>
</label>
<div className={styles.spacer6} />
<Button
onClick={() => {
void getDndApi().invoke(ipcChannels.windows.openSceneEditor, {});
@@ -2768,7 +2873,7 @@ function SceneInspector({
>
{t('sceneEditor.open')}
</Button>
</>
</div>
) : null}
<div className={styles.spacer6} />
<div className={styles.labelSm}>{t('scene.audio')}</div>
+1
View File
@@ -8,6 +8,7 @@ export const HELP_SECTION_LINK_ALIASES: Partial<Record<HelpSectionId, readonly s
effects: ['Эффекты', 'Effects'],
presentation: ['Презентация', 'Presentation'],
grid: ['Сетка', 'Grid'],
sceneEditor: ['Затемнение сцены', 'Scene darkness', 'Редактор сцены', 'Scene editor'],
controlPanel: ['Пульт'],
players: ['Игроки', 'Players'],
tokens: ['Токены', 'Tokens'],
File diff suppressed because one or more lines are too long
+14
View File
@@ -43,6 +43,7 @@ type Actions = {
createProject: (name: string) => Promise<void>;
openProject: (id: ProjectId) => Promise<void>;
closeProject: () => Promise<void>;
noteProject: (project: Project) => void;
createScene: () => Promise<void>;
createScenesFromMediaPaths: (
items: { filePath: string; title: string }[],
@@ -71,6 +72,7 @@ type Actions = {
previewVideoAutostart?: boolean;
previewRotationDeg?: 0 | 90 | 180 | 270;
darkenScene?: boolean;
darknessStrokes?: Scene['darknessStrokes'];
settings?: Partial<Scene['settings']>;
media?: Partial<Scene['media']>;
layout?: { x: number; y: number };
@@ -354,6 +356,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
previewVideoAutostart: false,
previewRotationDeg: 0,
darkenScene: false,
darknessStrokes: [],
traps: [],
tokens: [],
npcTokens: [],
@@ -564,6 +567,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
previewVideoAutostart?: boolean;
previewRotationDeg?: 0 | 90 | 180 | 270;
darkenScene?: boolean;
darknessStrokes?: Scene['darknessStrokes'];
traps?: import('../../../shared/types').SceneTrap[];
tokens?: import('../../../shared/types').SceneToken[];
npcTokens?: import('../../../shared/types').SceneNpcToken[];
@@ -594,6 +598,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
? { previewRotationDeg: patch.previewRotationDeg }
: null),
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
...(patch.darknessStrokes !== undefined ? { darknessStrokes: patch.darknessStrokes } : null),
...(patch.traps !== undefined ? { traps: patch.traps } : null),
...(patch.tokens !== undefined ? { tokens: patch.tokens } : null),
...(patch.npcTokens !== undefined ? { npcTokens: patch.npcTokens } : null),
@@ -918,6 +923,14 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
}
};
const noteProject = (project: Project) => {
setState((s) => ({
...s,
project,
selectedSceneId: project.currentSceneId ?? s.selectedSceneId,
}));
};
const deleteProject = async (projectId: ProjectId) => {
projectDataEpochRef.current += 1;
await api.invoke(ipcChannels.project.deleteProject, { projectId });
@@ -936,6 +949,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
createProject,
openProject,
closeProject,
noteProject,
createScene,
createScenesFromMediaPaths,
selectScene,
@@ -86,6 +86,39 @@
user-select: none;
}
.darknessTools {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.darknessBrushLayer {
position: absolute;
z-index: 29;
cursor: none;
touch-action: none;
}
.brushCursor {
position: absolute;
z-index: 30;
will-change: left, top;
transform: translate(-50%, -50%);
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.55);
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.3),
0 0 18px rgba(255, 140, 40, 0.12),
inset 0 0 18px rgba(255, 140, 40, 0.1);
background: rgba(255, 160, 60, 0.03);
pointer-events: none;
}
.brushCursorSquare {
border-radius: 2px;
}
.field {
display: grid;
gap: 6px;
+402
View File
@@ -7,14 +7,21 @@ import type {
NpcDisposition,
NpcId,
ProjectNpc,
Scene,
SceneDarknessBrushShape,
SceneDarknessDraft,
SceneDarknessRevealStroke,
SceneDarknessState,
SceneGrid,
SceneGridTemplate,
SceneId,
SceneNpcToken,
SceneToken,
SceneTrap,
SceneTrapType,
TokenId,
} from '../../shared/types';
import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay';
import {
asSceneNpcTokenId,
clampSceneNpcTokenSizeN,
@@ -237,6 +244,24 @@ function SceneNpcMarker({
);
}
function sameDarknessStrokes(
a: readonly SceneDarknessRevealStroke[],
b: readonly SceneDarknessRevealStroke[],
): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
const left = a[i];
const right = b[i];
if (!left || !right) return false;
if (left.id !== right.id || left.points.length !== right.points.length) return false;
if (left.radiusN !== right.radiusN || left.mode !== right.mode || left.shape !== right.shape) {
return false;
}
}
return true;
}
function NpcPaletteTile({ npc }: { npc: ProjectNpc }) {
const imageUrl = useAssetUrl(npc.avatarAssetId);
const disposition = normalizeNpcDisposition(npc.disposition);
@@ -270,8 +295,28 @@ export function SceneEditorApp() {
const [session, setSession] = useState<SessionState | null>(null);
const [trapsOpen, setTrapsOpen] = useState(false);
const [gridOpen, setGridOpen] = useState(false);
const [darknessOpen, setDarknessOpen] = useState(false);
const [tokensOpen, setTokensOpen] = useState(false);
const [npcsOpen, setNpcsOpen] = useState(false);
const [localDarken, setLocalDarken] = useState(false);
const [localDarknessStrokes, setLocalDarknessStrokes] = useState<SceneDarknessRevealStroke[]>([]);
const [darknessDraft, setDarknessDraft] = useState<SceneDarknessDraft | null>(null);
const [darknessTool, setDarknessTool] = useState<'none' | 'explore' | 'close'>('none');
const [darknessRadiusN, setDarknessRadiusN] = useState(0.08);
const [darknessBrushShape, setDarknessBrushShape] = useState<SceneDarknessBrushShape>('circle');
const darknessBrushRef = useRef<{
points: { x: number; y: number; tMs: number }[];
createdAtMs: number;
seed: number;
} | null>(null);
const brushCursorElRef = useRef<HTMLDivElement | null>(null);
const darknessCursorPosRef = useRef<{ x: number; y: number } | null>(null);
const darknessToolRef = useRef(darknessTool);
const darknessRadiusNRef = useRef(darknessRadiusN);
const darknessBrushShapeRef = useRef(darknessBrushShape);
darknessToolRef.current = darknessTool;
darknessRadiusNRef.current = darknessRadiusN;
darknessBrushShapeRef.current = darknessBrushShape;
const [tokenSearch, setTokenSearch] = useState('');
const [npcSearch, setNpcSearch] = useState('');
const [tokenModal, setTokenModal] = useState<
@@ -291,16 +336,23 @@ export function SceneEditorApp() {
const [selected, setSelected] = useState<Selection>(null);
const [view, setView] = useState<LocalView>({ scale: 1, ox: 0.5, oy: 0.5 });
const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
const contentRectRef = useRef(contentRect);
contentRectRef.current = contentRect;
const hostRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<DragMode>(null);
const saveTrapsTimerRef = useRef(0);
const saveTokensTimerRef = useRef(0);
const saveNpcTokensTimerRef = useRef(0);
const saveGridTimerRef = useRef(0);
const saveDarknessTimerRef = useRef(0);
const pendingDarknessStrokesRef = useRef<SceneDarknessRevealStroke[] | null>(null);
const darknessDirtyRef = useRef(false);
const spaceDownRef = useRef(false);
const sceneIdRef = useRef<SceneId | null>(null);
const project = session?.project ?? null;
const sceneId = project?.currentSceneId ?? null;
sceneIdRef.current = sceneId;
const scene = sceneId && project ? project.scenes[sceneId] : undefined;
const url = useAssetUrl(scene?.previewAssetId ?? null);
const rot = scene?.previewRotationDeg ?? 0;
@@ -316,6 +368,7 @@ export function SceneEditorApp() {
const trapsRef = useRef<SceneTrap[]>([]);
const tokensRef = useRef<SceneToken[]>([]);
const npcTokensRef = useRef<SceneNpcToken[]>([]);
const localDarknessStrokesRef = useRef<SceneDarknessRevealStroke[]>([]);
useEffect(() => {
trapsRef.current = localTraps;
@@ -324,12 +377,28 @@ export function SceneEditorApp() {
}, [localNpcTokens, localTokens, localTraps]);
useEffect(() => {
localDarknessStrokesRef.current = localDarknessStrokes;
}, [localDarknessStrokes]);
useEffect(() => {
darknessDirtyRef.current = false;
pendingDarknessStrokesRef.current = null;
if (saveDarknessTimerRef.current) {
window.clearTimeout(saveDarknessTimerRef.current);
saveDarknessTimerRef.current = 0;
}
setLocalTraps(scene?.traps ?? []);
setLocalTokens((scene?.tokens ?? []).filter((t) => appTokens.some((a) => a.id === t.tokenId)));
setLocalNpcTokens(
(scene?.npcTokens ?? []).filter((t) => project?.npcs.some((npc) => npc.id === t.npcId)),
);
setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID });
setLocalDarken(Boolean(scene?.darkenScene));
const strokes = scene?.darknessStrokes ?? [];
localDarknessStrokesRef.current = strokes;
setLocalDarknessStrokes(strokes);
setDarknessDraft(null);
setDarknessTool('none');
setSelected(null);
setView({ scale: 1, ox: 0.5, oy: 0.5 });
}, [sceneId, scene?.previewAssetId]);
@@ -356,6 +425,16 @@ export function SceneEditorApp() {
setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID });
}, [scene?.grid]);
useEffect(() => {
// IPC session clones arrays — do not clobber unsaved local darkness edits.
if (darknessBrushRef.current || darknessDirtyRef.current || saveDarknessTimerRef.current) return;
setLocalDarken(Boolean(scene?.darkenScene));
const incoming = scene?.darknessStrokes ?? [];
if (sameDarknessStrokes(incoming, localDarknessStrokesRef.current)) return;
localDarknessStrokesRef.current = incoming;
setLocalDarknessStrokes(incoming);
}, [scene?.darkenScene, scene?.darknessStrokes]);
useEffect(() => {
void api.invoke(ipcChannels.project.get, {}).then(({ project: p }) => {
setSession({ project: p, currentSceneId: p?.currentSceneId ?? null });
@@ -404,6 +483,99 @@ export function SceneEditorApp() {
[api, sceneId],
);
const mergeSceneIntoSession = useCallback((updated: Scene) => {
setSession((prev) => {
if (!prev?.project?.scenes[updated.id]) return prev;
return {
...prev,
project: {
...prev.project,
scenes: { ...prev.project.scenes, [updated.id]: updated },
},
};
});
}, []);
const flushDarknessStrokes = useCallback(async () => {
const sid = sceneIdRef.current;
const payload = pendingDarknessStrokesRef.current;
if (saveDarknessTimerRef.current) {
window.clearTimeout(saveDarknessTimerRef.current);
saveDarknessTimerRef.current = 0;
}
if (!sid || !payload) return;
pendingDarknessStrokesRef.current = null;
try {
const res = await api.invoke(ipcChannels.project.updateScene, {
sceneId: sid,
patch: { darknessStrokes: payload },
});
darknessDirtyRef.current = false;
mergeSceneIntoSession(res.scene);
} catch {
// Keep dirty so a later flush / session sync can retry from local ref.
pendingDarknessStrokesRef.current = payload;
darknessDirtyRef.current = true;
}
}, [api, mergeSceneIntoSession]);
const persistDarken = useCallback(
(next: boolean) => {
const sid = sceneIdRef.current;
if (!sid) return;
setLocalDarken(next);
if (!next) setDarknessTool('none');
darknessDirtyRef.current = true;
void (async () => {
await flushDarknessStrokes();
try {
const res = await api.invoke(ipcChannels.project.updateScene, {
sceneId: sid,
patch: { darkenScene: next },
});
darknessDirtyRef.current = false;
mergeSceneIntoSession(res.scene);
} catch {
darknessDirtyRef.current = true;
}
})();
},
[api, flushDarknessStrokes, mergeSceneIntoSession],
);
const persistDarknessStrokes = useCallback(
(next: SceneDarknessRevealStroke[]) => {
if (!sceneIdRef.current) return;
localDarknessStrokesRef.current = next;
setLocalDarknessStrokes(next);
pendingDarknessStrokesRef.current = next;
darknessDirtyRef.current = true;
if (saveDarknessTimerRef.current) window.clearTimeout(saveDarknessTimerRef.current);
saveDarknessTimerRef.current = window.setTimeout(() => {
void flushDarknessStrokes();
}, 120);
},
[flushDarknessStrokes],
);
const flushDarknessStrokesRef = useRef(flushDarknessStrokes);
flushDarknessStrokesRef.current = flushDarknessStrokes;
useEffect(() => {
const flush = () => {
void flushDarknessStrokesRef.current();
};
const onVis = () => {
if (document.hidden) flush();
};
window.addEventListener('blur', flush);
document.addEventListener('visibilitychange', onVis);
return () => {
window.removeEventListener('blur', flush);
document.removeEventListener('visibilitychange', onVis);
flush();
};
}, []);
const persistGrid = useCallback(
(next: SceneGrid) => {
if (!sceneId) return;
@@ -504,6 +676,34 @@ export function SceneEditorApp() {
};
};
const layoutDarknessBrushCursor = () => {
const el = brushCursorElRef.current;
const p = darknessCursorPosRef.current;
const cr = contentRectRef.current;
if (!el) return;
if (!p || !cr || darknessToolRef.current === 'none') {
el.style.visibility = 'hidden';
return;
}
el.style.visibility = 'visible';
el.className = [
styles.brushCursor,
darknessBrushShapeRef.current === 'square' ? styles.brushCursorSquare : '',
]
.filter(Boolean)
.join(' ');
const minDim = Math.min(cr.w, cr.h);
const size = Math.max(1, darknessRadiusNRef.current * minDim * 2);
el.style.left = `${String(cr.x + p.x * cr.w)}px`;
el.style.top = `${String(cr.y + p.y * cr.h)}px`;
el.style.width = `${String(size)}px`;
el.style.height = `${String(size)}px`;
};
useEffect(() => {
layoutDarknessBrushCursor();
}, [darknessBrushShape, darknessRadiusN, darknessTool, contentRect]);
const viewCamera = useMemo(() => view, [view]);
useEffect(() => {
@@ -632,6 +832,17 @@ export function SceneEditorApp() {
const isVideo = scene?.previewAssetType === 'video' && Boolean(url);
const hasMapMedia = isImage || isVideo;
const darknessOverlayState = useMemo((): SceneDarknessState | null => {
if (!localDarken) return null;
return {
revision: 1,
enabled: true,
cacheKey: sceneId,
strokes: localDarknessStrokes,
draft: darknessDraft,
};
}, [darknessDraft, localDarken, localDarknessStrokes, sceneId]);
return (
<div className={styles.page}>
<aside className={styles.sidebar}>
@@ -828,6 +1039,98 @@ export function SceneEditorApp() {
) : null}
</div>
<div className={styles.accordion} data-testid="scene-darkness-accordion">
<button
type="button"
className={styles.accordionHead}
onClick={() => setDarknessOpen((v) => !v)}
>
{t('sceneEditor.darkness')} {darknessOpen ? '▾' : '▸'}
</button>
{darknessOpen ? (
<div className={styles.gridPanel}>
<label className={styles.checkRow}>
<input
type="checkbox"
checked={localDarken}
disabled={!hasMapMedia}
onChange={(e) => persistDarken(e.target.checked)}
data-testid="scene-darken-toggle"
/>
<span>{t('scene.darkenScene')}</span>
</label>
{localDarken && hasMapMedia ? (
<>
<div className={styles.darknessTools}>
<Button
variant={darknessTool === 'explore' ? 'primary' : 'ghost'}
iconOnly
title={t('control.explorerBrush')}
ariaLabel={t('control.explorerBrush')}
onClick={() =>
setDarknessTool((prev) => (prev === 'explore' ? 'none' : 'explore'))
}
>
<span aria-hidden>🔦</span>
</Button>
<Button
variant={darknessTool === 'close' ? 'primary' : 'ghost'}
iconOnly
title={t('control.closerBrush')}
ariaLabel={t('control.closerBrush')}
onClick={() =>
setDarknessTool((prev) => (prev === 'close' ? 'none' : 'close'))
}
>
<span aria-hidden>⬛</span>
</Button>
</div>
<label className={styles.field}>
<span className={styles.fieldLabel}>
{t('control.brushRadius')}{' '}
<span className={styles.fieldValue}>
{Math.max(1, Math.min(36, Math.round(darknessRadiusN / 0.005)))}
</span>
</span>
<input
type="range"
className={styles.range}
min={1}
max={36}
step={1}
value={Math.max(1, Math.min(36, Math.round(darknessRadiusN / 0.005)))}
onChange={(e) => {
const ui = Number(e.currentTarget.value);
setDarknessRadiusN(Math.max(1, Math.min(36, Math.round(ui))) * 0.005);
}}
/>
</label>
<div className={styles.darknessTools}>
<Button
variant={darknessBrushShape === 'circle' ? 'primary' : 'ghost'}
iconOnly
title={t('control.brushShapeCircle')}
ariaLabel={t('control.brushShapeCircle')}
onClick={() => setDarknessBrushShape('circle')}
>
<span aria-hidden>○</span>
</Button>
<Button
variant={darknessBrushShape === 'square' ? 'primary' : 'ghost'}
iconOnly
title={t('control.brushShapeSquare')}
ariaLabel={t('control.brushShapeSquare')}
onClick={() => setDarknessBrushShape('square')}
>
<span aria-hidden>□</span>
</Button>
</div>
</>
) : null}
</div>
) : null}
</div>
{USERS_BRANCH_FEATURES_ENABLED ? (
<div className={styles.accordion} data-testid="scene-npc-accordion">
<button type="button" className={styles.accordionHead} onClick={() => setNpcsOpen((v) => !v)}>
@@ -1071,6 +1374,105 @@ export function SceneEditorApp() {
/>
)}
<SceneGridOverlay grid={localGrid} viewport={contentRect} />
{localDarken && contentRect ? (
<SceneDarknessOverlay
state={darknessOverlayState}
overlayAlpha={0.55}
viewport={contentRect}
style={{ zIndex: 28 }}
/>
) : null}
{localDarken && darknessTool !== 'none' && contentRect ? (
<>
<div
ref={brushCursorElRef}
className={styles.brushCursor}
style={{ visibility: 'hidden' }}
aria-hidden
/>
<div
className={styles.darknessBrushLayer}
style={{
left: contentRect.x,
top: contentRect.y,
width: contentRect.w,
height: contentRect.h,
}}
onPointerEnter={(e) => {
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
darknessCursorPosRef.current = p;
layoutDarknessBrushCursor();
}}
onPointerLeave={() => {
darknessCursorPosRef.current = null;
layoutDarknessBrushCursor();
}}
onPointerDown={(e) => {
if (e.button !== 0 || spaceDownRef.current) return;
e.preventDefault();
e.stopPropagation();
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
darknessCursorPosRef.current = p;
layoutDarknessBrushCursor();
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
const createdAtMs = Date.now();
const seed = Math.floor(Math.random() * 1_000_000_000);
const pt = { x: p.x, y: p.y, tMs: createdAtMs };
darknessBrushRef.current = { points: [pt], createdAtMs, seed };
setDarknessDraft({
points: [pt],
radiusN: darknessRadiusN,
mode: darknessTool === 'close' ? 'cover' : 'reveal',
shape: darknessBrushShape,
});
}}
onPointerMove={(e) => {
const p = hostToNorm(e.clientX, e.clientY);
if (p) {
darknessCursorPosRef.current = p;
layoutDarknessBrushCursor();
}
const brush = darknessBrushRef.current;
if (!brush || (e.buttons & 1) === 0) return;
if (!p) return;
const last = brush.points[brush.points.length - 1];
if (last && Math.hypot(p.x - last.x, p.y - last.y) < 0.002) return;
const pt = { x: p.x, y: p.y, tMs: Date.now() };
brush.points.push(pt);
setDarknessDraft({
points: [...brush.points],
radiusN: darknessRadiusN,
mode: darknessTool === 'close' ? 'cover' : 'reveal',
shape: darknessBrushShape,
});
}}
onPointerUp={() => {
const brush = darknessBrushRef.current;
darknessBrushRef.current = null;
setDarknessDraft(null);
if (!brush || brush.points.length === 0) return;
const stroke: SceneDarknessRevealStroke = {
id: `sd_${String(brush.createdAtMs)}_${String(brush.seed)}`,
seed: brush.seed,
createdAtMs: brush.createdAtMs,
points: brush.points,
radiusN: darknessRadiusN,
mode: darknessTool === 'close' ? 'cover' : 'reveal',
shape: darknessBrushShape,
};
persistDarknessStrokes([...localDarknessStrokesRef.current, stroke]);
}}
onPointerCancel={() => {
darknessBrushRef.current = null;
setDarknessDraft(null);
darknessCursorPosRef.current = null;
layoutDarknessBrushCursor();
}}
/>
</>
) : null}
<TokenPathsOverlay
tokens={localTokens}
npcTokens={USERS_BRANCH_FEATURES_ENABLED ? localNpcTokens : []}