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:
@@ -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));
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user