From abf3b1f618f3516e31c73774b4cfcb8eec142e34 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Wed, 23 Sep 2026 18:29:34 +0200 Subject: [PATCH] Pick an existing collider or rigidbody from the object menu (#222) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Add collider" and "Add rigidbody" could only create a fresh asset. Reusing one meant creating a copy by hand, and a new box collider never matched the sprite it was added to. - "Add collider…" and "Add rigidbody…" open a second menu at the same spot: a search field, "Create new …", a separator, then every asset of that kind sorted by name. Enter takes the first match. - An existing asset is placed as a shared child, not copied. An asset the object already carries is greyed out. - The rule for when the entries are available lives in one place in the collider commands. An object without a resolvable asset is greyed out again, as with the old canAddCollider. - "Fit to sprite" in the menu of a placed collider sizes it to its sprite and centres it (position 0,0, z kept) as one undo step. - Fit writes overrides of that placement, not the asset, because a collider can now sit on several sprites. A value equal to the asset value clears the override instead. - Fit removes the collider's own scale, swaps width and height near 90 degrees and gives a circle the radius that fits (min/2). It is greyed out for polygon and frame outlines and off a sprite. - Menu has no submenus, so a second menu opens instead, similar to "Add Component" in Unity. That costs one click more than before. Tests: - ./check.sh: 152 files, 2234 tests, CHECK EXIT 0 (before 151/2228). - 6 new unit tests in tests/core/collider-parts.test.ts. - 3 new e2e checks in 08a: submenu with search and greying, an existing collider via search and Enter, Fit on oc2. - 09, 40 and 41 follow the new labels. - e2e 1-16,39-41 on the final build: ALL PASSED, 596 checks. - Counter-checks: no greying, existing asset ignored and fit without centring each fail at their own check. Breaking the override rule fails 2 unit tests. - CI confirmed 1345 checks at 2cf7202 (task 265). New total 1348, calculated. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/core/commands/colliderCommands.ts | 153 +++++++++++++++++++------- src/editor/actions.ts | 34 +++--- src/editor/objectMenu.ts | 51 ++++++++- tests/core/collider-parts.test.ts | 110 ++++++++++++++++++ tools/e2e/08-colliders.mjs | 71 ++++++++++-- tools/e2e/09-containers.mjs | 4 +- tools/e2e/40-tilemap.mjs | 4 +- tools/e2e/41-physics.mjs | 4 +- 9 files changed, 361 insertions(+), 72 deletions(-) create mode 100644 tests/core/collider-parts.test.ts diff --git a/README.md b/README.md index 61ca1b88..dd8e233e 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, 1345 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1348 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/colliderCommands.ts b/src/core/commands/colliderCommands.ts index 2301e2b9..790d8c39 100644 --- a/src/core/commands/colliderCommands.ts +++ b/src/core/commands/colliderCommands.ts @@ -1,71 +1,91 @@ import { newId } from '../ids'; -import { isLiveInstance } from '../model/instance'; +import { findObject } from '../model/hierarchy'; +import { isLiveInstance, type ObjectRef, resolvedHolder } from '../model/instance'; import type { Asset } from '../model/Asset'; +import type { PropertyBag } from '../model/Properties'; import { findAsset, type ObjectHolder, type Project } from '../model/Project'; import { createSceneObject, type SceneObject } from '../model/SceneObject'; import { resolveSceneObjectProps } from '../properties/resolve'; +import { transformOfObject } from '../properties/transform'; import { removeById } from '../util/list'; import { buildAsset } from './assetCommands'; import { type Command, CommandError } from './Command'; import { nextAssetName } from './naming'; import { requireAsset, requireHolder, requireStructuralObject } from './require'; +import { ObjectEditCommand } from './sceneObjectCommands'; -/** A collider or a rigidbody: a new asset, placed as a child at the object's centre. */ +type PartType = 'collider' | 'rigidbody'; + +/** + * A collider or a rigidbody placed as a child at the object's centre: a new asset, or – given + * `existingAssetId` (#222) – one that is there already, shared with wherever else it is placed. + */ abstract class AddPartCommand implements Command { abstract readonly label: string; - readonly assetId = newId(); + protected abstract readonly partType: PartType; + readonly assetId: string; readonly objectId = newId(); + private readonly reuses: boolean; constructor( private readonly holderId: string, private readonly targetId: string, - ) {} + existingAssetId = '', + ) { + this.assetId = existingAssetId || newId(); + this.reuses = existingAssetId !== ''; + } - /** The asset to add, named after `owner` – or a `CommandError` saying why the object takes none. */ - protected abstract build( - project: Project, - holder: ObjectHolder, - target: SceneObject, - targetAsset: Asset, - owner: string, - ): Asset; + /** Why the object takes no part of this kind, or null when it does. */ + protected abstract refusal(project: Project, holder: ObjectHolder, target: SceneObject): string | null; + + /** The new asset, named after `owner`. */ + protected abstract build(project: Project, target: SceneObject, targetAsset: Asset, owner: string): Asset; execute(project: Project): void { const holder = requireHolder(project, this.holderId); const target = requireStructuralObject(project, holder, this.targetId); const targetAsset = requireAsset(project, target.assetId); - project.assets.push(this.build(project, holder, target, targetAsset, target.name || targetAsset.name)); + const refusal = this.refusal(project, holder, target); + if (refusal) throw new CommandError(refusal); + if (this.reuses) { + if (requireAsset(project, this.assetId).type !== this.partType) + throw new CommandError(`That asset is not a ${this.partType}`); + } else { + project.assets.push(this.build(project, target, targetAsset, target.name || targetAsset.name)); + } holder.objects.push(createSceneObject(this.objectId, this.assetId, { x: 0, y: 0, z: 0 }, this.targetId)); } undo(project: Project): void { const holder = requireHolder(project, this.holderId); holder.objects = removeById(holder.objects, this.objectId); - project.assets = removeById(project.assets, this.assetId); + if (!this.reuses) project.assets = removeById(project.assets, this.assetId); } } -/** Creates a collider asset sized like the object (sprites: width × height; anything else: schema defaults). */ +/** Why the object cannot take a collider, or null when it can. */ +export function colliderRefusal(project: Project, holder: ObjectHolder, target: SceneObject): string | null { + const type = findAsset(project, target.assetId)?.type; + if (!type) return 'The object has no asset'; + if (type === 'collider') return 'A collider cannot have a collider'; + if (isLiveInstance(project, holder, target)) + return 'A placed container shows its template; add the collider inside it'; + return null; +} + +/** A new collider asset is sized like the object (sprites: width × height; anything else: schema defaults). */ export class AddColliderCommand extends AddPartCommand { readonly label = 'Add collider'; + protected readonly partType = 'collider'; - protected build( - project: Project, - holder: ObjectHolder, - target: SceneObject, - targetAsset: Asset, - owner: string, - ): Asset { - if (targetAsset.type === 'collider') throw new CommandError('A collider cannot have a collider'); - if (isLiveInstance(project, holder, target)) - throw new CommandError('A placed container shows its template; add the collider inside it'); + protected refusal(project: Project, holder: ObjectHolder, target: SceneObject): string | null { + return colliderRefusal(project, holder, target); + } + protected build(project: Project, target: SceneObject, targetAsset: Asset, owner: string): Asset { const asset = buildAsset('collider', nextAssetName(project, `${owner} collider`), this.assetId); - if (targetAsset.type === 'sprite') { - const props = resolveSceneObjectProps(project, targetAsset, target); - asset.props.width = Number(props.width); - asset.props.height = Number(props.height); - } + if (targetAsset.type === 'sprite') Object.assign(asset.props, spriteSize(project, targetAsset, target)); return asset; } } @@ -84,19 +104,72 @@ export function rigidbodyRefusal(project: Project, holder: ObjectHolder, target: return taken ? 'The object has a rigidbody already' : null; } -/** Creates a rigidbody asset with the schema defaults. */ +/** A new rigidbody asset has the schema defaults. */ export class AddRigidbodyCommand extends AddPartCommand { readonly label = 'Add rigidbody'; + protected readonly partType = 'rigidbody'; - protected build( - project: Project, - holder: ObjectHolder, - target: SceneObject, - _targetAsset: Asset, - owner: string, - ): Asset { - const refusal = rigidbodyRefusal(project, holder, target); - if (refusal) throw new CommandError(refusal); + protected refusal(project: Project, holder: ObjectHolder, target: SceneObject): string | null { + return rigidbodyRefusal(project, holder, target); + } + + protected build(project: Project, _target: SceneObject, _targetAsset: Asset, owner: string): Asset { return buildAsset('rigidbody', nextAssetName(project, `${owner} rigidbody`), this.assetId); } } + +function spriteSize(project: Project, asset: Asset, object: SceneObject): { width: number; height: number } { + const props = resolveSceneObjectProps(project, asset, object); + return { width: Number(props.width), height: Number(props.height) }; +} + +/** + * What "Fit to sprite" writes on a placed collider (#222): the size that covers its parent sprite, + * measured in the collider's own units (its scale divided out, width and height swapped when it + * stands turned by a quarter), or why it cannot. A circle takes the radius that fits inside. + */ +export function colliderFit( + project: Project, + holder: ObjectHolder, + collider: SceneObject, +): PropertyBag | string { + const asset = findAsset(project, collider.assetId); + if (asset?.type !== 'collider') return 'Only a collider can be fitted'; + const tree = resolvedHolder(project, holder); + const parent = collider.parentId ? findObject(tree, collider.parentId) : undefined; + const parentAsset = parent && findAsset(project, parent.assetId); + if (!parent || parentAsset?.type !== 'sprite') return 'The collider does not sit on a sprite'; + const shape = resolveSceneObjectProps(project, asset, collider).shape; + if (shape === 'polygon' || shape === 'frame') return 'A polygon or frame outline has no size to fit'; + + const { width, height } = spriteSize(project, parentAsset, parent); + const { rotation, scale } = transformOfObject(asset, collider); + const turned = Math.abs(Math.sin((rotation * Math.PI) / 180)) > Math.SQRT1_2; + const [w, h] = (turned ? [height, width] : [width, height]).map((v) => Math.max(1, v / (scale || 1))); + return shape === 'circle' ? { radius: Math.max(1, Math.min(w!, h!) / 2) } : { width: w!, height: h! }; +} + +/** + * Fits one placed collider to its sprite and centres it there. The size goes into this placement's + * overrides, since the collider asset may be worn elsewhere too; a value equal to the asset's own + * clears the override instead. + */ +export class FitColliderCommand extends ObjectEditCommand { + readonly label = 'Fit collider to sprite'; + + constructor( + private readonly fitHolderId: string, + objectId: string, + ) { + super(fitHolderId, objectId); + } + + protected override apply(ref: ObjectRef, project: Project): void { + const fit = colliderFit(project, requireHolder(project, this.fitHolderId), ref.object); + if (typeof fit === 'string') throw new CommandError(fit); + const asset = requireAsset(project, ref.object.assetId); + for (const [key, value] of Object.entries(fit)) + ref.setOverride(key, asset.props[key] === value ? undefined : value); + ref.setPosition({ x: 0, y: 0, z: ref.object.position.z }); + } +} diff --git a/src/editor/actions.ts b/src/editor/actions.ts index 7b866eb4..71fc1117 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -4,14 +4,19 @@ */ import { annotationNoun, RemoveAnnotationCommand } from '../core/commands/annotationCommands'; import { DeleteAssetCommand, SetActiveSceneCommand } from '../core/commands/assetCommands'; -import { AddColliderCommand, AddRigidbodyCommand, rigidbodyRefusal } from '../core/commands/colliderCommands'; +import { + AddColliderCommand, + AddRigidbodyCommand, + colliderRefusal, + rigidbodyRefusal, +} from '../core/commands/colliderCommands'; import { oneStep } from '../core/commands/CompoundCommand'; import { CreateContainerCommand } from '../core/commands/containerCommands'; import { RemoveNodeCommand } from '../core/commands/graphCommands'; import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands'; import type { AssetType } from '../core/model/AssetTypes'; import { generatesPictures } from '../core/model/generated'; -import { isLiveInstance, isTemplateContent } from '../core/model/instance'; +import { isTemplateContent } from '../core/model/instance'; import type { Command } from '../core/commands/Command'; import { findAnnotation } from '../core/graph'; import { findAsset, findGraphAsset, type ObjectHolder, type Project } from '../core/model/Project'; @@ -39,20 +44,17 @@ export function createContainer(state: EditorState, holderId: string, objectIds: return true; } -/** The test `AddColliderCommand` makes, so no place offers what it would refuse. */ +/** The test `AddColliderCommand` makes; template content has no structure of its own to change. */ export function canAddCollider(project: Project, holder: ObjectHolder, object: SceneObject): boolean { - const type = findAsset(project, object.assetId)?.type; - return ( - type !== undefined && - type !== 'collider' && - !isTemplateContent(holder, object.id) && - !isLiveInstance(project, holder, object) - ); + return !isTemplateContent(holder, object.id) && colliderRefusal(project, holder, object) === null; } -/** Gives the object a collider child sized like it; selects the collider. */ -export function addCollider(state: EditorState, holderId: string, objectId: string): boolean { - const command = new AddColliderCommand(holderId, objectId); +/** + * Gives the object a collider child – a new one sized like it, or the existing `assetId` (#222); + * selects the collider. + */ +export function addCollider(state: EditorState, holderId: string, objectId: string, assetId = ''): boolean { + const command = new AddColliderCommand(holderId, objectId, assetId); if (!state.execute(command)) return false; state.select(objectSelection(holderId, [command.objectId])); return true; @@ -63,9 +65,9 @@ export function canAddRigidbody(project: Project, holder: ObjectHolder, object: return !isTemplateContent(holder, object.id) && rigidbodyRefusal(project, holder, object) === null; } -/** Gives the object a rigidbody child; selects the rigidbody. */ -export function addRigidbody(state: EditorState, holderId: string, objectId: string): boolean { - const command = new AddRigidbodyCommand(holderId, objectId); +/** Gives the object a rigidbody child, new or the existing `assetId`; selects the rigidbody. */ +export function addRigidbody(state: EditorState, holderId: string, objectId: string, assetId = ''): boolean { + const command = new AddRigidbodyCommand(holderId, objectId, assetId); if (!state.execute(command)) return false; state.select(objectSelection(holderId, [command.objectId])); return true; diff --git a/src/editor/objectMenu.ts b/src/editor/objectMenu.ts index a68fcbeb..89a5a2c5 100644 --- a/src/editor/objectMenu.ts +++ b/src/editor/objectMenu.ts @@ -1,3 +1,4 @@ +import { colliderFit, FitColliderCommand } from '../core/commands/colliderCommands'; import { UnpackContainerCommand } from '../core/commands/containerCommands'; import { SetParentCommand } from '../core/commands/sceneObjectCommands'; import { findObject } from '../core/model/hierarchy'; @@ -64,15 +65,24 @@ export function openObjectMenu( ) : []), { - label: 'Add collider', + label: 'Add collider…', disabled: !object || many || !canAddCollider(state.project, source, object), - onSelect: () => addCollider(state, holder.id, id), + onSelect: () => openPartMenu(state, 'collider', holder, id, x, y), }, { - label: 'Add rigidbody', + label: 'Add rigidbody…', disabled: !object || many || !canAddRigidbody(state.project, source, object), - onSelect: () => addRigidbody(state, holder.id, id), + onSelect: () => openPartMenu(state, 'rigidbody', holder, id, x, y), }, + ...(object && asset?.type === 'collider' + ? [ + { + label: 'Fit to sprite', + disabled: many || typeof colliderFit(state.project, source, object) === 'string', + onSelect: () => state.execute(new FitColliderCommand(holder.id, id)), + }, + ] + : []), { label: many ? `Create container from ${selected.length} objects` : 'Create container', disabled: selected.some((oid) => isTemplateContent(source, oid)), @@ -102,6 +112,39 @@ export function openObjectMenu( return menu; } +/** + * Which collider or rigidbody to add (#222): a new one, or any the project has, found by typing. One + * the object wears already is greyed out – a second copy of it would only double up. + */ +function openPartMenu( + state: EditorState, + type: 'collider' | 'rigidbody', + holder: ObjectHolder, + id: string, + x: number, + y: number, +): Menu { + const add = type === 'collider' ? addCollider : addRigidbody; + const worn = new Set(holder.objects.filter((o) => o.parentId === id).map((o) => o.assetId)); + const existing = state.project.assets + .filter((a) => a.type === type) + .sort((a, b) => a.name.localeCompare(b.name)); + const menu = new Menu( + [ + { label: `Create new ${type}`, onSelect: () => add(state, holder.id, id) }, + { separator: true }, + ...existing.map((a) => ({ + label: a.name, + disabled: worn.has(a.id), + onSelect: () => add(state, holder.id, id, a.id), + })), + ], + { searchable: true }, + ); + menu.open(x, y); + return menu; +} + /** * "Open texture", "Open shader", … – one entry per asset the values point at (#221), skipping kinds * that have no tab to open. The asset panel offers the same for an asset's own values. diff --git a/tests/core/collider-parts.test.ts b/tests/core/collider-parts.test.ts new file mode 100644 index 00000000..e6b3269d --- /dev/null +++ b/tests/core/collider-parts.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; +import { + AddColliderCommand, + AddRigidbodyCommand, + colliderFit, + FitColliderCommand, +} from '../../src/core/commands/colliderCommands'; +import { MoveSceneObjectCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; +import { makeBehavior, makeSpriteScene, place } from '../helpers'; + +/** A 100×60 crate placed twice, the first with a collider of its own. */ +function setup() { + const f = makeSpriteScene('Crate'); + f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 60)); + const a = place(f, f.spriteId, { x: 0, y: 0 }); + const b = place(f, f.spriteId, { x: 300, y: 0 }); + const collider = new AddColliderCommand(f.sceneId, a); + f.stack.execute(collider); + return { ...f, a, b, collider }; +} + +describe('adding an existing collider or rigidbody (#222)', () => { + it('places the existing collider below the object without a new asset; undo keeps the asset', () => { + const f = setup(); + const count = f.project.assets.length; + const reuse = new AddColliderCommand(f.sceneId, f.b, f.collider.assetId); + f.stack.execute(reuse); + expect(f.project.assets).toHaveLength(count); + expect(reuse.assetId).toBe(f.collider.assetId); + expect(f.object(reuse.objectId)).toMatchObject({ assetId: f.collider.assetId, parentId: f.b }); + f.stack.undo(); + expect(f.sceneOf().objects.some((o) => o.id === reuse.objectId)).toBe(false); + expect(f.project.assets.some((x) => x.id === f.collider.assetId)).toBe(true); + }); + + it('refuses an asset of the wrong kind and keeps the rules of a new part', () => { + const f = setup(); + const behavior = makeBehavior(f, 'Hit'); + expect(() => f.stack.execute(new AddColliderCommand(f.sceneId, f.b, behavior.id))).toThrow( + /not a collider/, + ); + expect(() => + f.stack.execute(new AddColliderCommand(f.sceneId, f.collider.objectId, f.collider.assetId)), + ).toThrow(/cannot have a collider/); + + const body = new AddRigidbodyCommand(f.sceneId, f.a); + f.stack.execute(body); + const shared = new AddRigidbodyCommand(f.sceneId, f.b, body.assetId); + f.stack.execute(shared); + expect(f.object(shared.objectId)).toMatchObject({ assetId: body.assetId, parentId: f.b }); + expect(() => f.stack.execute(new AddRigidbodyCommand(f.sceneId, f.b, body.assetId))).toThrow( + /has a rigidbody already/, + ); + }); +}); + +describe('fitting a collider to its sprite (#222)', () => { + it('covers the sprite as it is now and centres the collider; undo puts everything back', () => { + const f = setup(); + const { collider } = f; + f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'width', 240)); + f.stack.execute(new MoveSceneObjectCommand(f.sceneId, collider.objectId, { x: 30, y: -10, z: 2 })); + f.stack.execute(new FitColliderCommand(f.sceneId, collider.objectId)); + expect(f.object(collider.objectId).overrides).toEqual({ width: 240 }); + expect(f.object(collider.objectId).position).toEqual({ x: 0, y: 0, z: 2 }); + f.stack.undo(); + expect(f.object(collider.objectId).overrides).toEqual({}); + expect(f.object(collider.objectId).position).toEqual({ x: 30, y: -10, z: 2 }); + }); + + it('writes this placement only, so a collider shared by two sprites keeps its asset size', () => { + const f = setup(); + f.stack.execute(new SetOverrideCommand(f.sceneId, f.b, 'width', 50)); + const shared = new AddColliderCommand(f.sceneId, f.b, f.collider.assetId); + f.stack.execute(shared); + f.stack.execute(new FitColliderCommand(f.sceneId, shared.objectId)); + expect(f.object(shared.objectId).overrides).toEqual({ width: 50 }); + expect(f.object(f.collider.objectId).overrides).toEqual({}); + }); + + it('divides out the collider’s scale, swaps for a quarter turn and fits a circle inside', () => { + const f = setup(); + const id = f.collider.objectId; + const fit = () => colliderFit(f.project, f.sceneOf(), f.object(id)); + f.stack.execute(new SetOverrideCommand(f.sceneId, id, 'scale', 2)); + expect(fit()).toEqual({ width: 50, height: 30 }); + f.stack.execute(new SetOverrideCommand(f.sceneId, id, 'rotation', 90)); + expect(fit()).toEqual({ width: 30, height: 50 }); + f.stack.execute(new SetOverrideCommand(f.sceneId, id, 'shape', 'circle')); + expect(fit()).toEqual({ radius: 15 }); + }); + + it('says why a collider cannot be fitted', () => { + const f = setup(); + f.stack.execute(new SetOverrideCommand(f.sceneId, f.collider.objectId, 'shape', 'polygon')); + expect(colliderFit(f.project, f.sceneOf(), f.object(f.collider.objectId))).toMatch(/polygon/); + expect(colliderFit(f.project, f.sceneOf(), f.object(f.a))).toMatch(/Only a collider/); + + const text = new CreateAssetCommand('text', 'Label'); + f.stack.execute(text); + const label = place(f, text.asset.id, { x: 0, y: 200 }); + const onText = new AddColliderCommand(f.sceneId, label); + f.stack.execute(onText); + expect(colliderFit(f.project, f.sceneOf(), f.object(onText.objectId))).toMatch(/not sit on a sprite/); + expect(() => f.stack.execute(new FitColliderCommand(f.sceneId, onText.objectId))).toThrow( + /not sit on a sprite/, + ); + }); +}); diff --git a/tools/e2e/08-colliders.mjs b/tools/e2e/08-colliders.mjs index 4cabe8b7..34078db4 100644 --- a/tools/e2e/08-colliders.mjs +++ b/tools/e2e/08-colliders.mjs @@ -33,14 +33,35 @@ export async function colliders(t) { stop, } = t; - // 8a. Right-click the red crate → "Add collider": a collider asset sized like the crate - // (100×60) is placed as its child at the centre, selected and drawn in the overlay. The - // seed already gives both crates a "Crate collider" child (oc1/oc2), so the added one is - // undone again to keep exactly one collider per body for the checks below. + // 8a. Right-click the red crate → "Add collider…" opens a second, searchable menu (#222): a new + // collider or one the project has, the shared "Crate collider" the crate wears greyed out. + // "Create new collider" places a collider asset sized like the crate (100×60) as its child at + // the centre, selected and drawn in the overlay. The seed already gives both crates a + // "Crate collider" child (oc1/oc2), so the added one is undone again to keep exactly one + // collider per body for the checks below. const objectsBefore = (await objects()).length; - const redRow = await locate('Red crate'); - await click(redRow.x, redRow.y, 'right'); - await clickText('Add collider'); + const assetCount = () => json('__gm.state.project.assets.length'); + const colliderMenuOf = async (id) => { + await evaluate('__gm.hierarchyExpandAll()'); + const row = await inView(t, '__gm.leftColumn().objects', `__gm.hierarchyRow(${JSON.stringify(id)})`); + await click(row.x, row.y, 'right'); + await clickText('Add collider…'); + }; + await colliderMenuOf('o1'); + const pickRows = await json('__gm.menuRows()'); + const pickEnabled = await json('__gm.menuEnabled()'); + await typeText('crate'); + const searched = ((await json('__gm.menuRows()')) ?? []).filter(Boolean); + await press('Escape'); + check( + 'Add collider… offers a new or an existing collider, searchable; one the crate wears is greyed out', + JSON.stringify(pickRows) === JSON.stringify(['Create new collider', null, 'Crate collider']) && + JSON.stringify(pickEnabled) === JSON.stringify([true, null, false]) && + JSON.stringify(searched) === JSON.stringify(['Crate collider']), + JSON.stringify({ pickRows, pickEnabled, searched }), + ); + await colliderMenuOf('o1'); + await clickText('Create new collider'); const s = await state(); const colObj = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null; const colAsset = colObj ? await asset(colObj.assetId) : null; @@ -58,6 +79,22 @@ export async function colliders(t) { ); check('collider inspector shows the shape menu', Boolean(await evaluate(`__gm.locate('Box')`))); await shot('21-collider'); + + // The blue crate takes that new collider as it is: its name typed and Enter place it below the + // crate, no second asset made. + const assetsWithNew = await assetCount(); + await colliderMenuOf('o2'); + if (colAsset) await typeText(colAsset.name); + await press('Enter'); + const sShared = await state(); + const shared = sShared.selection?.kind === 'object' ? await object(sShared.selection.objectId) : null; + const assetsAfterShare = await assetCount(); + check( + 'picking an existing collider places it below the blue crate without a new asset', + shared?.parentId === 'o2' && shared.assetId === colAsset?.id && assetsAfterShare === assetsWithNew, + JSON.stringify({ shared, assetsWithNew, assetsAfterShare }), + ); + if (shared) await undo(); await undo(); const colliderAssetGone = colAsset ? !(await asset(colAsset.id)) : false; check( @@ -65,6 +102,26 @@ export async function colliders(t) { (await objects()).length === objectsBefore && colliderAssetGone, ); + // 8a-iii. "Fit to sprite" on a collider (#222): the blue crate's collider, pushed aside and + // narrowed, covers its 140-wide crate again and sits centred – which is how the seed has it. + await mutate(` + const oc2 = __gm.state.activeScene.objects.find(o => o.id === 'oc2'); + oc2.position = { x: 30, y: 12, z: 0 }; + oc2.overrides = { width: 90 };`); + const oc2Row = await inView(t, '__gm.leftColumn().objects', `__gm.hierarchyRow('oc2')`); + await click(oc2Row.x, oc2Row.y, 'right'); + const colliderRows = ((await json('__gm.menuRows()')) ?? []).filter(Boolean); + if (colliderRows.includes('Fit to sprite')) await clickText('Fit to sprite'); + else await press('Escape'); + const fitted = await object('oc2'); + check( + 'Fit to sprite sizes the collider like its sprite and centres it on the sprite', + JSON.stringify(fitted?.overrides) === JSON.stringify({ width: 140 }) && + fitted.position.x === 0 && + fitted.position.y === 0, + JSON.stringify({ colliderRows, fitted }), + ); + // 8b. Wire "On collision enter → Log(other)" into the Mover (owner = red crate) and play with // the crates overlapping: the log names the blue crate exactly once (enter, not stay), // and colliders are not rendered in play mode. diff --git a/tools/e2e/09-containers.mjs b/tools/e2e/09-containers.mjs index 6a1f143d..176c9593 100644 --- a/tools/e2e/09-containers.mjs +++ b/tools/e2e/09-containers.mjs @@ -302,7 +302,9 @@ export async function containers(t) { const before = (await objects()).length; const noticeNow = await notice(); await click(row.x, row.y, 'right'); - await clickText('Add collider'); + await clickText('Add collider…'); + // An enabled entry opens the second menu (#222); a disabled one leaves the first standing. + if (await has('Create new collider')) await clickText('Create new collider'); await press('Escape'); return { added: (await objects()).length - before, notice: await noticeNow() }; }; diff --git a/tools/e2e/40-tilemap.mjs b/tools/e2e/40-tilemap.mjs index fc418606..bf3c79f0 100644 --- a/tools/e2e/40-tilemap.mjs +++ b/tools/e2e/40-tilemap.mjs @@ -771,8 +771,8 @@ export async function tilemap(t) { const OBJECT_MENU = [ 'Select asset', 'Open asset', - 'Add collider', - 'Add rigidbody', + 'Add collider…', + 'Add rigidbody…', 'Create container', 'Unpack container', 'Unparent (move to top level)', diff --git a/tools/e2e/41-physics.mjs b/tools/e2e/41-physics.mjs index ad4d5246..584363a2 100644 --- a/tools/e2e/41-physics.mjs +++ b/tools/e2e/41-physics.mjs @@ -125,7 +125,9 @@ export async function physics(t) { const row = await inView(t, '__gm.leftColumn().objects', `__gm.hierarchyRow(${JSON.stringify(id)})`); const before = (await objects()).length; await click(row.x, row.y, 'right'); - await clickText('Add rigidbody'); + await clickText('Add rigidbody…'); + // An enabled entry opens the second menu (#222); a disabled one leaves the first standing. + if (await has('Create new rigidbody')) await clickText('Create new rigidbody'); await press('Escape'); const s = await state(); const selected = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null;