Open a scene framed, and thin out two inspector sections (#295)

Three small things asked for in one issue. A scene came up wherever
the view happened to stand, and the inspector carried a button and a
list that are better reached somewhere else.

- A scene comes up framed as if Fit had been clicked: after a
  project is loaded and when another scene is opened. From then on
  the view belongs to the user.
- An empty scene has nothing to frame and stands at the origin, one
  pixel per unit. A scene the running game loads is not framed anew.
- The Container tab is left out. It keeps the view of each container
  as before (#171).
- The Unpack button and the two lines under it are gone from the
  inspector of a placed container. Unpack container stays in the
  right-click menu of the hierarchy and of the scene view.
- The corners of a polygon collider are no row in the inspector any
  more. They are edited on the outline in the scene view. The new
  schema option noRow keeps a property out of the inspector while it
  stays a property.

Tests:
- Gate on melektaus-atx: 219 files, 3401 tests, CHECK EXIT 0.
- One new unit test in tests/core/polygon.test.ts: the corners are
  a property of the collider and no row.
- Three new e2e checks: a loaded project is framed like Fit (module
  03), an opened scene is (04), and after a real reload a click on
  Fit moves nothing (09).
- Module 09 unpacks through the right-click menu now and checks that
  the inspector offers no button.
- Full suite on melektaus-atx: 1695 checks, all pass. Smokes 24, 19
  and 21, all pass.
- No counter-probe evaluated, and nothing looked at after the build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-02 23:52:49 +02:00
1 parent ca5b2db449
commit 963f8a152a
10 files changed
+106 -23

No files matched your search

+1 -1
View File
@@ -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: The browser suite and the three example games need a machine with a GPU:
```bash ```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 # every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+6 -2
View File
@@ -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. */ /** Whether the inspector shows the row, given the values of the bag it is on. */
export function isRowShown(def: PropertyDef, values: PropertyBag): boolean { export function isRowShown(def: PropertyDef, values: PropertyBag): boolean {
if ('noRow' in def && def.noRow) return false;
if ( if (
'shownFor' in def && 'shownFor' in def &&
def.shownFor && 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`), * 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 * 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`). * 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 * `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 * keep its shape while single fields are written (`mergeStruct`). With a `structType` the fields
@@ -605,11 +607,13 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
{ key: 'width', label: 'Width', kind: 'number', default: 100, min: 1, step: 1 }, { 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: 'height', label: 'Height', kind: 'number', default: 100, min: 1, step: 1 },
{ key: 'radius', label: 'Radius', kind: 'number', default: 50, 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', key: 'points',
label: 'Points', label: 'Points',
kind: 'list', kind: 'list',
noRow: true,
default: [ default: [
{ x: 0, y: -50 }, { x: 0, y: -50 },
{ x: 50, y: 50 }, { x: 50, y: 50 },
+4
View File
@@ -37,6 +37,10 @@ export class ContainerCanvas extends SceneViewport {
return asset?.type === 'container' ? asset : undefined; 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. */ /** A container has no background of its own – painting one would invent a scene it is not in. */
protected override paintsBackground(): boolean { protected override paintsBackground(): boolean {
return false; return false;
+1 -11
View File
@@ -6,7 +6,6 @@ import {
RevertPositionToTemplateCommand, RevertPositionToTemplateCommand,
} from '../../core/commands/applyCommands'; } from '../../core/commands/applyCommands';
import type { ValuePath } from '../../core/properties/valuePath'; import type { ValuePath } from '../../core/properties/valuePath';
import { UnpackContainerCommand } from '../../core/commands/containerCommands';
import { ResizeTilemapEdgeCommand } from '../../core/commands/tilemapCommands'; import { ResizeTilemapEdgeCommand } from '../../core/commands/tilemapCommands';
import { TILEMAP_COLUMNS_KEY, TILEMAP_EDGES, TILEMAP_ROWS_KEY } from '../../core/model/tilemap'; import { TILEMAP_COLUMNS_KEY, TILEMAP_EDGES, TILEMAP_ROWS_KEY } from '../../core/model/tilemap';
import { findUsages } from '../../core/model/usages'; 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 – * 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( function contentSection(
host: InspectorHost, host: InspectorHost,
@@ -344,15 +343,6 @@ function contentSection(
); );
if (templateCount === 0) return; if (templateCount === 0) return;
addNote(host, 'Its content is listed below it in the hierarchy.', 'small'); 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). */ /** Pushes the current model values into the rows. False when the object is gone (rebuild). */
+6 -1
View File
@@ -40,6 +40,12 @@ export abstract class FreeViewport extends WorldViewport<EditorState> {
* placed and where the preview places its root, so content far off to one side would hide it. * placed and where the preview places its root, so content far off to one side would hide it.
*/ */
fit(): void { fit(): void {
this.frameContent();
this.redraw();
}
/** Points the camera at everything drawn, without drawing. */
protected frameContent(): void {
const { items } = this.collectFrame(); const { items } = this.collectFrame();
const boxes = items.map((item) => { const boxes = items.map((item) => {
const e = footprintExtent(item.type, item.props, item.m, item.frame); const e = footprintExtent(item.type, item.props, item.m, item.frame);
@@ -55,7 +61,6 @@ export abstract class FreeViewport extends WorldViewport<EditorState> {
Math.min((this.w - FIT_MARGIN * 2) / width, (this.h - FIT_MARGIN * 2) / height) * this.ratio(), 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). */ /** Centres the view on a world point, keeping the zoom (test hook). */
+32 -3
View File
@@ -190,6 +190,7 @@ export class SceneViewport extends FreeViewport {
state.on('load', () => { state.on('load', () => {
this.resetView(); this.resetView();
this.framedFor = null;
this.redraw(); this.redraw();
}); });
state.on('pick', () => this.redraw()); state.on('pick', () => this.redraw());
@@ -213,9 +214,33 @@ export class SceneViewport extends FreeViewport {
return; return;
} }
this.missedRedraw = false; this.missedRedraw = false;
this.frameOpened();
super.redraw(); 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. */ /** Coming back on screen: draws what was skipped while hidden. */
catchUp(): void { catchUp(): void {
if (this.missedRedraw) this.redraw(); 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. * switching between the two tabs moves nothing. Without one, frames the whole content.
*/ */
fitScene(): void { fitScene(): void {
const camera = cameraOfItems(this.collectFrame().items); this.frameScene();
if (!camera) return this.fit();
this.view = freeViewOfCamera(camera, this.w, this.h, this.state.gameScale, this.ratio());
this.redraw(); 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. */ /** Where Ctrl+V puts objects: under the pointer while it is over this view, else nowhere in particular. */
pasteHere(): boolean { pasteHere(): boolean {
const holder = this.holder(); const holder = this.holder();
+9
View File
@@ -21,6 +21,7 @@ import {
shapeContains, shapeContains,
shapesOverlap, shapesOverlap,
} from '../../src/core/properties/collider'; } from '../../src/core/properties/collider';
import { defaultsFor, isRowShown, schemaFor } from '../../src/core/properties/PropertySchema';
import { gripAt, withCornerAfter, withCornerMoved, withoutCorner } from '../../src/editor/polygonEdit'; import { gripAt, withCornerAfter, withCornerMoved, withoutCorner } from '../../src/editor/polygonEdit';
const P = (...xy: number[]) => 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, 1), 0)).toBeNull();
expect(withoutCorner(P(0, 0, 1, 0), 0, 0)).toEqual(P(1, 0)); 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. */ /** A ring as a set of corners, whichever corner it starts at and whichever way it runs. */
+14
View File
@@ -402,6 +402,20 @@ export async function play(t) {
const { ok, detail } = await sameAsGame(label); 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); 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%'); g = await pickScale('100%');
check('and 100% can be picked back', g.mode === 1 && g.scale === 1, JSON.stringify(g)); check('and 100% can be picked back', g.mode === 1 && g.scale === 1, JSON.stringify(g));
await showTab('scene'); await showTab('scene');
+14
View File
@@ -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: [] })`, `__gm.state.project.assets.push({ id: 'scene2', type: 'scene', name: 'Level 2', props: { backgroundColor: 0x22252c }, objects: [] })`,
); );
const secondScene = await json("__gm.assetRow('scene2')"); const secondScene = await json("__gm.assetRow('scene2')");
await evaluate('__gm.lookAt(5000, 5000)');
const pannedAway = await json(`__gm.objectRect('o1')`);
await showTab('graph'); await showTab('graph');
await doubleClick(secondScene.x, secondScene.y); await doubleClick(secondScene.x, secondScene.y);
check( check(
@@ -166,6 +168,18 @@ export async function assets(t) {
(await json('__gm.state.project.activeSceneId')) === 'scene1', (await json('__gm.state.project.activeSceneId')) === 'scene1',
await json('__gm.state.project.activeSceneId'), 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')`); 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 // 4d′. Opening an asset: a click on its row only selects, a double-click opens the tab it
+19 -5
View File
@@ -388,10 +388,12 @@ export async function containers(t) {
const storedBeforeUnpack = (await storedObjects()).length; const storedBeforeUnpack = (await storedObjects()).length;
await selectObject(instanceC.id); await selectObject(instanceC.id);
check( check(
'the inspector shows what the placed container follows', '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('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( check(
'Unpack writes the content into the scene, keeping the ids and the listing', 'Unpack writes the content into the scene, keeping the ids and the listing',
(await storedObjects()).length === storedBeforeUnpack + 5 && (await storedObjects()).length === storedBeforeUnpack + 5 &&
@@ -405,8 +407,9 @@ export async function containers(t) {
); );
await selectObject(instanceC.id); await selectObject(instanceC.id);
check( check(
'and the inspector stops offering the unpack, there is nothing left to follow', 'and the inspector says so, there is nothing left to follow',
!(await has('Unpack')) && !(await has('Follows a template of 5 object(s).')), (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`); await setTemplate(`tpl.position.x = -300`);
check( check(
@@ -597,6 +600,17 @@ export async function containers(t) {
const listedBeforeReload = (await objects()).length; const listedBeforeReload = (await objects()).length;
await navigate(); await navigate();
await sleep(500); 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( check(
'a reloaded project resolves the same content again, deviations included', 'a reloaded project resolves the same content again, deviations included',
(await objects()).length === listedBeforeReload && (await objects()).length === listedBeforeReload &&