Files
feuerwerk/tests/core/surface.test.ts
T
adminandClaude Opus 5.5 bc688f3efc
game-maker / gate (push) Successful in 3m50s
game-maker / suite (push) Successful in 12m3s
Refactoring round 7: split BehaviorSystem, fewer copies (#85)
Recurring cleanup round over the code since round 6 (056a622), 105
commits. Behaviour is unchanged. Duplicated code in src went from 29
clones with 229 lines (0.32 %) to 14 clones with 109 lines (0.15 %),
measured with jscpd and the same settings as round 6.

- BehaviorSystem.ts shrinks from 941 to 589 lines. Collecting the
  pulses (keys, timers, collisions, animations) moved to
  behaviorPulses.ts. The NodeContext the nodes receive moved to
  behaviorContext.ts. Imports from outside stay the same, TimeScaled
  is still exported from BehaviorSystem.ts.
- PointKindButton and drawPointGrip replace the "Point:" button with
  its menu and the point and handle drawing that the curve and the
  vector art tab each had on their own.
- lerpVec, cubicAt and splitCubic in src/core/util/vec.ts replace four
  copies of the cubic curve and two de Casteljau splits.
- mapNested in Properties.ts is the one walk over lists and structs
  that liveObjects and objectRefs did separately. withFieldRenamed
  and withoutField replace three copies of renaming a struct field
  and two of removing one.
- jsonBody and staleRevision build the JSON body and turn a 409 into
  StaleRevisionError for all stores. The auth store turns a 401 into
  its error in one place.
- Smaller helpers: structAssetFrom for the two "Make struct type"
  commands, shownObject in the inspector, selectedItem for the size
  gizmo and the polygon corners, solidTiles in the tilemap collision,
  requireVariablesAsset and requireBehaviorVariable.
- Left as is on purpose: the plain forwarding from the collision and
  physics systems to Solids, the pointer wiring of the canvases and
  the identical last lines of the two make commands.
- Removed unused code: tilemapTileIds and tilemapsUsing were never
  called since #128 and #131. EditorState.runtimeProject became unused
  with GameRun in #266. The assetId getter of the make commands was
  read only by a test.
- 60 values used only in their own file lost their export. The shader
  and particle node catalogues stay exported as in round 6.
- Five comments that told history now describe the current state. The
  doc comment of BehaviorSystem sits on the class again.
- README lists the struct, clip area and curve assets and mentions the
  game link from #266.

Findings:
- Deleting a tile that is painted on a tilemap leaves its cells in
  place, and they draw nothing. The delete dialog does not mention the
  tilemaps. That was probably the job of the unused tilemapsUsing.
  Left as is, because fixing it changes behaviour.
- The solid filter for tile surfaces was untested. A bent filter
  stayed green. A new test in tests/core/surface.test.ts now fails
  against it.

Tests:
- ./check.sh: 195 files, 2815 tests, exit 0 (one test removed with
  tilemapsUsing, one added for tile surfaces).
- Full e2e run: ALL PASSED, 1580 checks.
- Smokes jumprun, tetris, actor: 24, 19 and 21 checks, all passed.
  The example project files were restored byte for byte afterwards.
- Counter-checks, 12, all fail as expected: 10 in unit tests (among
  them mapNested, splitCubic, cubicAt, staleRevision, the stay pulse,
  the late timer and the time scale), 2 in the browser (Point button
  always enabled fails 47, selectedItem without a hit fails 29).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:16:37 +02:00

335 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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_ONE_WAY_ANGLE_KEY,
TILE_ONE_WAY_KEY,
TILE_SOLID_KEY,
} from '../../src/core/model/tile';
import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY } from '../../src/core/model/tilemap';
import { oneWayAngles, 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 },
]);
});
it('a tile that does not block brings no surface and no one-way angle of its own', () => {
const s = tileScene();
s.stack.execute(new SetAssetPropertyCommand(s.iceId, TILE_SOLID_KEY, false));
s.stack.execute(new SetAssetPropertyCommand(s.iceId, TILE_ONE_WAY_KEY, true));
s.stack.execute(new SetAssetPropertyCommand(s.iceId, TILE_ONE_WAY_ANGLE_KEY, 30));
s.paint([[0, 0]], s.wallId);
s.paint([[1, 0]], s.iceId);
const asset = s.mapAsset();
expect(tileMaterials(s.project, asset)).toEqual([{ friction: 0.5, bounciness: 0 }]);
expect(oneWayAngles(s.project, asset)).toEqual([]);
});
});
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);
});
});