From 5afb70a92e779f5db6ff35459095ecb63ae9cde5 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Thu, 24 Sep 2026 10:55:42 +0200 Subject: [PATCH] Let the project set the size of scene view symbols Pixel art projects draw at very different sizes, so the fixed 18 px icons of behaviors, containers and rigidbodies were too big in some projects and too small in others. The size now lives in the project settings and travels with the project. - New field Symbol scale in the project settings dialog, below Play starts. It takes 0.1 to 4 with two decimals and defaults to 1. - The value is stored as project.symbolScale. Older projects load with 1, and values out of range are clamped on load. - Every change is its own undo step through a project command. - The factor scales the icon size, the spacing of icons that share a spot, the label offset and the selection frame of icon-only symbols. - The footprints in bounds.ts that Fit uses stay as they were. The hit area follows the icon, so clicks match what is drawn. - uiHooks reports the icon size so e2e can measure it. The e2e sum in the README is 1375. That is 1374 plus one new check, and 1374 itself has not been confirmed by CI yet. Tests: - ./check.sh: 157 files, 2326 tests, CHECK EXIT 0 (before 2320). - New tests/core/symbol-scale.test.ts with 5 tests (default, save and load, clamping, undo, icon frame), one more in project-settings.test.ts. - e2e 46f: dialog opens with 1, 0.5 plus Apply gives a 9 px icon, undo gives 18 again. Module 46: ALL PASSED, 13 checks. - Counter checks: without loading the value the save and load test fails. With the icon ignoring the factor exactly 46f fails. Both originals restored per cmp. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/core/commands/projectCommands.ts | 18 ++++++++++ src/core/model/Project.ts | 7 ++++ src/core/model/symbolScale.ts | 19 ++++++++++ src/core/properties/bounds.ts | 16 +++++++++ src/core/store/serialize.ts | 2 ++ src/editor/ProjectSession.ts | 16 ++++++--- src/editor/ProjectSettingsDialog.ts | 49 ++++++++++++++++++++----- src/editor/debug/uiHooks.ts | 7 +++- src/editor/viewport/SceneViewport.ts | 26 +++++++++----- tests/core/holders.test.ts | 1 + tests/core/symbol-scale.test.ts | 51 +++++++++++++++++++++++++++ tests/editor/project-settings.test.ts | 9 +++++ tools/e2e/46-topbar.mjs | 33 +++++++++++++++++ 14 files changed, 233 insertions(+), 23 deletions(-) create mode 100644 src/core/model/symbolScale.ts create mode 100644 tests/core/symbol-scale.test.ts diff --git a/README.md b/README.md index efdcad47..354ea921 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1374 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1375 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/commands/projectCommands.ts b/src/core/commands/projectCommands.ts index 2f68a6d2..cb0b23a1 100644 --- a/src/core/commands/projectCommands.ts +++ b/src/core/commands/projectCommands.ts @@ -1,5 +1,6 @@ import { collisionLayersOf } from '../model/collisionLayers'; import type { Project } from '../model/Project'; +import { clampSymbolScale } from '../model/symbolScale'; import type { Command } from './Command'; export class RenameProjectCommand implements Command { @@ -38,3 +39,20 @@ export class SetCollisionLayersCommand implements Command { project.collisionLayers = this.previous; } } + +/** Size of the scene view's symbol icons, kept inside its range like a loaded project's. */ +export class SetSymbolScaleCommand implements Command { + readonly label = 'Symbol scale'; + private previous = 1; + + constructor(private readonly scale: number) {} + + execute(project: Project): void { + this.previous = project.symbolScale; + project.symbolScale = clampSymbolScale(this.scale); + } + + undo(project: Project): void { + project.symbolScale = this.previous; + } +} diff --git a/src/core/model/Project.ts b/src/core/model/Project.ts index 3d8ad412..9b26956b 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_SYMBOL_SCALE } from './symbolScale'; /** 2: a placed container carries no copy of its template. */ export const PROJECT_FORMAT_VERSION = 2; @@ -36,6 +37,11 @@ export interface Project { * which is also what every collider starts on, so nothing collides differently than it did. */ collisionLayers: string[]; + /** + * Size of the scene view's symbol icons as a factor, 0.1 to 4 (see `symbolScale.ts`). A project + * written before there was one reads as 1, the size the icons always had. + */ + symbolScale: number; } export function createEmptyProject(id: string, name: string): Project { @@ -48,6 +54,7 @@ export function createEmptyProject(id: string, name: string): Project { startSceneId: '', plugins: [], collisionLayers: [DEFAULT_LAYER_NAME], + symbolScale: DEFAULT_SYMBOL_SCALE, }; } diff --git a/src/core/model/symbolScale.ts b/src/core/model/symbolScale.ts new file mode 100644 index 00000000..cc0ab113 --- /dev/null +++ b/src/core/model/symbolScale.ts @@ -0,0 +1,19 @@ +/** + * How large the scene view draws its symbol icons, as a factor of their normal size. A project of + * 16-pixel art wants them smaller than one of 64-pixel art, which is why it belongs to the project + * and not to the browser. + */ +export const SYMBOL_SCALE_MIN = 0.1; +export const SYMBOL_SCALE_MAX = 4; +export const DEFAULT_SYMBOL_SCALE = 1; + +/** Keeps a factor inside the range; anything that is not a finite number is the default. */ +export function clampSymbolScale(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT_SYMBOL_SCALE; + return Math.min(SYMBOL_SCALE_MAX, Math.max(SYMBOL_SCALE_MIN, value)); +} + +/** The factor as stored, read off untrusted JSON. A project written before there was one has 1. */ +export function symbolScaleOf(raw: unknown): number { + return clampSymbolScale((raw as { symbolScale?: unknown } | null)?.symbolScale); +} diff --git a/src/core/properties/bounds.ts b/src/core/properties/bounds.ts index ff5c6874..ab1539a6 100644 --- a/src/core/properties/bounds.ts +++ b/src/core/properties/bounds.ts @@ -114,6 +114,22 @@ export function footprintCorners( return boxCorners(placed, w + 2 * local, h + 2 * local, anchorOffset(type, props, w), 0); } +/** + * The upright square of a symbol that is only its icon, centred on where the icon stands and grown + * with the project's symbol scale like the icon, plus `pad` world units on every side. + */ +export function iconCorners(type: AssetType, props: PropertyBag, at: Vec2, scale: number, pad = 0): Vec2[] { + const { w, h } = footprintOf(type, props); + const hw = (w * scale) / 2 + pad; + const hh = (h * scale) / 2 + pad; + return [ + { x: at.x - hw, y: at.y - hh }, + { x: at.x + hw, y: at.y - hh }, + { x: at.x + hw, y: at.y + hh }, + { x: at.x - hw, y: at.y + hh }, + ]; +} + /** The world rectangle around the drawn footprint – what culling, framing and labels need. */ export function footprintExtent( type: AssetType, diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 0f7fc2ce..4ce990b5 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 { symbolScaleOf } from '../model/symbolScale'; import { decodeCells, encodeCells } from './tilemapCells'; import { isAssetType } from '../model/AssetTypes'; import { isFontFile } from '../model/font'; @@ -248,6 +249,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { // is actually running. plugins: pluginsOf(r), collisionLayers: collisionLayersOf(r), + symbolScale: symbolScaleOf(r), }; return project; } diff --git a/src/editor/ProjectSession.ts b/src/editor/ProjectSession.ts index 46e45823..162e8b2b 100644 --- a/src/editor/ProjectSession.ts +++ b/src/editor/ProjectSession.ts @@ -1,7 +1,11 @@ /** * Loading, creating, switching and deleting projects, plus the top bar's menu that triggers it. */ -import { RenameProjectCommand, SetCollisionLayersCommand } from '../core/commands/projectCommands'; +import { + RenameProjectCommand, + SetCollisionLayersCommand, + SetSymbolScaleCommand, +} from '../core/commands/projectCommands'; import type { ProjectStore } from '../core/store/ProjectStore'; import { Dialog, Menu, type MenuEntry, type Widget } from '../ui'; import { dialogSlot } from './FormDialog'; @@ -137,9 +141,10 @@ export class ProjectSession { } /** - * Name, the scene Play starts and the collision layers (#152) in one dialog (#183). The name and - * the 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`). + * 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`). */ openProjectSettings(): ProjectSettingsDialog { const { project } = this.state; @@ -147,6 +152,7 @@ export class ProjectSession { name: project.name, playFrom: this.state.playFrom, collisionLayers: [...project.collisionLayers], + symbolScale: project.symbolScale, }; return this.settingsDialog.open( new ProjectSettingsDialog(before, (settings) => { @@ -154,6 +160,8 @@ export class ProjectSession { if (change.name !== undefined) this.state.execute(new RenameProjectCommand(change.name)); if (change.collisionLayers !== undefined) this.state.execute(new SetCollisionLayersCommand(change.collisionLayers)); + if (change.symbolScale !== undefined) + this.state.execute(new SetSymbolScaleCommand(change.symbolScale)); this.state.setPlayFrom(change.playFrom); }), ); diff --git a/src/editor/ProjectSettingsDialog.ts b/src/editor/ProjectSettingsDialog.ts index 11a76b4c..08c94a66 100644 --- a/src/editor/ProjectSettingsDialog.ts +++ b/src/editor/ProjectSettingsDialog.ts @@ -1,5 +1,6 @@ import { layerFallback, MAX_COLLISION_LAYERS } from '../core/model/collisionLayers'; -import { Button, HStack, Label, Menu, TextField, Theme, VStack } from '../ui'; +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 { FormDialog } from './FormDialog'; @@ -8,6 +9,7 @@ export interface ProjectSettings { name: string; playFrom: PlayFrom; collisionLayers: string[]; + symbolScale: number; } /** @@ -21,13 +23,15 @@ export interface ProjectSettings { export function settingsChanges( before: ProjectSettings, after: ProjectSettings, -): { name?: string; collisionLayers?: string[]; playFrom: PlayFrom } { +): { name?: string; collisionLayers?: string[]; symbolScale?: number; playFrom: PlayFrom } { const name = after.name.trim() || before.name; + const symbolScale = clampSymbolScale(after.symbolScale); return { ...(name === before.name ? {} : { name }), ...(JSON.stringify(after.collisionLayers) === JSON.stringify(before.collisionLayers) ? {} : { collisionLayers: [...after.collisionLayers] }), + ...(symbolScale === before.symbolScale ? {} : { symbolScale }), playFrom: after.playFrom, }; } @@ -38,9 +42,9 @@ const PLAY_FROM_LABEL: Record = { }; /** - * Everything about the project that is not an asset (#183): its name, which scene Play starts, and - * the collision layers (#152). The top bar carries icons only, so this is the one place the three - * are edited. + * Everything about the project that is not an asset (#183): its name, which scene Play starts, the + * size of the scene view's symbol icons, and the collision layers (#152). The top bar carries icons + * only, so this is the one place they are edited. * * Colliders name a layer by its *number*, so a layer taken out of the middle would silently move * every collider below it onto another one; only the last layer can be removed, and the list is @@ -50,8 +54,10 @@ export class ProjectSettingsDialog extends FormDialog { private projectName: string; private playFrom: PlayFrom; private layers: string[]; + private symbolScale: number; private readonly nameField: TextField; private readonly playFromButton: Button; + private readonly symbolScaleField: NumberField; private readonly rows = new VStack({ gap: Theme.gap }); private readonly addButton: Button; @@ -60,6 +66,7 @@ export class ProjectSettingsDialog extends FormDialog { this.projectName = settings.name; this.playFrom = settings.playFrom; this.layers = [...settings.collisionLayers]; + this.symbolScale = clampSymbolScale(settings.symbolScale); this.nameField = new TextField(this.projectName, { placeholder: 'Project name', @@ -82,6 +89,26 @@ export class ProjectSettingsDialog extends FormDialog { }), ); + this.symbolScaleField = new NumberField(this.symbolScale, { + width: 150, + min: SYMBOL_SCALE_MIN, + max: SYMBOL_SCALE_MAX, + step: 0.01, + decimals: 2, + onInput: (value) => { + this.symbolScale = value; + }, + onChange: (value) => { + this.symbolScale = value; + }, + }); + this.content.add(this.formRow(this.fieldLabel('Symbol scale'), this.symbolScaleField)); + this.content.add( + new Label(`Size of the icons in the scene view, ${SYMBOL_SCALE_MIN} to ${SYMBOL_SCALE_MAX}.`, { + style: 'muted', + }), + ); + this.content.add(new Label('Collision layers', { style: 'bodyBold' })); this.content.add( new Label('Two colliders meet only when each one names the layer of the other.', { @@ -97,6 +124,7 @@ export class ProjectSettingsDialog extends FormDialog { name: this.projectName, playFrom: this.playFrom, collisionLayers: this.layers, + symbolScale: this.symbolScale, }), ); this.buildLayers(); @@ -107,9 +135,9 @@ export class ProjectSettingsDialog extends FormDialog { return [...this.layers]; } - /** Name and start-scene switch as they stand (test hook). */ - values(): { name: string; playFrom: PlayFrom } { - return { name: this.projectName, playFrom: this.playFrom }; + /** Name, start-scene switch and symbol scale as they stand (test hook). */ + values(): { name: string; playFrom: PlayFrom; symbolScale: number } { + return { name: this.projectName, playFrom: this.playFrom, symbolScale: this.symbolScale }; } /** The start-scene switch itself, so a check can click it. */ @@ -117,6 +145,11 @@ export class ProjectSettingsDialog extends FormDialog { return this.playFromButton; } + /** The symbol scale field, so a check can type into it. */ + get symbolScaleControl(): NumberField { + return this.symbolScaleField; + } + /** A label and a field that takes the rest of the row – the layer rows are built the same way. */ private labelledRow(text: string, field: TextField): HStack { const row = new HStack({ gap: Theme.gap, align: 'center' }); diff --git a/src/editor/debug/uiHooks.ts b/src/editor/debug/uiHooks.ts index ea35627e..07a87b58 100644 --- a/src/editor/debug/uiHooks.ts +++ b/src/editor/debug/uiHooks.ts @@ -220,13 +220,18 @@ export function uiHooks({ app, state, editor }: HookContext) { project: [...state.project.collisionLayers], dialog: editor.projectSettingsDialog()?.layerNames() ?? null, }), - /** The "Project settings" dialog (#183): name and start-scene switch as its fields stand. */ + /** The "Project settings" dialog (#183): name, start-scene switch and symbol scale as its fields stand. */ projectSettings: () => editor.projectSettingsDialog()?.values() ?? null, /** Where to click the start-scene switch inside that dialog. */ projectSettingsPlayFromAt: () => { const dialog = editor.projectSettingsDialog(); return dialog ? centreOf(dialog.playFromControl) : null; }, + /** Where to click the symbol scale field inside that dialog. */ + projectSettingsSymbolScaleAt: () => { + const dialog = editor.projectSettingsDialog(); + return dialog ? centreOf(dialog.symbolScaleControl) : null; + }, /** The "Open project" dialog (#183): the projects it lists and the row picked. */ projectPicker: () => editor.projectPickerDialog()?.info() ?? null, /** The dividers left and right of the centre column – dragging them narrows the tab row. */ diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index 87a7ebe8..78de58d9 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -16,13 +16,12 @@ import { invert, linearOf, type Mat, - translation, } from '../../core/model/matrix'; import { placementRejection } from '../../core/model/placement'; import type { Position, Vec2 } from '../../core/model/Properties'; import { findAsset, type ObjectHolder } from '../../core/model/Project'; import { cellAtPoint } from '../../core/model/tilemap'; -import { footprintCorners } from '../../core/properties/bounds'; +import { footprintCorners, iconCorners } from '../../core/properties/bounds'; import { localMatrixOf, parentTransform, transformOf } from '../../core/properties/transform'; import { FramePreview } from '../framePreview'; import { type EditorState, objectSelection } from '../state'; @@ -582,7 +581,7 @@ export class SceneViewport extends FreeViewport { return this.solids.info(this.lastItems.find((i) => i.id === id)); } - /** The icon of a symbol in world units, the name beside it and whether it hangs off its owner (test hook). */ + /** The icon of a symbol in world units (its side as `size`), the name beside it and whether it hangs off its owner (test hook). */ iconInfo(id: string): { layer: string; visible: boolean; @@ -592,6 +591,7 @@ export class SceneViewport extends FreeViewport { y: number; screen: Vec2; clickable: boolean; + size: number; } | null { const icon = this.icons.get(id); if (!icon) return null; @@ -606,6 +606,7 @@ export class SceneViewport extends FreeViewport { y: icon.position.y, screen: { x: screen.x, y: screen.y }, clickable: icon.eventMode !== 'none', + size: icon.scale.x * SYMBOL_GRID, }; } @@ -628,7 +629,6 @@ export class SceneViewport extends FreeViewport { if (!icon) { icon = new Graphics(); drawSymbolIcon(icon, item.type as SymbolIcon); - icon.scale.set(SYMBOL_SIZE / SYMBOL_GRID); const half = SYMBOL_GRID / 2 + SYMBOL_OUTLINE; icon.hitArea = new Rectangle(-half, -half, half * 2, half * 2); icon.cursor = 'move'; @@ -636,10 +636,16 @@ export class SceneViewport extends FreeViewport { this.icons.set(item.id, icon); this.iconLayer.addChild(icon); } + icon.scale.set((SYMBOL_SIZE * this.symbolScale()) / SYMBOL_GRID); icon.visible = true; icon.eventMode = 'static'; } + /** The project's factor for icon size, spacing and the outline of an icon-only symbol. */ + private symbolScale(): number { + return this.state.project.symbolScale; + } + /** * Puts every icon where `layoutSymbols` says – on its object, or on a ring round a spot several * want – and each name beside its icon, on the outer side of a ring. Switched-off objects draw no @@ -652,8 +658,8 @@ export class SceneViewport extends FreeViewport { const anchors = items .filter((item) => item.enabled && hasSceneIcon(item.type)) .map((item) => ({ id: item.id, x: item.x, y: item.y })); - this.spots = layoutSymbols(anchors, SYMBOL_SPACING); - const gap = SYMBOL_SIZE / 2 + 4 / scale; + this.spots = layoutSymbols(anchors, SYMBOL_SPACING * this.symbolScale()); + const gap = (SYMBOL_SIZE * this.symbolScale()) / 2 + 4 / scale; for (const [id, spot] of this.spots) { this.icons.get(id)?.position.set(spot.x, spot.y); const label = this.labels.get(id); @@ -756,11 +762,13 @@ export class SceneViewport extends FreeViewport { const item = items.find((i) => i.id === id); if (!item) continue; // Two screen pixels outside the footprint, whichever way it is turned. A symbol that is - // only its icon is outlined where the icon stands, which need not be its position. + // only its icon is outlined where the icon stands, which need not be its position, and + // at the icon's size. const spot = ICON_ONLY.has(item.type) ? this.spots.get(id) : undefined; - const m = spot ? translation(spot.x, spot.y) : item.m; const corners = this.screenCorners( - footprintCorners(item.type, item.props, m, 2 / view.scale, item.frame), + spot + ? iconCorners(item.type, item.props, spot, this.symbolScale(), 2 / view.scale) + : footprintCorners(item.type, item.props, item.m, 2 / view.scale, item.frame), view, ); // A switched-off object draws nothing, so its outline is the only way to see where it diff --git a/tests/core/holders.test.ts b/tests/core/holders.test.ts index 52612af2..1a6fed38 100644 --- a/tests/core/holders.test.ts +++ b/tests/core/holders.test.ts @@ -151,6 +151,7 @@ describe('CommandStack', () => { startSceneId: '', plugins: [], collisionLayers: ['Default'], + symbolScale: 1, }; const stack = new CommandStack(project); let failing = false; diff --git a/tests/core/symbol-scale.test.ts b/tests/core/symbol-scale.test.ts new file mode 100644 index 00000000..c1c34648 --- /dev/null +++ b/tests/core/symbol-scale.test.ts @@ -0,0 +1,51 @@ +/** + * The project's symbol scale: how large the scene view draws its icons, 0.1 to 4, default 1. + */ +import { describe, expect, it } from 'vitest'; +import { SetSymbolScaleCommand } from '../../src/core/commands/projectCommands'; +import { createEmptyProject } from '../../src/core/model/Project'; +import { symbolScaleOf } from '../../src/core/model/symbolScale'; +import { footprintOf, iconCorners } from '../../src/core/properties/bounds'; +import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; + +describe('symbol scale', () => { + it('is 1 in a new project and in one written before there was any', () => { + expect(createEmptyProject('p', 'P').symbolScale).toBe(1); + expect(deserializeProject({ id: 'p', name: 'P', assets: [] }).symbolScale).toBe(1); + }); + + it('survives a save and a load', () => { + const project = { ...createEmptyProject('p', 'P'), symbolScale: 0.25 }; + expect(deserializeProject(serializeProject(project)).symbolScale).toBe(0.25); + }); + + it('reads untrusted JSON into the range 0.1 to 4', () => { + expect(symbolScaleOf({ symbolScale: 0.05 })).toBe(0.1); + expect(symbolScaleOf({ symbolScale: 12 })).toBe(4); + expect(symbolScaleOf({ symbolScale: 2.5 })).toBe(2.5); + expect(symbolScaleOf({ symbolScale: '2' })).toBe(1); + expect(symbolScaleOf({ symbolScale: Number.NaN })).toBe(1); + expect(symbolScaleOf(null)).toBe(1); + }); + + it('is one undo step, clamped like a loaded project', () => { + const project = createEmptyProject('p', 'P'); + const command = new SetSymbolScaleCommand(7); + command.execute(project); + expect(project.symbolScale).toBe(4); + command.undo(project); + expect(project.symbolScale).toBe(1); + }); + + it('grows the outline of an icon-only symbol with the icon, the padding stays', () => { + const { w } = footprintOf('behavior', {}); + const at = { x: 100, y: 50 }; + const half = (corners: { x: number }[]) => (corners[1]!.x - corners[0]!.x) / 2; + expect(half(iconCorners('behavior', {}, at, 1))).toBe(w / 2); + expect(half(iconCorners('behavior', {}, at, 0.5))).toBe(w / 4); + expect(half(iconCorners('behavior', {}, at, 2, 3))).toBe(w + 3); + const corners = iconCorners('behavior', {}, at, 0.5); + expect((corners[0]!.x + corners[2]!.x) / 2).toBe(100); + expect((corners[0]!.y + corners[2]!.y) / 2).toBe(50); + }); +}); diff --git a/tests/editor/project-settings.test.ts b/tests/editor/project-settings.test.ts index 5b6ad42e..3aca42ce 100644 --- a/tests/editor/project-settings.test.ts +++ b/tests/editor/project-settings.test.ts @@ -9,6 +9,7 @@ const before: ProjectSettings = { name: 'Demo project', playFrom: 'start', collisionLayers: ['Default', 'Enemies'], + symbolScale: 1, }; describe('project settings', () => { @@ -46,6 +47,14 @@ describe('project settings', () => { expect(change.collisionLayers).toEqual(['Default', 'Foes']); }); + it('writes the symbol scale only when it changed, kept inside 0.1 to 4', () => { + expect(settingsChanges(before, { ...before, symbolScale: 1 }).symbolScale).toBeUndefined(); + expect(settingsChanges(before, { ...before, symbolScale: 0.5 }).symbolScale).toBe(0.5); + expect(settingsChanges(before, { ...before, symbolScale: 0.01 }).symbolScale).toBe(0.1); + expect(settingsChanges(before, { ...before, symbolScale: 9 }).symbolScale).toBe(4); + expect(settingsChanges(before, { ...before, symbolScale: Number.NaN }).symbolScale).toBeUndefined(); + }); + it('always reports the start-scene switch – it is a preference, not an undo step', () => { expect(settingsChanges(before, { ...before, playFrom: 'active' }).playFrom).toBe('active'); expect(settingsChanges(before, { ...before, playFrom: 'start' }).playFrom).toBe('start'); diff --git a/tools/e2e/46-topbar.mjs b/tools/e2e/46-topbar.mjs index ecef386f..d837d31e 100644 --- a/tools/e2e/46-topbar.mjs +++ b/tools/e2e/46-topbar.mjs @@ -133,5 +133,38 @@ export async function topbar(t) { (await json('__gm.state.project.name')) === projectName, await json('__gm.state.project.name'), ); + + // 46f. The symbol scale in the same dialog: 1 by default, typed down to 0.5, and every icon of the + // scene drawn at half its side (18 → 9 world units), undone in one step. + const iconSize = () => + json(`(() => { + const p = __gm.state.project; + const scene = p.assets.find((a) => a.id === p.activeSceneId); + const id = (scene?.objects ?? []).map((o) => o.id).find((id) => __gm.sceneIcon(id)?.visible); + return id ? __gm.sceneIcon(id).size : null; + })()`); + await clickToolbar('menu'); + await clickText('Project settings…'); + const opened = await json('__gm.projectSettings()'); + const field = await json('__gm.projectSettingsSymbolScaleAt()'); + await t.click(field.x, field.y); + await press('a', { ctrl: true }); + await typeText('0.5'); + await press('Enter'); + await clickText('Apply'); + await sleep(200); + const scaled = { project: await json('__gm.state.project.symbolScale'), icon: await iconSize() }; + await clickToolbar('undo'); + await sleep(200); + const restored = { project: await json('__gm.state.project.symbolScale'), icon: await iconSize() }; + check( + 'the symbol scale starts at 1, Apply halves every scene icon, and undo gives the size back', + opened?.symbolScale === 1 && + scaled.project === 0.5 && + Math.abs(scaled.icon - 9) < 1e-9 && + restored.project === 1 && + Math.abs(restored.icon - 18) < 1e-9, + JSON.stringify({ opened, scaled, restored }), + ); await t.saved(); }