Pick an existing collider or rigidbody from the object menu (#222)
"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:
1 parent
2cf72022eb
commit
abf3b1f618
9 files changed
+361
-72
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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() };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user