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
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user