diff --git a/src/core/commands/projectCommands.ts b/src/core/commands/projectCommands.ts index cb0b23a1..84abe5e1 100644 --- a/src/core/commands/projectCommands.ts +++ b/src/core/commands/projectCommands.ts @@ -1,4 +1,5 @@ import { collisionLayersOf } from '../model/collisionLayers'; +import type { PlayFrom } from '../model/playSettings'; import type { Project } from '../model/Project'; import { clampSymbolScale } from '../model/symbolScale'; import type { Command } from './Command'; @@ -56,3 +57,20 @@ export class SetSymbolScaleCommand implements Command { project.symbolScale = this.previous; } } + +/** Which scene Play starts (#226): saved with the project like the rest of its settings. */ +export class SetPlayFromCommand implements Command { + readonly label = 'Play starts'; + private previous: PlayFrom = 'start'; + + constructor(private readonly playFrom: PlayFrom) {} + + execute(project: Project): void { + this.previous = project.playFrom; + project.playFrom = this.playFrom; + } + + undo(project: Project): void { + project.playFrom = this.previous; + } +} diff --git a/src/core/model/Project.ts b/src/core/model/Project.ts index 9b26956b..70c4004e 100644 --- a/src/core/model/Project.ts +++ b/src/core/model/Project.ts @@ -7,6 +7,7 @@ import { type SceneAsset, } from './Asset'; import { DEFAULT_LAYER_NAME } from './collisionLayers'; +import { DEFAULT_GAME_SCALE, DEFAULT_PLAY_FROM, type GameScale, type PlayFrom } from './playSettings'; import { DEFAULT_SYMBOL_SCALE } from './symbolScale'; /** 2: a placed container carries no copy of its template. */ @@ -42,6 +43,10 @@ export interface Project { * written before there was one reads as 1, the size the icons always had. */ symbolScale: number; + /** Which scene Play starts (see `playSettings.ts`). */ + playFrom: PlayFrom; + /** The scale the game view draws the camera at. Saved, but not an undo step: it is how you look. */ + gameScale: GameScale; } export function createEmptyProject(id: string, name: string): Project { @@ -55,6 +60,8 @@ export function createEmptyProject(id: string, name: string): Project { plugins: [], collisionLayers: [DEFAULT_LAYER_NAME], symbolScale: DEFAULT_SYMBOL_SCALE, + playFrom: DEFAULT_PLAY_FROM, + gameScale: DEFAULT_GAME_SCALE, }; } diff --git a/src/core/model/playSettings.ts b/src/core/model/playSettings.ts new file mode 100644 index 00000000..41acc9aa --- /dev/null +++ b/src/core/model/playSettings.ts @@ -0,0 +1,33 @@ +/** + * How the project is played in the editor (#226): which scene Play starts and the scale of the + * game view. Both are saved with the project, so whoever opens it gets the same answer, and a + * project written before they were saved reads as the defaults. + */ + +/** Which scene Play starts: the one named as the start scene, or whatever is open right now. */ +export type PlayFrom = 'start' | 'active'; + +export const DEFAULT_PLAY_FROM: PlayFrom = 'start'; + +/** How the game view sizes the camera frame: a fixed factor, the largest step that fits, or fitted. */ +export type GameScale = 'auto' | 'fit' | number; + +export const GAME_SCALE_STEPS = [0.25, 0.5, 1, 2, 4]; + +export const DEFAULT_GAME_SCALE: GameScale = 1; + +export function isGameScale(value: unknown): value is GameScale { + return value === 'auto' || value === 'fit' || GAME_SCALE_STEPS.includes(value as number); +} + +/** Read off untrusted JSON; anything else is the default. */ +export function playFromOf(raw: unknown): PlayFrom { + const value = (raw as { playFrom?: unknown } | null)?.playFrom; + return value === 'start' || value === 'active' ? value : DEFAULT_PLAY_FROM; +} + +/** Read off untrusted JSON; anything that is not one of the choices is the default. */ +export function gameScaleOf(raw: unknown): GameScale { + const value = (raw as { gameScale?: unknown } | null)?.gameScale; + return isGameScale(value) ? value : DEFAULT_GAME_SCALE; +} diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 4ce990b5..1387ec11 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -39,6 +39,7 @@ import type { import { parseCellKey } from '../model/tilemap'; import { pluginsOf } from './projectPlugins'; import { collisionLayersOf } from '../model/collisionLayers'; +import { gameScaleOf, playFromOf } from '../model/playSettings'; import { symbolScaleOf } from '../model/symbolScale'; import { decodeCells, encodeCells } from './tilemapCells'; import { isAssetType } from '../model/AssetTypes'; @@ -250,6 +251,8 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { plugins: pluginsOf(r), collisionLayers: collisionLayersOf(r), symbolScale: symbolScaleOf(r), + playFrom: playFromOf(r), + gameScale: gameScaleOf(r), }; return project; } diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 10d86de4..26aba5f9 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -62,7 +62,7 @@ import { ParticlesEditor } from './particles/ParticlesEditor'; import { ShaderEditor } from './shader/ShaderEditor'; import { StateMachineEditor } from './statemachine/StateMachineEditor'; import { installShortcuts } from './shortcuts'; -import type { PlayFrom } from './settings'; +import type { PlayFrom } from '../core/model/playSettings'; import type { EditorState } from './state'; import { PixelArtEditor } from './pixelart/PixelArtEditor'; import { VectorArtEditor } from './vectorart/VectorArtEditor'; diff --git a/src/editor/ProjectSession.ts b/src/editor/ProjectSession.ts index 162e8b2b..3511c705 100644 --- a/src/editor/ProjectSession.ts +++ b/src/editor/ProjectSession.ts @@ -4,6 +4,7 @@ import { RenameProjectCommand, SetCollisionLayersCommand, + SetPlayFromCommand, SetSymbolScaleCommand, } from '../core/commands/projectCommands'; import type { ProjectStore } from '../core/store/ProjectStore'; @@ -142,9 +143,7 @@ export class ProjectSession { /** * Name, the scene Play starts, the symbol scale and the collision layers (#152) in one dialog - * (#183). Name, symbol scale and layers are undo steps of their own – one each, and only for what - * actually changed; which scene Play starts is a preference of this browser, not of the project - * (see `settings.ts`). + * (#183). Each is an undo step of its own – one each, and only for what actually changed. */ openProjectSettings(): ProjectSettingsDialog { const { project } = this.state; @@ -162,7 +161,7 @@ export class ProjectSession { this.state.execute(new SetCollisionLayersCommand(change.collisionLayers)); if (change.symbolScale !== undefined) this.state.execute(new SetSymbolScaleCommand(change.symbolScale)); - this.state.setPlayFrom(change.playFrom); + if (change.playFrom !== undefined) this.state.execute(new SetPlayFromCommand(change.playFrom)); }), ); } diff --git a/src/editor/ProjectSettingsDialog.ts b/src/editor/ProjectSettingsDialog.ts index 08c94a66..0c033714 100644 --- a/src/editor/ProjectSettingsDialog.ts +++ b/src/editor/ProjectSettingsDialog.ts @@ -1,7 +1,7 @@ import { layerFallback, MAX_COLLISION_LAYERS } from '../core/model/collisionLayers'; import { clampSymbolScale, SYMBOL_SCALE_MAX, SYMBOL_SCALE_MIN } from '../core/model/symbolScale'; import { Button, HStack, Label, Menu, NumberField, TextField, Theme, VStack } from '../ui'; -import type { PlayFrom } from './settings'; +import type { PlayFrom } from '../core/model/playSettings'; import { FormDialog } from './FormDialog'; /** What the dialog hands back when Apply is pressed. */ @@ -14,8 +14,7 @@ export interface ProjectSettings { /** * What Apply has to carry out. Only what actually changed, so opening the dialog and closing it - * with Apply is not an undo step: a rename and a change of the layers are one command each, while - * the start-scene switch is a preference of this browser and no command at all. + * with Apply is not an undo step: every setting that did change is one command. * * A blank name is not a change but a fallback to the old one – a project without a name would be * unfindable in the picker. @@ -23,7 +22,7 @@ export interface ProjectSettings { export function settingsChanges( before: ProjectSettings, after: ProjectSettings, -): { name?: string; collisionLayers?: string[]; symbolScale?: number; playFrom: PlayFrom } { +): { name?: string; collisionLayers?: string[]; symbolScale?: number; playFrom?: PlayFrom } { const name = after.name.trim() || before.name; const symbolScale = clampSymbolScale(after.symbolScale); return { @@ -32,7 +31,7 @@ export function settingsChanges( ? {} : { collisionLayers: [...after.collisionLayers] }), ...(symbolScale === before.symbolScale ? {} : { symbolScale }), - playFrom: after.playFrom, + ...(after.playFrom === before.playFrom ? {} : { playFrom: after.playFrom }), }; } diff --git a/src/editor/saves.ts b/src/editor/saves.ts index 3817cc5d..dfa62a9f 100644 --- a/src/editor/saves.ts +++ b/src/editor/saves.ts @@ -1,8 +1,8 @@ /** * The saves of persistent Variables assets (#166), kept in this browser per project. Played and * editor runs write to separate areas, so trying a behavior in the editor leaves the player's save - * alone. Like the editor settings, storage is best effort: a browser that refuses `localStorage` - * plays every run from the declared values. + * alone. Storage is best effort: a browser that refuses `localStorage` plays every run from the + * declared values. */ import type { SaveArea, SavedValues } from '../core/graph'; import type { PropertyBag } from '../core/model/Properties'; diff --git a/src/editor/settings.ts b/src/editor/settings.ts deleted file mode 100644 index 5498a6ea..00000000 --- a/src/editor/settings.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * The handful of things the editor remembers about *how you work*, as opposed to what you built. - * They belong to this browser, not to the project: a second person opening the same project has - * their own answer, and none of this may end up in a diff of the project file. - * - * Storage is best effort. A browser that refuses `localStorage` (private mode, a blocked origin) - * gets the defaults and keeps working – a setting is not worth an error. - */ - -import { GAME_SCALE_STEPS, type GameScale } from '../viewport/view'; - -const KEY = 'game-maker.editor'; - -/** Which scene Play starts: the one named as the start scene, or whatever is open right now. */ -export type PlayFrom = 'start' | 'active'; - -export interface EditorSettings { - playFrom: PlayFrom; - gameScale: GameScale; -} - -const DEFAULTS: EditorSettings = { playFrom: 'start', gameScale: 1 }; - -let current: EditorSettings | null = null; - -export function settings(): EditorSettings { - if (current) return current; - current = { ...DEFAULTS }; - try { - const raw = window.localStorage.getItem(KEY); - const read: unknown = raw ? JSON.parse(raw) : null; - if (read && typeof read === 'object') { - const { playFrom, gameScale } = read as Partial; - if (playFrom === 'start' || playFrom === 'active') current.playFrom = playFrom; - if (gameScale === 'auto' || gameScale === 'fit' || GAME_SCALE_STEPS.includes(gameScale as number)) - current.gameScale = gameScale as GameScale; - } - } catch { - // Unreadable or refused: the defaults stand. - } - return current; -} - -export function updateSettings(change: Partial): void { - const next = { ...settings(), ...change }; - current = next; - try { - window.localStorage.setItem(KEY, JSON.stringify(next)); - } catch { - // Not stored is not a failure worth reporting; the session keeps the value either way. - } -} diff --git a/src/editor/state.ts b/src/editor/state.ts index 65a0dfcb..a30843d6 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -19,7 +19,7 @@ import { import { deserializeProject, serializeProject } from '../core/store/serialize'; import { requestRender } from '../ui'; import { consoleLog } from './console/log'; -import { type PlayFrom, settings, updateSettings } from './settings'; +import type { GameScale, PlayFrom } from '../core/model/playSettings'; export type Selection = | { kind: 'asset'; assetId: string } @@ -321,13 +321,22 @@ export class EditorState { } get playFrom(): PlayFrom { - return settings().playFrom; + return this.project.playFrom; } - setPlayFrom(playFrom: PlayFrom): void { - if (playFrom === this.playFrom) return; - updateSettings({ playFrom }); - this.emit('mode'); + get gameScale(): GameScale { + return this.project.gameScale; + } + + /** + * Saved with the project but not an undo step (#226): Ctrl+Z undoes edits, not a look at the game. + * While playing it goes into the saved model too, so stopping does not throw it away. + */ + setGameScale(scale: GameScale): void { + if (scale === this.gameScale) return; + this.project.gameScale = scale; + if (this.edit) this.edit.project.gameScale = scale; + this.setSaveStatus('dirty'); } /** diff --git a/src/editor/viewport/GameViewport.ts b/src/editor/viewport/GameViewport.ts index eb225fcc..0e134a77 100644 --- a/src/editor/viewport/GameViewport.ts +++ b/src/editor/viewport/GameViewport.ts @@ -17,7 +17,6 @@ import { screenToWorld, type View, } from '../../viewport/view'; -import { settings, updateSettings } from '../settings'; import { type Frame, WorldViewport } from './WorldViewport'; const LETTERBOX_COLOR = 0x000000; @@ -81,12 +80,12 @@ export class GameViewport extends WorldViewport { } get scaleMode(): GameScale { - return settings().gameScale; + return this.state.gameScale; } - /** Remembered in this browser, like which scene Play starts. */ + /** Saved with the project, but no undo step (see `EditorState.setGameScale`). */ setScaleMode(mode: GameScale): void { - updateSettings({ gameScale: mode }); + this.state.setGameScale(mode); this.redraw(); } diff --git a/src/viewport/view.ts b/src/viewport/view.ts index 330bfbf7..77b3f72e 100644 --- a/src/viewport/view.ts +++ b/src/viewport/view.ts @@ -1,3 +1,4 @@ +import { GAME_SCALE_STEPS, type GameScale } from '../core/model/playSettings'; import type { PropertyBag, Vec2 } from '../core/model/Properties'; import { cameraFrame, cameraSize, rectAround, type WorldRect } from '../core/properties/camera'; @@ -48,10 +49,7 @@ function clampIndex(index: number): number { return Math.max(0, Math.min(FREE_ZOOM_STEPS.length - 1, index)); } -/** How the game view sizes the camera frame: a fixed factor, the largest step that fits, or fitted. */ -export type GameScale = 'auto' | 'fit' | number; - -export const GAME_SCALE_STEPS = [0.25, 0.5, 1, 2, 4]; +export { GAME_SCALE_STEPS, type GameScale }; /** * Device pixels per camera pixel for a camera picture of `sizeW`×`sizeH` in a `w`×`h` view (CSS diff --git a/tests/core/holders.test.ts b/tests/core/holders.test.ts index 1a6fed38..526104b2 100644 --- a/tests/core/holders.test.ts +++ b/tests/core/holders.test.ts @@ -152,6 +152,8 @@ describe('CommandStack', () => { plugins: [], collisionLayers: ['Default'], symbolScale: 1, + playFrom: 'start' as const, + gameScale: 1, }; const stack = new CommandStack(project); let failing = false; diff --git a/tests/editor/play-settings.test.ts b/tests/editor/play-settings.test.ts new file mode 100644 index 00000000..e15418bc --- /dev/null +++ b/tests/editor/play-settings.test.ts @@ -0,0 +1,73 @@ +/** + * Which scene Play starts and the game view scale (#226): both live in the project now, not in the + * browser. The start-scene switch is an undo step like every other project setting; the scale is + * saved but never lands on the undo stack. + */ +import { describe, expect, it } from 'vitest'; +import { SetPlayFromCommand } from '../../src/core/commands/projectCommands'; +import { gameScaleOf, playFromOf } from '../../src/core/model/playSettings'; +import { createEmptyProject } from '../../src/core/model/Project'; +import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; +import { EditorState } from '../../src/editor/state'; + +function loaded() { + const state = new EditorState(); + state.loadProject(createEmptyProject('p', 'P'), 1); + return state; +} + +describe('play settings in the project', () => { + it('start from the start scene at 100% in a new project and in one written before there were any', () => { + const fresh = createEmptyProject('p', 'P'); + const old = deserializeProject({ id: 'p', name: 'P', assets: [] }); + expect([fresh.playFrom, fresh.gameScale]).toEqual(['start', 1]); + expect([old.playFrom, old.gameScale]).toEqual(['start', 1]); + }); + + it('survive a save and a load', () => { + const project = { + ...createEmptyProject('p', 'P'), + playFrom: 'active' as const, + gameScale: 'auto' as const, + }; + const back = deserializeProject(serializeProject(project)); + expect([back.playFrom, back.gameScale]).toEqual(['active', 'auto']); + }); + + it('read untrusted JSON as the defaults unless it is one of the choices', () => { + expect(playFromOf({ playFrom: 'active' })).toBe('active'); + expect(playFromOf({ playFrom: 'elsewhere' })).toBe('start'); + expect(playFromOf(null)).toBe('start'); + expect(gameScaleOf({ gameScale: 'fit' })).toBe('fit'); + expect(gameScaleOf({ gameScale: 0.25 })).toBe(0.25); + expect(gameScaleOf({ gameScale: 3 })).toBe(1); + expect(gameScaleOf({ gameScale: '2' })).toBe(1); + }); + + it('make the start-scene switch one undo step', () => { + const state = loaded(); + state.execute(new SetPlayFromCommand('active')); + expect(state.playFrom).toBe('active'); + expect(state.saveStatus).toBe('dirty'); + state.undo(); + expect(state.playFrom).toBe('start'); + }); + + it('save the game view scale without an undo step', () => { + const state = loaded(); + state.setGameScale(4); + expect(state.project.gameScale).toBe(4); + expect(state.saveStatus).toBe('dirty'); + expect(state.commands.canUndo).toBe(false); + }); + + it('keep a scale picked while playing after Stop', () => { + const state = loaded(); + state.enterPlay(); + state.setGameScale('fit'); + expect(state.gameScale).toBe('fit'); + state.exitPlay(); + expect(state.gameScale).toBe('fit'); + expect(state.saveStatus).toBe('dirty'); + }); +}); diff --git a/tests/editor/project-settings.test.ts b/tests/editor/project-settings.test.ts index 3aca42ce..febdc63c 100644 --- a/tests/editor/project-settings.test.ts +++ b/tests/editor/project-settings.test.ts @@ -14,9 +14,7 @@ const before: ProjectSettings = { describe('project settings', () => { it('reports nothing to do when nothing was touched', () => { - expect(settingsChanges(before, { ...before, collisionLayers: ['Default', 'Enemies'] })).toEqual({ - playFrom: 'start', - }); + expect(settingsChanges(before, { ...before, collisionLayers: ['Default', 'Enemies'] })).toEqual({}); }); it('renames only when the name really differs', () => { @@ -55,8 +53,8 @@ describe('project settings', () => { expect(settingsChanges(before, { ...before, symbolScale: Number.NaN }).symbolScale).toBeUndefined(); }); - it('always reports the start-scene switch – it is a preference, not an undo step', () => { + it('writes the start-scene switch only when it changed (#226)', () => { expect(settingsChanges(before, { ...before, playFrom: 'active' }).playFrom).toBe('active'); - expect(settingsChanges(before, { ...before, playFrom: 'start' }).playFrom).toBe('start'); + expect(settingsChanges(before, { ...before, playFrom: 'start' }).playFrom).toBeUndefined(); }); }); diff --git a/tools/e2e/03-play.mjs b/tools/e2e/03-play.mjs index 7e43460a..f459dc33 100644 --- a/tools/e2e/03-play.mjs +++ b/tools/e2e/03-play.mjs @@ -305,6 +305,9 @@ export async function play(t) { const centred = ({ frame, view }) => Math.abs(frame.x + frame.w / 2 - (view.x + view.w / 2)) <= 1 && Math.abs(frame.y + frame.h / 2 - (view.y + view.h / 2)) <= 1; + const undoSteps = () => json('__gm.state.commands.undoStack.length'); + await evaluate(`localStorage.removeItem('game-maker.editor')`); + const stepsBefore = await undoSteps(); let g = await scale(); const crateAt100 = (await json(`__gm.gameRect('o1')`)).w; check( @@ -351,9 +354,11 @@ export async function play(t) { JSON.stringify({ g, fit }), ); check( - 'the scale is remembered in this browser, not in the project', - (await evaluate(`JSON.parse(localStorage.getItem('game-maker.editor')).gameScale`)) === 'auto' && - !(await evaluate(`JSON.stringify(__gm.state.project).includes('gameScale')`)), + 'the scale is saved with the project (#226), not in this browser, and is no undo step', + (await json('__gm.state.project.gameScale')) === 'auto' && + (await evaluate(`localStorage.getItem('game-maker.editor')`)) === null && + (await undoSteps()) === stepsBefore, + JSON.stringify({ steps: await undoSteps(), stepsBefore }), ); g = await pickScale('100%'); check('and 100% can be picked back', g.mode === 1 && g.scale === 1, JSON.stringify(g)); diff --git a/tools/e2e/35-load-scene.mjs b/tools/e2e/35-load-scene.mjs index 35472689..69858494 100644 --- a/tools/e2e/35-load-scene.mjs +++ b/tools/e2e/35-load-scene.mjs @@ -257,8 +257,8 @@ export async function loadScene(t) { ); await stop(); - // 35f. The switch is the way to run what you are working on instead – and it is remembered - // between runs, because it says how you work rather than what the game does. + // 35f. The switch is the way to run what you are working on instead – saved with the project + // (#226), so it holds between runs. const chosen = await setPlayFrom('Active scene'); await play(); const fromActive = await playInfo(); @@ -268,6 +268,7 @@ export async function loadScene(t) { (await playFrom()).mode === 'active' && // The dialog held the new mode before Apply, and Apply left the start scene alone. chosen.playFrom === 'active' && + (await json('__gm.state.project.playFrom')) === 'active' && (await json('__gm.state.project.startSceneId')) === 'ls-scene', JSON.stringify({ fromActive, chosen, switch: await playFrom() }), );