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, colliderFit, 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. */ 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/, ); }); }); 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 }); }); });