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