Colliders take a frame's outline and tiles have their own surface (#193)
Issue #193 asked where friction, bounciness and density of a tile live. Tiles had a fixed surface (friction 0.5, bounciness 0) and a collider could not use the outline drawn on a texture frame (#141). Both gaps are closed here. - A collider has a fifth shape "Frame collision" with a Frame row. The frame is picked like a sprite's texture and stored as the value 'frame'. - The shape is linked, not copied. Redrawing the outline in the texture tab changes every collider using that frame, in the editor and on the next play. - The frame centre sits on the collider origin, one image pixel is one unit. The shape turns, scales and mirrors like any other. - Every outline ring blocks. Each ring is split into convex parts the same way a polygon is. - Without a frame, with a whole texture or with a frame that has no outline, the collider is drawn red as a box and blocks nothing. - Move, Rapier, raycast, detection, hit test, drawing and footprint all resolve the frame. At runtime an entity looks the frame up live in the current project. - A tile has Friction and Bounciness next to Solid and One-way. The rows only show when Solid is on. Defaults and mixing rules match the collider. Density is left out because a tilemap never moves. - Tilemap cells are grouped by surface as well as by one-way angle. Ice and stone never merge into one rectangle, and every cell brings its surface to Move and Rapier. Findings: - A tile with a collision scale below 1 is no longer flush with a neighbour of another surface. It is inset there like at an open side, the same as next to one-way tiles today. - A bounce test first measured 0.84. The ball had landed on the hero at the origin, not on the tile. On the tile it measures 0.993. Tests: - ./check.sh: 130 files, 1931 tests, exit 0 (was 129 / 1917). - 14 new unit tests in tests/core/surface.test.ts. The Move tests run against the editor run and the physics system. - 3 new e2e checks: 40l (surface rows follow Solid), 42e (collider has frame size, a walker climbs the slope in the frame). - GM_STEPS=1-8,39-43 freshly seeded: ALL PASSED, 376 checks. - Three counter-probes bite: Move ignores the tile surface (2 fail), Rapier ignores it (1 fails), frame shape falls back to a box (5 fail). - Check sum 1198 is computed (1195 + 3), the full run belongs to CI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3ff7e91dbf
commit
f4ec23d3d3
20 files changed
+729
-83
No files matched your search
@@ -0,0 +1,316 @@
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||||
import { AddColliderCommand, AddRigidbodyCommand } from '../../src/core/commands/colliderCommands';
|
||||
import { AddFrameCommand, SetFrameCollisionCommand } from '../../src/core/commands/frameCommands';
|
||||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import { PaintTilesCommand } from '../../src/core/commands/tilemapCommands';
|
||||
import type { TextureAsset, TilemapAsset, TileAsset } from '../../src/core/model/Asset';
|
||||
import { translation } from '../../src/core/model/matrix';
|
||||
import type { PropertyValue, Vec2 } from '../../src/core/model/Properties';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { TILE_BOUNCINESS_KEY, TILE_FRICTION_KEY, TILE_SOLID_KEY } from '../../src/core/model/tile';
|
||||
import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY } from '../../src/core/model/tilemap';
|
||||
import { solidRects, tileMaterial, tileMaterials } from '../../src/core/model/tilemapCollision';
|
||||
import {
|
||||
colliderFrame,
|
||||
colliderShape,
|
||||
colliderSize,
|
||||
outlinesOf,
|
||||
shapeContains,
|
||||
} from '../../src/core/properties/collider';
|
||||
import { isRowShown, schemaFor } from '../../src/core/properties/PropertySchema';
|
||||
import { loadPhysics } from '../../src/runtime/physics/engine';
|
||||
import { COLLISION_SYSTEMS, makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
|
||||
|
||||
beforeAll(async () => {
|
||||
await loadPhysics();
|
||||
});
|
||||
|
||||
const P = (...xy: number[]) =>
|
||||
Array.from({ length: xy.length / 2 }, (_, i) => ({ x: xy[2 * i]!, y: xy[2 * i + 1]! }));
|
||||
|
||||
/**
|
||||
* A scene with a texture of one 40 × 40 frame, and `add`, which places an object with a collider
|
||||
* (and maybe a rigidbody) and sets the collider's properties.
|
||||
*/
|
||||
function frameScene() {
|
||||
const f = makeSpriteScene('Thing');
|
||||
const texture = new CreateAssetCommand('texture', 'Sheet');
|
||||
f.stack.execute(texture);
|
||||
const sheet = findAsset(f.project, texture.asset.id) as TextureAsset;
|
||||
sheet.file = `${'a'.repeat(64)}.png`;
|
||||
sheet.width = 64;
|
||||
sheet.height = 64;
|
||||
const frame = new AddFrameCommand(texture.asset.id, { x: 0, y: 0, w: 40, h: 40 });
|
||||
f.stack.execute(frame);
|
||||
const outline = (...outlines: Vec2[][]) =>
|
||||
f.stack.execute(new SetFrameCollisionCommand(texture.asset.id, frame.frameId, outlines));
|
||||
const add = (at: Vec2, collider: Record<string, PropertyValue>, body?: Record<string, PropertyValue>) => {
|
||||
const id = place(f, f.spriteId, at);
|
||||
const command = new AddColliderCommand(f.sceneId, id);
|
||||
f.stack.execute(command);
|
||||
for (const [key, value] of Object.entries(collider))
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, command.objectId, key, value));
|
||||
if (body) f.stack.execute(new AddRigidbodyCommand(f.sceneId, id));
|
||||
return id;
|
||||
};
|
||||
return { ...f, textureId: texture.asset.id, frameId: frame.frameId, outline, add };
|
||||
}
|
||||
|
||||
/** The right half of the 40 × 40 frame: in the collider's units x runs 0 … 20, y −20 … 20. */
|
||||
const RIGHT_HALF = P(20, 0, 40, 0, 40, 40, 20, 40);
|
||||
/** A triangle in the top left corner and a square in the bottom right: two rings. */
|
||||
const TRIANGLE = P(0, 0, 16, 0, 0, 16);
|
||||
const SQUARE = P(24, 24, 36, 24, 36, 36, 24, 36);
|
||||
|
||||
describe('a collider wearing a frame outline (#193)', () => {
|
||||
it('is a shape of its own with a frame row, the frame picked like a texture', () => {
|
||||
const schema = schemaFor('collider');
|
||||
const shape = schema.find((d) => d.key === 'shape')!;
|
||||
expect(shape.kind === 'enum' && shape.options.map((o) => o.value)).toContain('frame');
|
||||
expect(schema.find((d) => d.key === 'frame')).toMatchObject({ kind: 'texture', default: '' });
|
||||
});
|
||||
|
||||
it('takes every ring of the frame, centred on its origin, a pixel to a unit, and the frame size', () => {
|
||||
const s = frameScene();
|
||||
s.outline(TRIANGLE, SQUARE);
|
||||
const props = { shape: 'frame', frame: s.frameId };
|
||||
const frame = colliderFrame(s.project, props);
|
||||
expect(frame).toMatchObject({ w: 40, h: 40 });
|
||||
expect(colliderSize(props, frame)).toEqual({ w: 40, h: 40 });
|
||||
const shape = colliderShape(props, translation(100, 50), frame);
|
||||
if (shape.kind !== 'polygon') throw new Error(shape.kind);
|
||||
expect(outlinesOf(shape)).toEqual([P(80, 30, 96, 30, 80, 46), P(104, 54, 116, 54, 116, 66, 104, 66)]);
|
||||
// Inside a ring collides, the gap between them does not.
|
||||
expect(shapeContains(shape, { x: 83, y: 33 })).toBe(true);
|
||||
expect(shapeContains(shape, { x: 110, y: 60 })).toBe(true);
|
||||
expect(shapeContains(shape, { x: 100, y: 50 })).toBe(false);
|
||||
});
|
||||
|
||||
it('stays linked: a redrawn outline is what the collider wears next, and undo brings the old one back', () => {
|
||||
const s = frameScene();
|
||||
s.outline(TRIANGLE);
|
||||
const props = { shape: 'frame', frame: s.frameId };
|
||||
const ringsNow = () => colliderFrame(s.project, props)!.rings;
|
||||
expect(ringsNow()).toHaveLength(1);
|
||||
s.outline(TRIANGLE, SQUARE);
|
||||
expect(ringsNow()).toHaveLength(2);
|
||||
s.stack.undo();
|
||||
expect(ringsNow()).toEqual([P(-20, -20, -4, -20, -20, -4)]);
|
||||
});
|
||||
|
||||
it('without a frame, with a whole texture or with nothing drawn it is a box that collides with nothing', () => {
|
||||
const s = frameScene();
|
||||
const shapeOf = (frame: string) => {
|
||||
const props = { shape: 'frame', frame, width: 30, height: 10 };
|
||||
return colliderShape(props, translation(0, 0), colliderFrame(s.project, props));
|
||||
};
|
||||
// No frame, or an id that is no frame: the collider's own width × height.
|
||||
for (const id of ['', s.textureId, 'gone'])
|
||||
expect(shapeOf(id), id).toMatchObject({
|
||||
kind: 'polygon',
|
||||
outline: P(-15, -5, 15, -5, 15, 5, -15, 5),
|
||||
pieces: [],
|
||||
});
|
||||
// A frame nobody drew an outline on: the frame's box.
|
||||
expect(shapeOf(s.frameId)).toMatchObject({ outline: P(-20, -20, 20, -20, 20, 20, -20, 20), pieces: [] });
|
||||
});
|
||||
|
||||
it('keeps shape and frame through a round trip', () => {
|
||||
const s = frameScene();
|
||||
s.outline(RIGHT_HALF);
|
||||
s.add({ x: 0, y: 0 }, { shape: 'frame', frame: s.frameId });
|
||||
const collider = roundTrip(s.project).assets.find((a) => a.type === 'collider')!;
|
||||
const object = roundTrip(s.project)
|
||||
.assets.flatMap((a) => (a.type === 'scene' ? a.objects : []))
|
||||
.find((o) => o.assetId === collider.id)!;
|
||||
expect(object.overrides).toMatchObject({ shape: 'frame', frame: s.frameId });
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(COLLISION_SYSTEMS)(
|
||||
'$system: a frame outline blocks where it is drawn (#193)',
|
||||
({ physics }) => {
|
||||
it('Move stops at the outline, not at the frame box, and a click hits only the outline', () => {
|
||||
const s = frameScene();
|
||||
s.outline(RIGHT_HALF);
|
||||
const wall = s.add({ x: 100, y: 0 }, { shape: 'frame', frame: s.frameId });
|
||||
const hero = s.add({ x: 0, y: 0 }, { width: 20, height: 20 });
|
||||
const { world, collisions } = startWorld(s, { physics, collisions: true });
|
||||
const mover = world.findByObjectId(hero)!;
|
||||
expect(collisions.moveAndSlide(world, mover, 200, 0)).toMatchObject({ blockedX: true, hit: true });
|
||||
// The outline's left face is at 100 (the frame's would be at 80): the hero's right side stops there.
|
||||
expect(mover.x).toBeCloseTo(90, 6);
|
||||
const target = world.findByObjectId(wall)!;
|
||||
expect(world.hitTest(target, 110, 0)).toBe(true);
|
||||
expect(world.hitTest(target, 90, 0)).toBe(false);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe('physics: a body lands on a frame outline (#193)', () => {
|
||||
it('a dropped box comes to rest on the drawn top, not on the frame box', () => {
|
||||
const s = frameScene();
|
||||
// The lower half of the frame: its top face lies at the collider's origin.
|
||||
s.outline(P(0, 20, 40, 20, 40, 40, 0, 40));
|
||||
s.add({ x: 0, y: 200 }, { shape: 'frame', frame: s.frameId });
|
||||
const box = s.add({ x: 0, y: 100 }, { width: 10, height: 10 }, {});
|
||||
const started = startWorld(s, { physics: true, collisions: true });
|
||||
for (let i = 0; i < 120; i++) started.frame(1 / 60);
|
||||
// Resting on y = 200, half its 10 up; the frame box would hold it at 175.
|
||||
expect(started.world.findByObjectId(box)!.y).toBeCloseTo(195, 0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A scene with a 10 × 10 map of 48 px cells at the origin – cell (x, y) spans x·48 − 240 … – a
|
||||
* solid Wall and a solid Ice tile, and a 40 × 40 hero with a collider at the origin.
|
||||
*/
|
||||
function tileScene() {
|
||||
const f = makeSpriteScene('Hero');
|
||||
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'width', 40));
|
||||
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 40));
|
||||
const wall = new CreateAssetCommand('tile', 'Wall');
|
||||
const ice = new CreateAssetCommand('tile', 'Ice');
|
||||
const map = new CreateAssetCommand('tilemap', 'Ground');
|
||||
for (const command of [wall, ice, map]) f.stack.execute(command);
|
||||
for (const tile of [wall, ice])
|
||||
f.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_SOLID_KEY, true));
|
||||
f.stack.execute(new SetAssetPropertyCommand(ice.asset.id, TILE_FRICTION_KEY, 0));
|
||||
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, 10));
|
||||
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, 10));
|
||||
place(f, map.asset.id, { x: 0, y: 0 });
|
||||
const hero = place(f, f.spriteId, { x: 0, y: 0 });
|
||||
f.stack.execute(new AddColliderCommand(f.sceneId, hero));
|
||||
const mapAsset = () => findAsset(f.project, map.asset.id) as TilemapAsset;
|
||||
const paint = (at: [number, number][], tileId: string) =>
|
||||
f.stack.execute(
|
||||
new PaintTilesCommand(
|
||||
map.asset.id,
|
||||
mapAsset().layers[0]!.id,
|
||||
at.map(([x, y]) => ({ x, y, tileId })),
|
||||
),
|
||||
);
|
||||
const tile = (id: string) => findAsset(f.project, id) as TileAsset;
|
||||
return { ...f, wallId: wall.asset.id, iceId: ice.asset.id, mapAsset, paint, hero, tile };
|
||||
}
|
||||
|
||||
const COLUMN_SIX = Array.from({ length: 10 }, (_, y) => [6, y] as [number, number]);
|
||||
|
||||
describe('the surface of a solid tile (#193)', () => {
|
||||
it('has the rows and defaults of a collider, no density, shown only while the tile is solid', () => {
|
||||
const tile = schemaFor('tile');
|
||||
expect(tile.find((d) => d.key === TILE_FRICTION_KEY)).toMatchObject({
|
||||
kind: 'number',
|
||||
default: 0.5,
|
||||
min: 0,
|
||||
});
|
||||
expect(tile.find((d) => d.key === TILE_BOUNCINESS_KEY)).toMatchObject({ default: 0, min: 0, max: 1 });
|
||||
expect(tile.some((d) => d.key === 'density')).toBe(false);
|
||||
const row = tile.find((d) => d.key === TILE_FRICTION_KEY)!;
|
||||
expect(isRowShown(row, { [TILE_SOLID_KEY]: false })).toBe(false);
|
||||
expect(isRowShown(row, { [TILE_SOLID_KEY]: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('is read like a collider material and survives a round trip', () => {
|
||||
const s = tileScene();
|
||||
expect(tileMaterial(s.tile(s.wallId))).toEqual({ friction: 0.5, bounciness: 0 });
|
||||
s.stack.execute(new SetAssetPropertyCommand(s.iceId, TILE_BOUNCINESS_KEY, 0.8));
|
||||
const ice = findAsset(roundTrip(s.project), s.iceId) as TileAsset;
|
||||
expect(tileMaterial(ice)).toEqual({ friction: 0, bounciness: 0.8 });
|
||||
// Out of range or unreadable, held like a collider's.
|
||||
s.tile(s.iceId).props[TILE_BOUNCINESS_KEY] = 7;
|
||||
s.tile(s.iceId).props[TILE_FRICTION_KEY] = 'x';
|
||||
expect(tileMaterial(s.tile(s.iceId))).toEqual({ friction: 0.5, bounciness: 1 });
|
||||
});
|
||||
|
||||
it('ice and wall never merge into one rectangle; each surface is its own group', () => {
|
||||
const s = tileScene();
|
||||
s.paint(
|
||||
[
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
],
|
||||
s.wallId,
|
||||
);
|
||||
s.paint(
|
||||
[
|
||||
[2, 0],
|
||||
[3, 0],
|
||||
],
|
||||
s.iceId,
|
||||
);
|
||||
const asset = s.mapAsset();
|
||||
// Drawn, the row is one rectangle; for collision, one per surface.
|
||||
expect(solidRects(s.project, asset.props, asset)).toEqual([{ x: 0, y: 0, w: 4, h: 1 }]);
|
||||
const materials = tileMaterials(s.project, asset);
|
||||
expect(materials).toEqual([
|
||||
{ friction: 0, bounciness: 0 },
|
||||
{ friction: 0.5, bounciness: 0 },
|
||||
]);
|
||||
expect(solidRects(s.project, asset.props, asset, false, materials[0])).toEqual([
|
||||
{ x: 2, y: 0, w: 2, h: 1 },
|
||||
]);
|
||||
expect(solidRects(s.project, asset.props, asset, false, materials[1])).toEqual([
|
||||
{ x: 0, y: 0, w: 2, h: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(COLLISION_SYSTEMS)('$system: Move slides by the tile surface (#193)', ({ physics }) => {
|
||||
/** The hero pressed against column six while walking down it, 60 times: how far down it got. */
|
||||
function slideDown(tileId: 'wall' | 'ice'): number {
|
||||
const s = tileScene();
|
||||
s.paint(COLUMN_SIX, tileId === 'wall' ? s.wallId : s.iceId);
|
||||
const { world, collisions } = startWorld(s, { physics, collisions: true });
|
||||
const hero = world.findByObjectId(s.hero)!;
|
||||
hero.y = -200;
|
||||
collisions.moveAndSlide(world, hero, 97.3, 0);
|
||||
for (let i = 0; i < 60; i++) collisions.moveAndSlide(world, hero, 3.1, 6.7);
|
||||
expect(hero.x).toBeCloseTo(28, 6);
|
||||
return hero.y + 200;
|
||||
}
|
||||
|
||||
it('the pair averages the hero’s 0.5 with the tile’s own friction', () => {
|
||||
expect(slideDown('wall')).toBeCloseTo(60 * (6.7 - 0.5 * 3.1), 6);
|
||||
expect(slideDown('ice')).toBeCloseTo(60 * (6.7 - 0.25 * 3.1), 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('physics: a ball bounces by the tile it lands on (#193)', () => {
|
||||
/** How high a ball dropped onto the bottom row climbs again after its first bounce. */
|
||||
function rebound(bounciness: number): number {
|
||||
const s = tileScene();
|
||||
s.stack.execute(new SetAssetPropertyCommand(s.iceId, TILE_BOUNCINESS_KEY, bounciness));
|
||||
s.paint(
|
||||
Array.from({ length: 10 }, (_, x) => [x, 9] as [number, number]),
|
||||
s.iceId,
|
||||
); // top face at y = 192
|
||||
// Clear of the hero, who stands on the origin with a collider of its own.
|
||||
const ball = place(s, s.spriteId, { x: 120, y: 0 });
|
||||
const collider = new AddColliderCommand(s.sceneId, ball);
|
||||
s.stack.execute(collider);
|
||||
for (const [key, value] of Object.entries({ shape: 'circle', radius: 16 }))
|
||||
s.stack.execute(new SetOverrideCommand(s.sceneId, collider.objectId, key, value));
|
||||
s.stack.execute(new AddRigidbodyCommand(s.sceneId, ball));
|
||||
const started = startWorld(s, { physics: true, collisions: true });
|
||||
const y = () => started.world.findByObjectId(ball)!.y;
|
||||
let landed = false;
|
||||
let highest = Infinity;
|
||||
for (let i = 0; i < 240; i++) {
|
||||
started.frame(1 / 60);
|
||||
if (y() > 192 - 16 - 1) landed = true;
|
||||
else if (landed) highest = Math.min(highest, y());
|
||||
}
|
||||
return Number.isFinite(highest) ? 192 - 16 - highest : 0;
|
||||
}
|
||||
|
||||
it('a dead tile keeps it down, 0.5 sends it back a quarter of the drop, 1 nearly all of it', () => {
|
||||
const drop = 192 - 16;
|
||||
expect(rebound(0)).toBeLessThan(1);
|
||||
const half = rebound(0.5) / drop;
|
||||
expect(half).toBeGreaterThan(0.2);
|
||||
expect(half).toBeLessThan(0.3);
|
||||
expect(rebound(1) / drop).toBeGreaterThan(0.9);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user