diff --git a/README.md b/README.md index 48161ccd..8a9e4433 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1616 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1617 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 d3e9e322..5404524e 100644 --- a/src/core/commands/colliderCommands.ts +++ b/src/core/commands/colliderCommands.ts @@ -2,9 +2,11 @@ import { newId } from '../ids'; 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 { frameOutline } from '../model/frameOutline'; +import { type PropertyBag, textOf } from '../model/Properties'; import { findAsset, type ObjectHolder, type Project } from '../model/Project'; import { createSceneObject, objectName, type SceneObject } from '../model/SceneObject'; +import { matchesAspect } from '../properties/aspect'; import { resolveSceneObjectProps } from '../properties/resolve'; import { transformOfObject } from '../properties/transform'; import { removeById } from '../util/list'; @@ -74,7 +76,23 @@ export function colliderRefusal(project: Project, holder: ObjectHolder, target: return null; } -/** A new collider asset is sized like the object (sprites: width × height; anything else: schema defaults). */ +/** + * What a new collider on `target` starts with. A sprite gets a box of its width × height – or, when + * its picture is a frame with a collision outline, that outline (#218): a frame collider is the + * frame's size, so it is scaled up to the sprite, and a sprite stretched out of the frame's + * proportions keeps the box, since an outline cannot be stretched. Anything else: schema defaults. + */ +export function newColliderProps(project: Project, targetAsset: Asset, target: SceneObject): PropertyBag { + if (targetAsset.type !== 'sprite') return {}; + const size = spriteSize(project, targetAsset, target); + const frame = textOf(resolveSceneObjectProps(project, targetAsset, target).texture ?? ''); + const outline = frameOutline(project, frame); + if (!outline || outline.pieces.length === 0 || !matchesAspect(outline, size)) return size; + const scale = size.width / outline.w; + return { shape: 'frame', frame, width: outline.w, height: outline.h, ...(scale === 1 ? {} : { scale }) }; +} + +/** A new collider asset starts with `newColliderProps`. */ export class AddColliderCommand extends AddPartCommand { readonly label = 'Add collider'; protected readonly partType = 'collider'; @@ -85,7 +103,7 @@ export class AddColliderCommand extends AddPartCommand { 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') Object.assign(asset.props, spriteSize(project, targetAsset, target)); + Object.assign(asset.props, newColliderProps(project, targetAsset, target)); return asset; } } diff --git a/tests/core/collider-parts.test.ts b/tests/core/collider-parts.test.ts index e6b3269d..42e5200a 100644 --- a/tests/core/collider-parts.test.ts +++ b/tests/core/collider-parts.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; +import { AddFrameCommand, SetFrameCollisionCommand } from '../../src/core/commands/frameCommands'; import { AddColliderCommand, AddRigidbodyCommand, @@ -7,6 +8,9 @@ import { FitColliderCommand, } from '../../src/core/commands/colliderCommands'; import { MoveSceneObjectCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; +import type { TextureAsset } from '../../src/core/model/Asset'; +import { findAsset } from '../../src/core/model/Project'; +import { colliderFrame } from '../../src/core/properties/collider'; import { makeBehavior, makeSpriteScene, place } from '../helpers'; /** A 100×60 crate placed twice, the first with a collider of its own. */ @@ -108,3 +112,72 @@ describe('fitting a collider to its sprite (#222)', () => { ); }); }); + +describe('a new collider on a sprite whose frame has an outline (#218)', () => { + const WEDGE = [ + { x: 0, y: 20 }, + { x: 40, y: 0 }, + { x: 40, y: 20 }, + ]; + + /** A sprite wearing a 40 × 20 frame at that size, and `added`: the props of a collider added to it. */ + function wearing() { + const f = makeSpriteScene('Wedge'); + const texture = new CreateAssetCommand('texture', 'Sheet'); + f.stack.execute(texture); + Object.assign(findAsset(f.project, texture.asset.id) as TextureAsset, { + file: `${'a'.repeat(64)}.png`, + width: 64, + height: 64, + }); + const frame = new AddFrameCommand(texture.asset.id, { x: 0, y: 0, w: 40, h: 20 }); + f.stack.execute(frame); + const set = (key: string, value: number | string) => + f.stack.execute(new SetAssetPropertyCommand(f.spriteId, key, value)); + set('texture', frame.frameId); + set('width', 40); + set('height', 20); + const id = place(f, f.spriteId, { x: 0, y: 0 }); + const added = () => { + const command = new AddColliderCommand(f.sceneId, id); + f.stack.execute(command); + return findAsset(f.project, command.assetId)!.props; + }; + const outline = () => + f.stack.execute(new SetFrameCollisionCommand(texture.asset.id, frame.frameId, [WEDGE])); + return { ...f, id, textureId: texture.asset.id, frameId: frame.frameId, set, added, outline }; + } + + it('wears that frame, and one undo takes collider and asset away again', () => { + const f = wearing(); + f.outline(); + const count = f.project.assets.length; + const props = f.added(); + expect(props).toMatchObject({ shape: 'frame', frame: f.frameId, width: 40, height: 20, scale: 1 }); + expect(colliderFrame(f.project, props)?.pieces).toHaveLength(1); + f.stack.undo(); + expect(f.project.assets).toHaveLength(count); + }); + + it('is scaled to a sprite drawn bigger, and reads the frame this placement overrides', () => { + const f = wearing(); + f.outline(); + f.set('texture', ''); + f.stack.execute(new SetOverrideCommand(f.sceneId, f.id, 'texture', f.frameId)); + f.stack.execute(new SetOverrideCommand(f.sceneId, f.id, 'width', 100)); + f.stack.execute(new SetOverrideCommand(f.sceneId, f.id, 'height', 50)); + expect(f.added()).toMatchObject({ shape: 'frame', frame: f.frameId, width: 40, height: 20, scale: 2.5 }); + }); + + it('stays the box of the sprite without an outline, with the whole texture or stretched', () => { + const f = wearing(); + const box = { shape: 'box', width: 40, height: 20, scale: 1 }; + expect(f.added()).toMatchObject(box); + f.outline(); + f.set('texture', f.textureId); + expect(f.added()).toMatchObject(box); + f.set('texture', f.frameId); + f.set('height', 60); + expect(f.added()).toMatchObject({ ...box, height: 60 }); + }); +}); diff --git a/tools/e2e/42-shapes.mjs b/tools/e2e/42-shapes.mjs index 5249172c..0a3bb2f6 100644 --- a/tools/e2e/42-shapes.mjs +++ b/tools/e2e/42-shapes.mjs @@ -1,13 +1,14 @@ import { edge, node } from '../lib/builders.mjs'; -import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs'; +import { inView, LANES, MAKE_OBJECT } from './lib/scaffold.mjs'; /** * Capsules and polygons (#151), and the collision outline of a frame that a tile blocks with * (#141). What only a running editor can show: the corners of a polygon collider are edited on * its outline in the scene, the engine lets a capsule slide down a polygon ramp, the Texture tab * draws a frame's outline with clicks, and a painted map blocks with that outline in the game – as - * does a collider that wears the frame's outline (#193), and "Generate collision…" reads that - * outline off the picture (#161). + * does a collider that wears the frame's outline (#193) – which "Add collider…" picks by itself on a + * sprite wearing such a frame (#218) –, and "Generate collision…" reads that outline off the picture + * (#161). */ /** A 32 × 32 picture, pasted like a file: the texture the outline is drawn on. */ @@ -502,6 +503,57 @@ export async function shapes(t) { frameClimb.length > 100 && peak < F - 42 + 1 && peak > F - 42 - 3 && frameClimb.at(-1).x > 45, JSON.stringify({ frames: frameClimb.length, peak, top: F - 42, last: frameClimb.at(-1) }), ); + + // 42e-ii. "Add collider…" on a sprite wearing that frame (#218): the new collider wears the frame's + // outline at once, scaled to the sprite (64 wide, the frame 32); a sprite without a picture + // gets the box. Undo takes each collider and its asset away again. + await mutate(` + const scene = __gm.state.activeScene; + ${MAKE_OBJECT} + ${put} + put('sh-worn', 'crate', '', 150, ${F - 120}, 'Worn 42', { texture: 'sh-frame', width: 64, height: 64 }); + put('sh-plain', 'crate', '', -150, ${F - 120}, 'Plain 42', { width: 64, height: 64 }); + __gm.state.select(null)`); + await evaluate(`__gm.lookAt(0, ${F - 60})`); + const addedTo = 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 clickText('Create new collider'); + await settle(); + const s = await t.state(); + const placed = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null; + const props = placed ? (await t.asset(placed.assetId))?.props : null; + const rect = placed ? await json(`__gm.objectRect(${JSON.stringify(placed.id)})`) : null; + return { + parentId: placed?.parentId, + shape: props?.shape, + frame: props?.frame, + scale: props?.scale, + rect, + }; + }; + const assetCount = () => json('__gm.state.project.assets.length'); + const assetsBefore = await assetCount(); + const worn = await addedTo('sh-worn'); + const wornRect = await json(`__gm.objectRect('sh-worn')`); + await shot('42-add-frame-collider'); + await undo(); + const plain = await addedTo('sh-plain'); + await undo(); + check( + 'Add collider on a sprite wearing a frame with an outline takes "Frame collision" of that frame, sprite-sized', + worn.parentId === 'sh-worn' && + worn.shape === 'frame' && + worn.frame === 'sh-frame' && + worn.scale === 2 && + Math.abs(worn.rect?.w - wornRect?.w) < 1 && + plain.parentId === 'sh-plain' && + plain.shape === 'box' && + (await assetCount()) === assetsBefore, + JSON.stringify({ worn, wornRect, plain }), + ); await clearShapes(); // 42f. "Generate collision…" reads the outlines off the picture (#161): a wedge and a faint band