Pick an existing collider or rigidbody from the object menu (#222)
game-maker / gate (push) Successful in 3m8s
game-maker / suite (push) Successful in 9m52s

"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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-23 18:29:34 +02:00
1 parent 2cf72022eb
commit abf3b1f618
9 files changed
+361 -72

No files matched your search

+1 -1
View File
@@ -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: 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, 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 # 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
+113 -40
View File
@@ -1,71 +1,91 @@
import { newId } from '../ids'; 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 { Asset } from '../model/Asset';
import type { PropertyBag } from '../model/Properties';
import { findAsset, type ObjectHolder, type Project } from '../model/Project'; import { findAsset, type ObjectHolder, type Project } from '../model/Project';
import { createSceneObject, type SceneObject } from '../model/SceneObject'; import { createSceneObject, type SceneObject } from '../model/SceneObject';
import { resolveSceneObjectProps } from '../properties/resolve'; import { resolveSceneObjectProps } from '../properties/resolve';
import { transformOfObject } from '../properties/transform';
import { removeById } from '../util/list'; import { removeById } from '../util/list';
import { buildAsset } from './assetCommands'; import { buildAsset } from './assetCommands';
import { type Command, CommandError } from './Command'; import { type Command, CommandError } from './Command';
import { nextAssetName } from './naming'; import { nextAssetName } from './naming';
import { requireAsset, requireHolder, requireStructuralObject } from './require'; 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 class AddPartCommand implements Command {
abstract readonly label: string; abstract readonly label: string;
readonly assetId = newId(); protected abstract readonly partType: PartType;
readonly assetId: string;
readonly objectId = newId(); readonly objectId = newId();
private readonly reuses: boolean;
constructor( constructor(
private readonly holderId: string, private readonly holderId: string,
private readonly targetId: 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. */ /** Why the object takes no part of this kind, or null when it does. */
protected abstract build( protected abstract refusal(project: Project, holder: ObjectHolder, target: SceneObject): string | null;
project: Project,
holder: ObjectHolder, /** The new asset, named after `owner`. */
target: SceneObject, protected abstract build(project: Project, target: SceneObject, targetAsset: Asset, owner: string): Asset;
targetAsset: Asset,
owner: string,
): Asset;
execute(project: Project): void { execute(project: Project): void {
const holder = requireHolder(project, this.holderId); const holder = requireHolder(project, this.holderId);
const target = requireStructuralObject(project, holder, this.targetId); const target = requireStructuralObject(project, holder, this.targetId);
const targetAsset = requireAsset(project, target.assetId); 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)); holder.objects.push(createSceneObject(this.objectId, this.assetId, { x: 0, y: 0, z: 0 }, this.targetId));
} }
undo(project: Project): void { undo(project: Project): void {
const holder = requireHolder(project, this.holderId); const holder = requireHolder(project, this.holderId);
holder.objects = removeById(holder.objects, this.objectId); 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 { export class AddColliderCommand extends AddPartCommand {
readonly label = 'Add collider'; readonly label = 'Add collider';
protected readonly partType = 'collider';
protected build( protected refusal(project: Project, holder: ObjectHolder, target: SceneObject): string | null {
project: Project, return colliderRefusal(project, holder, target);
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 build(project: Project, target: SceneObject, targetAsset: Asset, owner: string): Asset {
const asset = buildAsset('collider', nextAssetName(project, `${owner} collider`), this.assetId); const asset = buildAsset('collider', nextAssetName(project, `${owner} collider`), this.assetId);
if (targetAsset.type === 'sprite') { if (targetAsset.type === 'sprite') Object.assign(asset.props, spriteSize(project, targetAsset, target));
const props = resolveSceneObjectProps(project, targetAsset, target);
asset.props.width = Number(props.width);
asset.props.height = Number(props.height);
}
return asset; return asset;
} }
} }
@@ -84,19 +104,72 @@ export function rigidbodyRefusal(project: Project, holder: ObjectHolder, target:
return taken ? 'The object has a rigidbody already' : null; 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 { export class AddRigidbodyCommand extends AddPartCommand {
readonly label = 'Add rigidbody'; readonly label = 'Add rigidbody';
protected readonly partType = 'rigidbody';
protected build( protected refusal(project: Project, holder: ObjectHolder, target: SceneObject): string | null {
project: Project, return rigidbodyRefusal(project, holder, target);
holder: ObjectHolder, }
target: SceneObject,
_targetAsset: Asset, protected build(project: Project, _target: SceneObject, _targetAsset: Asset, owner: string): Asset {
owner: string,
): Asset {
const refusal = rigidbodyRefusal(project, holder, target);
if (refusal) throw new CommandError(refusal);
return buildAsset('rigidbody', nextAssetName(project, `${owner} rigidbody`), this.assetId); 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 });
}
}
+18 -16
View File
@@ -4,14 +4,19 @@
*/ */
import { annotationNoun, RemoveAnnotationCommand } from '../core/commands/annotationCommands'; import { annotationNoun, RemoveAnnotationCommand } from '../core/commands/annotationCommands';
import { DeleteAssetCommand, SetActiveSceneCommand } from '../core/commands/assetCommands'; 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 { oneStep } from '../core/commands/CompoundCommand';
import { CreateContainerCommand } from '../core/commands/containerCommands'; import { CreateContainerCommand } from '../core/commands/containerCommands';
import { RemoveNodeCommand } from '../core/commands/graphCommands'; import { RemoveNodeCommand } from '../core/commands/graphCommands';
import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands'; import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands';
import type { AssetType } from '../core/model/AssetTypes'; import type { AssetType } from '../core/model/AssetTypes';
import { generatesPictures } from '../core/model/generated'; 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 type { Command } from '../core/commands/Command';
import { findAnnotation } from '../core/graph'; import { findAnnotation } from '../core/graph';
import { findAsset, findGraphAsset, type ObjectHolder, type Project } from '../core/model/Project'; 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; 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 { export function canAddCollider(project: Project, holder: ObjectHolder, object: SceneObject): boolean {
const type = findAsset(project, object.assetId)?.type; return !isTemplateContent(holder, object.id) && colliderRefusal(project, holder, object) === null;
return (
type !== undefined &&
type !== 'collider' &&
!isTemplateContent(holder, object.id) &&
!isLiveInstance(project, holder, object)
);
} }
/** Gives the object a collider child sized like it; selects the collider. */ /**
export function addCollider(state: EditorState, holderId: string, objectId: string): boolean { * Gives the object a collider child – a new one sized like it, or the existing `assetId` (#222);
const command = new AddColliderCommand(holderId, objectId); * 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; if (!state.execute(command)) return false;
state.select(objectSelection(holderId, [command.objectId])); state.select(objectSelection(holderId, [command.objectId]));
return true; return true;
@@ -63,9 +65,9 @@ export function canAddRigidbody(project: Project, holder: ObjectHolder, object:
return !isTemplateContent(holder, object.id) && rigidbodyRefusal(project, holder, object) === null; return !isTemplateContent(holder, object.id) && rigidbodyRefusal(project, holder, object) === null;
} }
/** Gives the object a rigidbody child; selects the rigidbody. */ /** Gives the object a rigidbody child, new or the existing `assetId`; selects the rigidbody. */
export function addRigidbody(state: EditorState, holderId: string, objectId: string): boolean { export function addRigidbody(state: EditorState, holderId: string, objectId: string, assetId = ''): boolean {
const command = new AddRigidbodyCommand(holderId, objectId); const command = new AddRigidbodyCommand(holderId, objectId, assetId);
if (!state.execute(command)) return false; if (!state.execute(command)) return false;
state.select(objectSelection(holderId, [command.objectId])); state.select(objectSelection(holderId, [command.objectId]));
return true; return true;
+47 -4
View File
@@ -1,3 +1,4 @@
import { colliderFit, FitColliderCommand } from '../core/commands/colliderCommands';
import { UnpackContainerCommand } from '../core/commands/containerCommands'; import { UnpackContainerCommand } from '../core/commands/containerCommands';
import { SetParentCommand } from '../core/commands/sceneObjectCommands'; import { SetParentCommand } from '../core/commands/sceneObjectCommands';
import { findObject } from '../core/model/hierarchy'; 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), 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), 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', label: many ? `Create container from ${selected.length} objects` : 'Create container',
disabled: selected.some((oid) => isTemplateContent(source, oid)), disabled: selected.some((oid) => isTemplateContent(source, oid)),
@@ -102,6 +112,39 @@ export function openObjectMenu(
return menu; 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 * "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. * that have no tab to open. The asset panel offers the same for an asset's own values.
+110
View File
@@ -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/,
);
});
});
+64 -7
View File
@@ -33,14 +33,35 @@ export async function colliders(t) {
stop, stop,
} = t; } = t;
// 8a. Right-click the red crate → "Add collider": a collider asset sized like the crate // 8a. Right-click the red crate → "Add collider…" opens a second, searchable menu (#222): a new
// (100×60) is placed as its child at the centre, selected and drawn in the overlay. The // collider or one the project has, the shared "Crate collider" the crate wears greyed out.
// seed already gives both crates a "Crate collider" child (oc1/oc2), so the added one is // "Create new collider" places a collider asset sized like the crate (100×60) as its child at
// undone again to keep exactly one collider per body for the checks below. // 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 objectsBefore = (await objects()).length;
const redRow = await locate('Red crate'); const assetCount = () => json('__gm.state.project.assets.length');
await click(redRow.x, redRow.y, 'right'); const colliderMenuOf = async (id) => {
await clickText('Add collider'); 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 s = await state();
const colObj = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null; const colObj = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null;
const colAsset = colObj ? await asset(colObj.assetId) : 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')`))); check('collider inspector shows the shape menu', Boolean(await evaluate(`__gm.locate('Box')`)));
await shot('21-collider'); 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(); await undo();
const colliderAssetGone = colAsset ? !(await asset(colAsset.id)) : false; const colliderAssetGone = colAsset ? !(await asset(colAsset.id)) : false;
check( check(
@@ -65,6 +102,26 @@ export async function colliders(t) {
(await objects()).length === objectsBefore && colliderAssetGone, (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 // 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), // the crates overlapping: the log names the blue crate exactly once (enter, not stay),
// and colliders are not rendered in play mode. // and colliders are not rendered in play mode.
+3 -1
View File
@@ -302,7 +302,9 @@ export async function containers(t) {
const before = (await objects()).length; const before = (await objects()).length;
const noticeNow = await notice(); const noticeNow = await notice();
await click(row.x, row.y, 'right'); 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'); await press('Escape');
return { added: (await objects()).length - before, notice: await noticeNow() }; return { added: (await objects()).length - before, notice: await noticeNow() };
}; };
+2 -2
View File
@@ -771,8 +771,8 @@ export async function tilemap(t) {
const OBJECT_MENU = [ const OBJECT_MENU = [
'Select asset', 'Select asset',
'Open asset', 'Open asset',
'Add collider', 'Add collider…',
'Add rigidbody', 'Add rigidbody…',
'Create container', 'Create container',
'Unpack container', 'Unpack container',
'Unparent (move to top level)', 'Unparent (move to top level)',
+3 -1
View File
@@ -125,7 +125,9 @@ export async function physics(t) {
const row = await inView(t, '__gm.leftColumn().objects', `__gm.hierarchyRow(${JSON.stringify(id)})`); const row = await inView(t, '__gm.leftColumn().objects', `__gm.hierarchyRow(${JSON.stringify(id)})`);
const before = (await objects()).length; const before = (await objects()).length;
await click(row.x, row.y, 'right'); 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'); await press('Escape');
const s = await state(); const s = await state();
const selected = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null; const selected = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null;