diff --git a/README.md b/README.md index 9f404592..ee367605 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,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, 1692 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1695 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/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index b495f845..59d39855 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -132,6 +132,7 @@ export function initialOf(def: PropertyDef): PropertyValue { /** Whether the inspector shows the row, given the values of the bag it is on. */ export function isRowShown(def: PropertyDef, values: PropertyBag): boolean { + if ('noRow' in def && def.noRow) return false; if ( 'shownFor' in def && def.shownFor && @@ -197,8 +198,9 @@ export type PropertyDef = * A list declares nothing about its items: each one carries its own kind (see `listItems.ts`), * and the graph types a list as a list, so one `list` port fits them all. An `itemType` makes * every item a struct of that Struct asset (#254), each marked with it (`ITEM_TYPE_KEY`). + * `noRow` keeps it out of the inspector: its items are edited somewhere else (#295). */ - | { key: string; label: string; kind: 'list'; default: ListValue; itemType?: string } + | { key: string; label: string; kind: 'list'; default: ListValue; itemType?: string; noRow?: boolean } /** * `fields` is the declaration; the value carries exactly those keys, which is what lets a struct * keep its shape while single fields are written (`mergeStruct`). With a `structType` the fields @@ -605,11 +607,13 @@ const ASSET_SCHEMAS: Record = { { key: 'width', label: 'Width', kind: 'number', default: 100, min: 1, step: 1 }, { key: 'height', label: 'Height', kind: 'number', default: 100, min: 1, step: 1 }, { key: 'radius', label: 'Radius', kind: 'number', default: 50, min: 1, step: 1 }, - // A polygon's corners in the collider's own units, edited on the outline in the scene (#151). + // A polygon's corners in the collider's own units, edited on the outline in the scene (#151) – + // and only there: the inspector does not list them (#295). { key: 'points', label: 'Points', kind: 'list', + noRow: true, default: [ { x: 0, y: -50 }, { x: 50, y: 50 }, diff --git a/src/editor/container/ContainerCanvas.ts b/src/editor/container/ContainerCanvas.ts index e9894a86..4f829d5c 100644 --- a/src/editor/container/ContainerCanvas.ts +++ b/src/editor/container/ContainerCanvas.ts @@ -37,6 +37,10 @@ export class ContainerCanvas extends SceneViewport { return asset?.type === 'container' ? asset : undefined; } + protected override framesOnOpen(): boolean { + return false; + } + /** A container has no background of its own – painting one would invent a scene it is not in. */ protected override paintsBackground(): boolean { return false; diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index 70c24f44..e60c3cf4 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -6,7 +6,6 @@ import { RevertPositionToTemplateCommand, } from '../../core/commands/applyCommands'; import type { ValuePath } from '../../core/properties/valuePath'; -import { UnpackContainerCommand } from '../../core/commands/containerCommands'; import { ResizeTilemapEdgeCommand } from '../../core/commands/tilemapCommands'; import { TILEMAP_COLUMNS_KEY, TILEMAP_EDGES, TILEMAP_ROWS_KEY } from '../../core/model/tilemap'; import { findUsages } from '../../core/model/usages'; @@ -323,7 +322,7 @@ function assetLink(host: InspectorHost, key: string, text: string, asset: Asset) /** * What a placed container holds. While it follows its template the content cannot be changed here – - * unpacking writes it into the scene as ordinary objects, which cuts the link for good. + * unpacking (its right-click menu) writes it into the scene as ordinary objects. */ function contentSection( host: InspectorHost, @@ -344,15 +343,6 @@ function contentSection( ); if (templateCount === 0) return; addNote(host, 'Its content is listed below it in the hierarchy.', 'small'); - addGap(host); - host.body.add( - new Button('Unpack', { - variant: 'ghost', - onClick: () => host.state.execute(new UnpackContainerCommand(holderId, object.id)), - }), - ); - addNote(host, 'Copies the content into the scene and stops', 'small'); - addNote(host, 'following the template.', 'small'); } /** Pushes the current model values into the rows. False when the object is gone (rebuild). */ diff --git a/src/editor/viewport/FreeViewport.ts b/src/editor/viewport/FreeViewport.ts index 0fec2278..dfb9867c 100644 --- a/src/editor/viewport/FreeViewport.ts +++ b/src/editor/viewport/FreeViewport.ts @@ -40,6 +40,12 @@ export abstract class FreeViewport extends WorldViewport { * placed and where the preview places its root, so content far off to one side would hide it. */ fit(): void { + this.frameContent(); + this.redraw(); + } + + /** Points the camera at everything drawn, without drawing. */ + protected frameContent(): void { const { items } = this.collectFrame(); const boxes = items.map((item) => { const e = footprintExtent(item.type, item.props, item.m, item.frame); @@ -55,7 +61,6 @@ export abstract class FreeViewport extends WorldViewport { Math.min((this.w - FIT_MARGIN * 2) / width, (this.h - FIT_MARGIN * 2) / height) * this.ratio(), ), }; - this.redraw(); } /** Centres the view on a world point, keeping the zoom (test hook). */ diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index 830518fb..bedc7018 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -190,6 +190,7 @@ export class SceneViewport extends FreeViewport { state.on('load', () => { this.resetView(); + this.framedFor = null; this.redraw(); }); state.on('pick', () => this.redraw()); @@ -213,9 +214,33 @@ export class SceneViewport extends FreeViewport { return; } this.missedRedraw = false; + this.frameOpened(); super.redraw(); } + /** The scene the view was last framed for; null until the first one after a load. */ + private framedFor: string | null = null; + + /** + * A scene comes up framed as if Fit had been clicked (#295) – after a load and when another one + * is opened. From then on the view is the user's. A scene loaded by the running game is not one + * opened: the view is back on the edited scene when the game stops. An empty scene has nothing + * to frame and comes up at the origin, one pixel per unit. + */ + private frameOpened(): void { + if (!this.framesOnOpen() || this.state.mode !== 'edit' || this.w <= 0 || this.h <= 0) return; + const id = this.holder()?.id ?? ''; + if (id === this.framedFor) return; + this.framedFor = id; + this.resetView(); + if (this.collectFrame().items.length > 0) this.frameScene(); + } + + /** The Container tab keeps the view of each container instead (#171). */ + protected framesOnOpen(): boolean { + return true; + } + /** Coming back on screen: draws what was skipped while hidden. */ catchUp(): void { if (this.missedRedraw) this.redraw(); @@ -272,12 +297,16 @@ export class SceneViewport extends FreeViewport { * switching between the two tabs moves nothing. Without one, frames the whole content. */ fitScene(): void { - const camera = cameraOfItems(this.collectFrame().items); - if (!camera) return this.fit(); - this.view = freeViewOfCamera(camera, this.w, this.h, this.state.gameScale, this.ratio()); + this.frameScene(); this.redraw(); } + private frameScene(): void { + const camera = cameraOfItems(this.collectFrame().items); + if (camera) this.view = freeViewOfCamera(camera, this.w, this.h, this.state.gameScale, this.ratio()); + else this.frameContent(); + } + /** Where Ctrl+V puts objects: under the pointer while it is over this view, else nowhere in particular. */ pasteHere(): boolean { const holder = this.holder(); diff --git a/tests/core/polygon.test.ts b/tests/core/polygon.test.ts index 733076ea..b3359236 100644 --- a/tests/core/polygon.test.ts +++ b/tests/core/polygon.test.ts @@ -21,6 +21,7 @@ import { shapeContains, shapesOverlap, } from '../../src/core/properties/collider'; +import { defaultsFor, isRowShown, schemaFor } from '../../src/core/properties/PropertySchema'; import { gripAt, withCornerAfter, withCornerMoved, withoutCorner } from '../../src/editor/polygonEdit'; const P = (...xy: number[]) => @@ -265,6 +266,14 @@ describe('editing an outline by its corners', () => { expect(withoutCorner(P(0, 0, 1, 0, 0, 1), 0)).toBeNull(); expect(withoutCorner(P(0, 0, 1, 0), 0, 0)).toEqual(P(1, 0)); }); + + it('the corners are a property of the collider, but no row in the inspector (#295)', () => { + const collider = schemaFor('collider'); + expect(collider.map((d) => d.key)).toContain('points'); + expect(defaultsFor('collider').points).toHaveLength(3); + for (const shape of ['box', 'polygon']) + expect(collider.filter((d) => isRowShown(d, { shape })).map((d) => d.key)).not.toContain('points'); + }); }); /** A ring as a set of corners, whichever corner it starts at and whichever way it runs. */ diff --git a/tools/e2e/03-play.mjs b/tools/e2e/03-play.mjs index ce6af1a5..0e321089 100644 --- a/tools/e2e/03-play.mjs +++ b/tools/e2e/03-play.mjs @@ -402,6 +402,20 @@ export async function play(t) { const { ok, detail } = await sameAsGame(label); check(`Fit in the scene tab shows the world exactly as the game view does at ${label}`, ok, detail); } + // 3i. A project that is loaded brings its scene up framed the same way, without the click (#295). + // The scale still stands at Fit, so the framed view is not the one a bare reset would give. + const gameAtFit = await json(`__gm.gameRect('o1')`); + await showTab('scene'); + await evaluate('__gm.lookAt(5000, 5000)'); + await evaluate('__gm.state.loadProject(__gm.state.project, __gm.state.revision)'); + await showTab('scene'); + const sceneOnLoad = await json(`__gm.objectRect('o1')`); + check( + 'a loaded project shows its scene framed like Fit, without a click', + Boolean(sceneOnLoad) && ['x', 'y', 'w', 'h'].every((k) => Math.abs(sceneOnLoad[k] - gameAtFit[k]) < 0.01), + JSON.stringify({ gameAtFit, sceneOnLoad }), + ); + await showTab('game'); g = await pickScale('100%'); check('and 100% can be picked back', g.mode === 1 && g.scale === 1, JSON.stringify(g)); await showTab('scene'); diff --git a/tools/e2e/04-assets.mjs b/tools/e2e/04-assets.mjs index 723f3c45..0ae5ce3a 100644 --- a/tools/e2e/04-assets.mjs +++ b/tools/e2e/04-assets.mjs @@ -151,6 +151,8 @@ export async function assets(t) { `__gm.state.project.assets.push({ id: 'scene2', type: 'scene', name: 'Level 2', props: { backgroundColor: 0x22252c }, objects: [] })`, ); const secondScene = await json("__gm.assetRow('scene2')"); + await evaluate('__gm.lookAt(5000, 5000)'); + const pannedAway = await json(`__gm.objectRect('o1')`); await showTab('graph'); await doubleClick(secondScene.x, secondScene.y); check( @@ -166,6 +168,18 @@ export async function assets(t) { (await json('__gm.state.project.activeSceneId')) === 'scene1', await json('__gm.state.project.activeSceneId'), ); + // An opened scene comes up framed, as if Fit had been clicked (#295) – not where the view stood. + const opened = await json(`__gm.objectRect('o1')`); + const fitAt = await json(`__gm.sceneHeaderAt('fit')`); + await click(fitAt.x, fitAt.y); + const fitted = await json(`__gm.objectRect('o1')`); + const sameRect = (a, b) => + Boolean(a && b) && ['x', 'y', 'w', 'h'].every((k) => Math.abs(a[k] - b[k]) < 0.01); + check( + 'a scene that is opened comes up framed like after a click on Fit', + sameRect(opened, fitted) && !sameRect(opened, pannedAway), + JSON.stringify({ pannedAway, opened, fitted }), + ); await mutate(`__gm.state.project.assets = __gm.state.project.assets.filter((a) => a.id !== 'scene2')`); // 4d′. Opening an asset: a click on its row only selects, a double-click opens the tab it diff --git a/tools/e2e/09-containers.mjs b/tools/e2e/09-containers.mjs index 176c9593..559147cc 100644 --- a/tools/e2e/09-containers.mjs +++ b/tools/e2e/09-containers.mjs @@ -388,10 +388,12 @@ export async function containers(t) { const storedBeforeUnpack = (await storedObjects()).length; await selectObject(instanceC.id); check( - 'the inspector shows what the placed container follows', - await has('Follows a template of 5 object(s).'), + 'the inspector shows what the placed container follows, without a button to unpack it (#295)', + (await has('Follows a template of 5 object(s).')) && !(await has('Unpack')), ); - await clickText('Unpack'); + const instanceCRow = await json(`__gm.hierarchyRow(${JSON.stringify(instanceC.id)})`); + await click(instanceCRow.x, instanceCRow.y, 'right'); + await clickText('Unpack container'); check( 'Unpack writes the content into the scene, keeping the ids and the listing', (await storedObjects()).length === storedBeforeUnpack + 5 && @@ -405,8 +407,9 @@ export async function containers(t) { ); await selectObject(instanceC.id); check( - 'and the inspector stops offering the unpack, there is nothing left to follow', - !(await has('Unpack')) && !(await has('Follows a template of 5 object(s).')), + 'and the inspector says so, there is nothing left to follow', + (await has('Unpacked: its content is stored in the scene.')) && + !(await has('Follows a template of 5 object(s).')), ); await setTemplate(`tpl.position.x = -300`); check( @@ -597,6 +600,17 @@ export async function containers(t) { const listedBeforeReload = (await objects()).length; await navigate(); await sleep(500); + // The reloaded scene stands framed already (#295): a click on Fit moves nothing. + const afterReload = await json(`__gm.objectRect(${JSON.stringify(instanceB.id)})`); + const fitButton = await json(`__gm.sceneHeaderAt('fit')`); + await click(fitButton.x, fitButton.y); + const afterFit = await json(`__gm.objectRect(${JSON.stringify(instanceB.id)})`); + check( + 'after a reload the scene is framed as Fit frames it', + Boolean(afterReload && afterFit) && + ['x', 'y', 'w', 'h'].every((k) => Math.abs(afterReload[k] - afterFit[k]) < 0.01), + JSON.stringify({ afterReload, afterFit }), + ); check( 'a reloaded project resolves the same content again, deviations included', (await objects()).length === listedBeforeReload &&