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