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
+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,