From f4ec23d3d320e2af669dc53da0acc62b74775138 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Mon, 21 Sep 2026 07:29:26 +0200 Subject: [PATCH] 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 --- README.md | 2 +- src/core/model/frameOutline.ts | 46 ++++ src/core/model/tile.ts | 3 + src/core/model/tilemapCollision.ts | 58 ++++- src/core/properties/PropertySchema.ts | 31 ++- src/core/properties/bounds.ts | 33 ++- src/core/properties/collider.ts | 76 ++++++- src/editor/viewport/FreeViewport.ts | 2 +- src/editor/viewport/SceneViewport.ts | 2 +- src/editor/viewport/WorldViewport.ts | 6 +- src/runtime/Entity.ts | 6 + src/runtime/World.ts | 10 +- src/runtime/physics/PhysicsSystem.ts | 22 +- src/runtime/systems/solids.ts | 90 ++++---- src/viewport/items.ts | 6 + src/viewport/symbols.ts | 16 +- tests/core/one-way-angle.test.ts | 8 +- tests/core/surface.test.ts | 316 ++++++++++++++++++++++++++ tools/e2e/40-tilemap.mjs | 10 + tools/e2e/42-shapes.mjs | 69 +++++- 20 files changed, 729 insertions(+), 83 deletions(-) create mode 100644 src/core/model/frameOutline.ts create mode 100644 tests/core/surface.test.ts diff --git a/README.md b/README.md index dde49933..58574558 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,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, 1195 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1198 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/model/frameOutline.ts b/src/core/model/frameOutline.ts new file mode 100644 index 00000000..1660df0c --- /dev/null +++ b/src/core/model/frameOutline.ts @@ -0,0 +1,46 @@ +/** + * A frame's collision outlines (#141) as a collider wears them (#193): centred on the collider's + * origin, one image pixel to one unit – the size a sprite of that frame is placed with. + */ +import type { TextureFrame } from './Asset'; +import { convexPieces } from './polygon'; +import type { Project } from './Project'; +import type { Vec2 } from './Properties'; +import { findFrame } from './texture'; + +export interface FrameOutline { + /** The frame's size in pixels, which is the collider's footprint. */ + w: number; + h: number; + /** Every outline the frame has, in the collider's own units. */ + rings: Vec2[][]; + /** What collides: the convex pieces of every ring that is a polygon. */ + pieces: Vec2[][]; +} + +/** One per drawn outline list, so a frame is cut into pieces once and not on every test. */ +const cache = new WeakMap(); + +function isCurrent( + known: { from: TextureFrame['collision']; outline: FrameOutline }, + frame: TextureFrame, +): boolean { + return known.from === frame.collision && known.outline.w === frame.w && known.outline.h === frame.h; +} + +/** + * What the frame with that id blocks with, or null when the id names no frame. A frame without a + * drawn outline has no rings: the collider then collides with nothing, like a broken polygon. + */ +export function frameOutline(project: Project | null, frameId: string): FrameOutline | null { + const found = project ? findFrame(project, frameId) : null; + if (!found) return null; + const { frame } = found; + const known = cache.get(frame); + if (known && isCurrent(known, frame)) return known.outline; + const centre = (p: Vec2): Vec2 => ({ x: p.x - frame.w / 2, y: p.y - frame.h / 2 }); + const rings = (frame.collision ?? []).map((ring) => ring.map(centre)); + const outline = { w: frame.w, h: frame.h, rings, pieces: rings.flatMap((ring) => convexPieces(ring)) }; + cache.set(frame, { from: frame.collision, outline }); + return outline; +} diff --git a/src/core/model/tile.ts b/src/core/model/tile.ts index be58544c..cf7a96ae 100644 --- a/src/core/model/tile.ts +++ b/src/core/model/tile.ts @@ -26,6 +26,9 @@ export const TILE_ONE_WAY_KEY = 'oneWay'; export const TILE_ONE_WAY_ANGLE_KEY = 'oneWayAngle'; /** How big the blocking box of a cell is against the cell, 1 = the whole cell (#140). */ export const TILE_COLLISION_SCALE_KEY = 'collisionScale'; +/** The surface of a solid tile (#193): the same rows, and the same defaults, as a collider's material. */ +export const TILE_FRICTION_KEY = 'friction'; +export const TILE_BOUNCINESS_KEY = 'bounciness'; export const TILE_COLLISION_SCALE_MIN = 0.1; export const TILE_COLLISION_SCALE_MAX = 2; diff --git a/src/core/model/tilemapCollision.ts b/src/core/model/tilemapCollision.ts index ecaf2099..59856d0b 100644 --- a/src/core/model/tilemapCollision.ts +++ b/src/core/model/tilemapCollision.ts @@ -20,7 +20,8 @@ * * One-way tiles (#155) block only from above. They are worked out on their own, by the same rules – * each function takes `oneWay` – so a platform never merges with a wall into one rectangle, nor with - * a platform of another angle (#159). + * a platform of another angle (#159). The same holds for a tile's surface (#193): with a `material`, + * a function looks only at the tiles of that friction and bounciness. */ import type { TileAsset, TilemapAsset } from './Asset'; import { tileQuadrants } from './autotile'; @@ -40,6 +41,7 @@ import { } from './tile'; import { cellOrigin, inGrid, neighboursOf, parseCellKey, tilemapSize, tilemapTileSize } from './tilemap'; import { type ColliderShape, oneWayAngle, type ShapeBounds } from '../properties/collider'; +import { colliderMaterial } from '../properties/rigidbody'; /** A rectangle in cells: top-left corner and size. Whole cells at collision scale 1, parts of one otherwise. */ export interface CellRect { @@ -192,11 +194,51 @@ function tileOneWayAngle(tile: TileAsset): number { return oneWayAngle(tile.props[TILE_ONE_WAY_ANGLE_KEY]); } -function passesFilter(tile: TileAsset, oneWay: OneWayFilter): boolean { +/** The surface of a solid tile (#193), read like a collider's. A map never moves, so no density. */ +export interface TileMaterial { + friction: number; + bounciness: number; +} + +export function tileMaterial(tile: TileAsset): TileMaterial { + const { friction, bounciness } = colliderMaterial(tile.props); + return { friction, bounciness }; +} + +function sameMaterial(a: TileMaterial, b: TileMaterial): boolean { + return a.friction === b.friction && a.bounciness === b.bounciness; +} + +/** + * Which solid tiles a function looks at: those passing `oneWay`, and with a `material` only the + * tiles of that surface – so ice and rubber never merge into one rectangle (#193). + */ +function passesFilter(tile: TileAsset, oneWay: OneWayFilter, material?: TileMaterial): boolean { + if (material && !sameMaterial(tileMaterial(tile), material)) return false; if (!isTileOneWay(tile)) return oneWay === false; return oneWay === true || tileOneWayAngle(tile) === oneWay; } +/** The surfaces of the solid tiles painted on the map that pass `oneWay`, each once. */ +export function tileMaterials( + project: Project, + asset: TilemapAsset, + oneWay: OneWayFilter = false, +): TileMaterial[] { + const materials: TileMaterial[] = []; + const seen = new Set(); + for (const layer of asset.layers) + for (const tileId of Object.values(layer.cells)) { + if (!tileId || seen.has(tileId)) continue; + seen.add(tileId); + const tile = findAssetOf(project, tileId, 'tile'); + if (!tile || !isTileSolid(tile) || !passesFilter(tile, oneWay)) continue; + const material = tileMaterial(tile); + if (!materials.some((known) => sameMaterial(known, material))) materials.push(material); + } + return materials.sort((a, b) => a.friction - b.friction || a.bounciness - b.bounciness); +} + /** The angles of the solid one-way tiles painted on the map, each once, smallest first. */ export function oneWayAngles(project: Project, asset: TilemapAsset): number[] { const angles = new Set(); @@ -216,13 +258,14 @@ function blockingCells( props: PropertyBag, asset: TilemapAsset, oneWay: OneWayFilter, + material?: TileMaterial, ): CellBlock[] { const tiles = new Map(); const tileOf = (id: string) => { if (tiles.has(id)) return tiles.get(id)!; const tile = findAssetOf(project, id, 'tile'); const value = - tile && isTileSolid(tile) && passesFilter(tile, oneWay) + tile && isTileSolid(tile) && passesFilter(tile, oneWay, material) ? { scale: tileCollisionScale(tile), shape: tileShape(project, tile) } : null; tiles.set(id, value); @@ -279,8 +322,9 @@ export function solidCells( props: PropertyBag, asset: TilemapAsset, oneWay: OneWayFilter = false, + material?: TileMaterial, ): Required[] { - return blockingCells(project, props, asset, oneWay) + return blockingCells(project, props, asset, oneWay, material) .filter((cell) => cell.box !== null) .map(({ x, y, box }) => ({ x, y, scale: box! })); } @@ -296,9 +340,10 @@ export function solidPolygons( props: PropertyBag, asset: TilemapAsset, oneWay: OneWayFilter = false, + material?: TileMaterial, ): CellPolygon[] { const polygons: CellPolygon[] = []; - for (const cell of blockingCells(project, props, asset, oneWay)) { + for (const cell of blockingCells(project, props, asset, oneWay, material)) { if (cell.box !== null) continue; for (const { outline, pieces } of cell.polygons) polygons.push({ x: cell.x, y: cell.y, outline, pieces }); } @@ -410,8 +455,9 @@ export function solidRects( props: PropertyBag, asset: TilemapAsset, oneWay: OneWayFilter = false, + material?: TileMaterial, ): CellRect[] { - return mergeCells(solidCells(project, props, asset, oneWay)); + return mergeCells(solidCells(project, props, asset, oneWay, material)); } /** diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 0d605f53..53579ab7 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -22,10 +22,12 @@ import { import { findAssetOf, type Project } from '../model/Project'; import { ALL_LAYERS, COLLIDES_WITH_KEY, COLLISION_LAYER_KEY } from '../model/collisionLayers'; import { + TILE_BOUNCINESS_KEY, TILE_COLLISION_SCALE_KEY, TILE_COLLISION_SCALE_MAX, TILE_COLLISION_SCALE_MIN, TILE_FPS_KEY, + TILE_FRICTION_KEY, TILE_KIND_KEY, TILE_ONE_WAY_ANGLE_KEY, TILE_ONE_WAY_KEY, @@ -105,6 +107,25 @@ const ONE_WAY_ANGLE_ROW: FlatDef<'number'> = { shownWith: 'oneWay', }; +/** The surface a collider or a solid tile (#193) offers, read by the physics engine and by `Move` (#146, #150). */ +const FRICTION_ROW: FlatDef<'number'> = { + key: 'friction', + label: 'Friction', + kind: 'number', + default: 0.5, + min: 0, + step: 0.05, +}; +const BOUNCINESS_ROW: FlatDef<'number'> = { + key: 'bounciness', + label: 'Bounciness', + kind: 'number', + default: 0, + min: 0, + max: 1, + step: 0.05, +}; + /** * Which colliders may meet (#152). Both rows sit on colliders and on tilemaps, so a map can be * ground that only some things walk on. The defaults are layer 0 against every layer, which is @@ -343,6 +364,9 @@ const ASSET_SCHEMAS: Record = { // A solid tile that only blocks from above, like a platform one jumps through (#155). { key: TILE_ONE_WAY_KEY, label: 'One-way (from above)', kind: 'boolean', default: false }, { ...ONE_WAY_ANGLE_ROW, key: TILE_ONE_WAY_ANGLE_KEY, shownWith: TILE_ONE_WAY_KEY }, + // No density: a map never moves, so what its cells would weigh counts for nothing. + { ...FRICTION_ROW, key: TILE_FRICTION_KEY, shownWith: TILE_SOLID_KEY }, + { ...BOUNCINESS_ROW, key: TILE_BOUNCINESS_KEY, shownWith: TILE_SOLID_KEY }, // Below 1 a cell blocks less than its square, above 1 more – but only on the sides without a // blocking neighbour, so a wall of such cells stays closed (#140). { @@ -433,6 +457,7 @@ const ASSET_SCHEMAS: Record = { { value: 'circle', label: 'Circle' }, { value: 'capsule', label: 'Capsule' }, { value: 'polygon', label: 'Polygon' }, + { value: 'frame', label: 'Frame collision' }, ], }, // A box and a capsule fill width × height; the capsule is rounded along the longer side. @@ -450,10 +475,12 @@ const ASSET_SCHEMAS: Record = { { x: -50, y: 50 }, ], }, + // The frame whose collision outlines (#141) the "Frame collision" shape wears, kept linked (#193). + { key: 'frame', label: 'Frame', kind: 'texture', default: '' }, { key: 'isTrigger', label: 'Trigger (no blocking)', kind: 'boolean', default: false }, // The material, read by the physics engine where a rigidbody takes part (#146). - { key: 'friction', label: 'Friction', kind: 'number', default: 0.5, min: 0, step: 0.05 }, - { key: 'bounciness', label: 'Bounciness', kind: 'number', default: 0, min: 0, max: 1, step: 0.05 }, + FRICTION_ROW, + BOUNCINESS_ROW, { key: 'density', label: 'Density', kind: 'number', default: 1, min: 0, step: 0.1 }, // Blocks only what comes from that side, turned with the object; the rest passes through (#155). { key: 'oneWay', label: 'One-way', kind: 'boolean', default: false }, diff --git a/src/core/properties/bounds.ts b/src/core/properties/bounds.ts index b8c07185..cbf236ec 100644 --- a/src/core/properties/bounds.ts +++ b/src/core/properties/bounds.ts @@ -4,6 +4,7 @@ * where its corners are. */ import { type AssetType, hasSceneIcon } from '../model/AssetTypes'; +import type { FrameOutline } from '../model/frameOutline'; import { apply, boxCorners, @@ -49,15 +50,22 @@ export function textAnchor(props: PropertyBag): number { return align === 'left' ? 0 : align === 'right' ? 1 : 0.5; } -/** Centred on the object's position; text is anchored instead (see `textAnchor`). */ -export function footprintOf(type: AssetType, props: PropertyBag): { w: number; h: number } { +/** + * Centred on the object's position; text is anchored instead (see `textAnchor`). A collider that + * wears a frame (#193) is that frame's size, which only the caller can look up. + */ +export function footprintOf( + type: AssetType, + props: PropertyBag, + frame?: FrameOutline | null, +): { w: number; h: number } { switch (type) { case 'sprite': return { w: Number(props.width ?? 1), h: Number(props.height ?? 1) }; case 'text': return textSize(props); case 'collider': - return colliderSize(props); + return colliderSize(props, frame); case 'behavior': return { w: BEHAVIOR_SYMBOL, h: BEHAVIOR_SYMBOL }; case 'container': @@ -93,16 +101,27 @@ function anchorOffset(type: AssetType, props: PropertyBag, w: number): number { } /** The footprint's corners in world units, grown by `pad` world units on every side. */ -export function footprintCorners(type: AssetType, props: PropertyBag, m: Mat, pad = 0): Vec2[] { +export function footprintCorners( + type: AssetType, + props: PropertyBag, + m: Mat, + pad = 0, + frame?: FrameOutline | null, +): Vec2[] { const placed = placementOf(type, m); - const { w, h } = footprintOf(type, props); + const { w, h } = footprintOf(type, props, frame); const local = pad / (scaleOf(placed) || 1); return boxCorners(placed, w + 2 * local, h + 2 * local, anchorOffset(type, props, w), 0); } /** The world rectangle around the drawn footprint – what culling, framing and labels need. */ -export function footprintExtent(type: AssetType, props: PropertyBag, m: Mat): Extent { - return extentOfPoints(footprintCorners(type, props, m)); +export function footprintExtent( + type: AssetType, + props: PropertyBag, + m: Mat, + frame?: FrameOutline | null, +): Extent { + return extentOfPoints(footprintCorners(type, props, m, 0, frame)); } export function footprintContains(type: AssetType, props: PropertyBag, m: Mat, point: Vec2): boolean { diff --git a/src/core/properties/collider.ts b/src/core/properties/collider.ts index 25a98617..7b9de2fb 100644 --- a/src/core/properties/collider.ts +++ b/src/core/properties/collider.ts @@ -3,8 +3,10 @@ * "overlaps" means. A box is oriented: it turns and grows with the body it sits on. A capsule and a * polygon (#151) carry their points in the world already, and a polygon its convex pieces. */ +import { type FrameOutline, frameOutline } from '../model/frameOutline'; import { angleOf, apply, type Mat, scaleOf } from '../model/matrix'; import { convexPieces, pointsOf, signedArea } from '../model/polygon'; +import type { Project } from '../model/Project'; import { type PropertyBag, type PropertyValue, textOf, type Vec2 } from '../model/Properties'; import { dot } from '../util/vec'; import { @@ -28,15 +30,55 @@ export type ColliderShape = | { kind: 'circle'; x: number; y: number; r: number } /** The segment `a`–`b` grown by `r`. */ | { kind: 'capsule'; x: number; y: number; angle: number; a: Vec2; b: Vec2; r: number } - /** `outline` as drawn; `pieces` what collides – none when the outline is no polygon (see `polygonProblem`). */ - | { kind: 'polygon'; x: number; y: number; angle: number; outline: Vec2[]; pieces: Vec2[][] }; + /** + * `outline` as drawn; `pieces` what collides – none when the outline is no polygon (see + * `polygonProblem`). A frame's outline (#193) may be several rings: then `rings` holds them all, + * `outline` the first. Read them through `outlinesOf`. + */ + | { + kind: 'polygon'; + x: number; + y: number; + angle: number; + outline: Vec2[]; + rings?: Vec2[][]; + pieces: Vec2[][]; + }; type Box = Extract; type Circle = Extract; +type Polygon = Extract; type Classic = Box | Circle; +/** Every outline a polygon is drawn with – one, or a frame's several (#193). */ +export function outlinesOf(shape: Polygon): Vec2[][] { + return shape.rings ?? [shape.outline]; +} + /** Where a polygon collider keeps its corners (#151). */ export const COLLIDER_POINTS_KEY = 'points'; +/** Which frame a collider of the shape "Frame collision" wears (#193): a frame id, as a sprite's texture holds it. */ +export const COLLIDER_FRAME_KEY = 'frame'; + +/** Whether the collider wears a frame's outline (#193), which only the project can resolve. */ +export function wearsFrame(props: PropertyBag): boolean { + return props.shape === 'frame'; +} + +/** The frame a collider names, looked up in `project` – null for another shape or a dangling id. */ +export function colliderFrame(project: Project | null, props: PropertyBag): FrameOutline | null { + return wearsFrame(props) ? frameOutline(project, textOf(props[COLLIDER_FRAME_KEY] ?? '')) : null; +} + +/** The four corners of a centred `w` × `h` box, clockwise on screen. */ +function boxRing(w: number, h: number): Vec2[] { + return [ + { x: -w / 2, y: -h / 2 }, + { x: w / 2, y: -h / 2 }, + { x: w / 2, y: h / 2 }, + { x: -w / 2, y: h / 2 }, + ]; +} /** A polygon's pieces in its own units, cut once per stored list rather than on every test. */ const piecesCache = new WeakMap(); @@ -61,8 +103,12 @@ function capsuleCore(w: number, h: number): { a: Vec2; b: Vec2; r: number } { return { a: { x: -(w - h) / 2, y: 0 }, b: { x: (w - h) / 2, y: 0 }, r: h / 2 }; } -/** The shape as it sits in the world: sized by the transform's scale, turned (and mirrored) with it. */ -export function colliderShape(props: PropertyBag, m: Mat): ColliderShape { +/** + * The shape as it sits in the world: sized by the transform's scale, turned (and mirrored) with it. + * A collider of the shape "Frame collision" (#193) needs its frame from `colliderFrame`. Without one it is drawn + * as the frame's box, or its own width × height, and collides with nothing. + */ +export function colliderShape(props: PropertyBag, m: Mat, frame?: FrameOutline | null): ColliderShape { const scale = scaleOf(m); if (props.shape === 'circle') return { @@ -96,6 +142,20 @@ export function colliderShape(props: PropertyBag, m: Mat): ColliderShape { pieces: localPieces(value).map((piece) => piece.map((p) => apply(m, p))), }; } + if (wearsFrame(props)) { + const worn = frame && frame.pieces.length > 0; + const rings = worn ? frame.rings : [frame ? boxRing(frame.w, frame.h) : boxRing(w, h)]; + const placed = rings.map((ring) => ring.map((p) => apply(m, p))); + return { + kind: 'polygon', + x: m.tx, + y: m.ty, + angle: angleOf(m), + outline: placed[0] ?? [], + rings: placed, + pieces: worn ? frame.pieces.map((piece) => piece.map((p) => apply(m, p))) : [], + }; + } return { kind: 'box', x: m.tx, y: m.ty, w: w * scale, h: h * scale, angle: angleOf(m) }; } @@ -167,13 +227,15 @@ export function movedShape(shape: ColliderShape, move: (p: Vec2) => Vec2, turn: ...centre, angle: shape.angle + turn, outline: shape.outline.map(move), + ...(shape.rings && { rings: shape.rings.map((ring) => ring.map(move)) }), pieces: shape.pieces.map((piece) => piece.map(move)), }; } } -/** The size in the collider's own units, before any transform. */ -export function colliderSize(props: PropertyBag): { w: number; h: number } { +/** The size in the collider's own units, before any transform; a worn frame (#193) is its own size. */ +export function colliderSize(props: PropertyBag, frame?: FrameOutline | null): { w: number; h: number } { + if (wearsFrame(props) && frame) return { w: frame.w, h: frame.h }; // Centred like every footprint, so it reaches as far as the farthest corner on each axis. if (props.shape === 'polygon') { const points = colliderPoints(props); @@ -407,7 +469,7 @@ export function shapeBounds(shape: ColliderShape): ShapeBounds { bottom: Math.max(shape.a.y, shape.b.y) + shape.r, }; case 'polygon': - return boundsOfPoints(shape.outline); + return boundsOfPoints(outlinesOf(shape).flat()); } } diff --git a/src/editor/viewport/FreeViewport.ts b/src/editor/viewport/FreeViewport.ts index 592ec8f7..082e0fad 100644 --- a/src/editor/viewport/FreeViewport.ts +++ b/src/editor/viewport/FreeViewport.ts @@ -34,7 +34,7 @@ export abstract class FreeViewport extends WorldViewport { fit(): void { const { items } = this.collectFrame(); const boxes = items.map((item) => { - const e = footprintExtent(item.type, item.props, item.m); + const e = footprintExtent(item.type, item.props, item.m, item.frame); return { id: item.id, x: e.left, y: e.top, w: e.right - e.left, h: e.bottom - e.top }; }); const { minX, minY, maxX, maxY } = boundsOf(boxes, { minX: 0, minY: 0, maxX: 0, maxY: 0 })!; diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index 90b30d0c..6e1d6a5b 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -1094,7 +1094,7 @@ export class SceneViewport extends FreeViewport { if (!item) continue; // Two screen pixels outside the footprint, whichever way it is turned. const corners = this.screenCorners( - footprintCorners(item.type, item.props, item.m, 2 / view.scale), + footprintCorners(item.type, item.props, item.m, 2 / view.scale, item.frame), view, ); // A switched-off object draws nothing, so its outline is the only way to see where it diff --git a/src/editor/viewport/WorldViewport.ts b/src/editor/viewport/WorldViewport.ts index 20f4a100..95f9e6a8 100644 --- a/src/editor/viewport/WorldViewport.ts +++ b/src/editor/viewport/WorldViewport.ts @@ -191,7 +191,7 @@ export abstract class WorldViewport extends Widget { const view = this.currentView(); const item = this.lastItems.find((i) => i.id === objectId); if (!item || !view) return null; - const extent = footprintExtent(item.type, item.props, item.m); + const extent = footprintExtent(item.type, item.props, item.m, item.frame); const local = worldToScreen(view, (extent.left + extent.right) / 2, (extent.top + extent.bottom) / 2); const origin = this.getGlobalPosition(); return { @@ -229,8 +229,8 @@ export abstract class WorldViewport extends Widget { const seen = new Set(); for (const item of items) { seen.add(item.id); - const size = footprintOf(item.type, item.props); - const extent = footprintExtent(item.type, item.props, item.m); + const size = footprintOf(item.type, item.props, item.frame); + const extent = footprintExtent(item.type, item.props, item.m, item.frame); // A switched-off object is not drawn at all, in the scene view as in the game; it is // selected and switched back on from the hierarchy panel. A source is never culled by its // own little square: its particles may be anywhere on screen while it is not. diff --git a/src/runtime/Entity.ts b/src/runtime/Entity.ts index d32532d4..721d7373 100644 --- a/src/runtime/Entity.ts +++ b/src/runtime/Entity.ts @@ -1,6 +1,7 @@ import type { ENTITY_HANDLE } from '../core/graph/NodeContext'; import type { AnimationPlayer } from '../core/model/animation'; import type { AssetType } from '../core/model/AssetTypes'; +import type { FrameOutline } from '../core/model/frameOutline'; import type { PropertyBag } from '../core/model/Properties'; /** A live instance of a scene object; its resolved props are mutable runtime state the world mirrors back. */ @@ -26,4 +27,9 @@ export interface Entity { animations?: AnimationPlayer[]; /** How often an `Emit` node has restarted this particle source; out of `props` for the same reason. */ emits?: number; + /** + * The frame a collider of the shape "Frame collision" wears (#193), looked up in the world's project on every + * call, so a changed `frame` prop or a redrawn outline counts at once. Absent outside a world. + */ + wornFrame?: () => FrameOutline | null; } diff --git a/src/runtime/World.ts b/src/runtime/World.ts index 73a6bfe4..b7a545c3 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -9,7 +9,7 @@ import { descendantsBelow } from '../core/model/walk'; import { findAsset, findScene, type Project } from '../core/model/Project'; import { createSceneObject, type SceneObject } from '../core/model/SceneObject'; import { footprintContains } from '../core/properties/bounds'; -import { colliderShape, shapeContains } from '../core/properties/collider'; +import { colliderFrame, colliderShape, shapeContains } from '../core/properties/collider'; import { resolveSceneObjectProps } from '../core/properties/resolve'; import type { Entity } from './Entity'; import type { System } from './systems/System'; @@ -176,6 +176,10 @@ export class World { props: {}, parent: null, }; + if (asset.type === 'collider') { + const collider = entity; + collider.wornFrame = () => colliderFrame(this.project, collider.props); + } this.entities.push(entity); } entity.assetId = asset.id; @@ -309,7 +313,9 @@ export class World { const parts = [entity, ...this.descendantsOf(entity)].filter((e) => isHandleEnabled(e)); const colliders = parts.filter((e) => e.type === 'collider'); if (colliders.length > 0) - return colliders.some((c) => shapeContains(colliderShape(c.props, worldMatrixOf(c)), point)); + return colliders.some((c) => + shapeContains(colliderShape(c.props, worldMatrixOf(c), c.wornFrame?.()), point), + ); return parts.some( (e) => (e.type === 'sprite' || e.type === 'text') && diff --git a/src/runtime/physics/PhysicsSystem.ts b/src/runtime/physics/PhysicsSystem.ts index adf949c0..1096b002 100644 --- a/src/runtime/physics/PhysicsSystem.ts +++ b/src/runtime/physics/PhysicsSystem.ts @@ -12,6 +12,7 @@ import type { Vec2 } from '../../core/model/Properties'; import { signedArea } from '../../core/model/polygon'; import { parentChain } from '../../core/model/walk'; import { collisionFilterOf, interactionGroups } from '../../core/model/collisionLayers'; +import type { TileMaterial } from '../../core/model/tilemapCollision'; import { type ColliderShape, contactBetween, @@ -47,6 +48,7 @@ import { shapeOf, type SolidShape, Solids, + surfaceOf, } from '../systems/solids'; import type { System } from '../systems/System'; import { type Rapier, toMetres, toPixels } from './engine'; @@ -507,10 +509,11 @@ export class PhysicsSystem implements System, CollisionSource { const local = shapes.map((solid) => ({ ...solid, pieces: piecesInBody(origin, solid.shape) })); const shapeKey = JSON.stringify( - local.map(({ entity, pieces, facing }) => [ + local.map(({ entity, pieces, facing, surface }) => [ entity.id, pieces.map(pieceKey), facing && [round(facing.x), round(facing.y), facing.cone], + surface ?? 0, entity.type === 'collider' ? [isTrigger(entity.props), colliderMaterial(entity.props)] : 0, // The layers sit on the engine collider, so changing one has to rebuild it (#152). interactionGroups(collisionFilterOf(entity.props)), @@ -530,10 +533,10 @@ export class PhysicsSystem implements System, CollisionSource { (sum, { entity, shape }) => sum + (isSolid(entity) ? shapeArea(shape) : 0), 0, ); - for (const { entity, shape, facing, pieces } of local) + for (const { entity, shape, facing, surface, pieces } of local) for (const piece of pieces) { const share = mass > 0 && area > 0 ? (mass * pieceArea(piece)) / area : 0; - const desc = this.colliderDesc(entity, piece, share, facing !== null); + const desc = this.colliderDesc(entity, piece, share, facing !== null, surface); if (!desc) continue; const collider = this.engine!.createCollider(desc, body.body); body.colliders.push(collider); @@ -574,7 +577,13 @@ export class PhysicsSystem implements System, CollisionSource { } /** The engine collider of one piece; null for a piece of no size, which the engine would refuse. */ - private colliderDesc(entity: Entity, piece: Piece, mass: number, oneWay: boolean): ColliderDesc | null { + private colliderDesc( + entity: Entity, + piece: Piece, + mass: number, + oneWay: boolean, + surface?: TileMaterial, + ): ColliderDesc | null { const R = this.rapier; if (pieceArea(piece) <= 0) return null; let desc: ColliderDesc | null; @@ -597,9 +606,10 @@ export class PhysicsSystem implements System, CollisionSource { if (!desc) return null; if (piece.kind !== 'convex') desc.setTranslation(toMetres(piece.x), toMetres(piece.y)).setRotation(piece.angle); - // A tile has no material of its own; it gets what a new collider starts with. + // A tile's cell brings its tile's surface (#193); a density it does not need, a map never moves. const material = colliderMaterial(entity.type === 'collider' ? entity.props : {}); - desc.setFriction(material.friction).setRestitution(material.bounciness); + const { friction, bounciness } = surfaceOf(entity, surface); + desc.setFriction(friction).setRestitution(bounciness); // The livelier side wins, as in Box2D: a ball that should bounce does so on plain ground too. desc.setRestitutionCombineRule(R.CoefficientCombineRule.Max); if (!isSolid(entity)) desc.setSensor(true).setDensity(0); diff --git a/src/runtime/systems/solids.ts b/src/runtime/systems/solids.ts index c9977155..1d234a11 100644 --- a/src/runtime/systems/solids.ts +++ b/src/runtime/systems/solids.ts @@ -13,6 +13,8 @@ import { oneWayAngles, solidPolygons, solidRects, + type TileMaterial, + tileMaterials, } from '../../core/model/tilemapCollision'; import { parentChain } from '../../core/model/walk'; import { @@ -106,6 +108,8 @@ export const NOT_BLOCKED: MoveResult = Object.freeze({ export interface SolidShape { shape: ColliderShape; facing: OneWaySide | null; + /** A tilemap cell's surface (#193); a collider's is read off its own props (see `surfaceOf`). */ + surface?: TileMaterial; } /** A shape in the mover's way and the collider or tilemap it belongs to. */ @@ -143,6 +147,8 @@ export interface RayFilter { /** Where a sweep stopped: what it met, with which own collider, the normal, and the move left over. */ interface SweepContact { by: Entity; + /** The surface of the shape met when it is a tile's (#193). */ + surface?: TileMaterial; own: Entity; normal: Vec2; left: Vec2; @@ -203,17 +209,15 @@ export class Solids { const asset = findAssetOf(world.project, tilemap.assetId, 'tilemap'); const { project } = world; const { props } = tilemap; - blocks = asset - ? { - rects: solidRects(project, props, asset), - polygons: solidPolygons(project, props, asset), - oneWay: oneWayAngles(project, asset).map((angle) => ({ - angle, - rects: solidRects(project, props, asset, angle), - polygons: solidPolygons(project, props, asset, angle), - })), - } - : { rects: [], polygons: [], oneWay: [] }; + // One group per side and surface, so cells only merge with cells that block alike (#159, #193). + const groups = (angle: number | null) => + tileMaterials(project, asset!, angle ?? false).map((material) => ({ + angle, + material, + rects: solidRects(project, props, asset!, angle ?? false, material), + polygons: solidPolygons(project, props, asset!, angle ?? false, material), + })); + blocks = asset ? [...groups(null), ...oneWayAngles(project, asset).flatMap(groups)] : []; this.blocks.set(tilemap, blocks); } return blocks; @@ -228,27 +232,25 @@ export class Solids { solidShapesOf(world: World, body: Entity, within?: ShapeBounds): SolidShape[] { const m = worldMatrixOf(body); if (body.type !== 'tilemap') - return [{ shape: colliderShape(body.props, m), facing: oneWayFacing(body.props, m) }]; - const { rects, polygons, oneWay } = this.blocksOf(world, body); + return [ + { shape: colliderShape(body.props, m, body.wornFrame?.()), facing: oneWayFacing(body.props, m) }, + ]; const up = oneWayFacing({ oneWay: true }, m); // A map is many solids under one entity, so the cells out of reach are dropped before any of // them is worked out in world units at all (#184). const range = within ? cellsWithin(body.props, m, within) : null; const near = (x: number, y: number, w?: number, h?: number) => !range || cellsMeet(range, x, y, w, h); - const shapes = (cells: CellRect[], outlines: CellPolygon[], facing: OneWaySide | null) => [ - ...cells - .filter((rect) => near(rect.x, rect.y, rect.w, rect.h)) - .map((rect) => ({ shape: cellRectShape(body.props, rect, m), facing })), - ...outlines - .filter((polygon) => near(polygon.x, polygon.y)) - .map((polygon) => ({ shape: cellPolygonShape(body.props, polygon, m), facing })), - ]; - return [ - ...shapes(rects, polygons, null), - ...oneWay.flatMap((group) => - shapes(group.rects, group.polygons, up && { ...up, cone: oneWayCone(group.angle) }), - ), - ]; + return this.blocksOf(world, body).flatMap(({ angle, material: surface, rects, polygons }) => { + const facing = angle === null ? null : up && { ...up, cone: oneWayCone(angle) }; + return [ + ...rects + .filter((rect) => near(rect.x, rect.y, rect.w, rect.h)) + .map((rect) => ({ shape: cellRectShape(body.props, rect, m), facing, surface })), + ...polygons + .filter((polygon) => near(polygon.x, polygon.y)) + .map((polygon) => ({ shape: cellPolygonShape(body.props, polygon, m), facing, surface })), + ]; + }); } shapesOf(world: World, body: Entity, within?: ShapeBounds): ColliderShape[] { @@ -391,7 +393,11 @@ export class Solids { const contact = sweep(entity, frame, own, obstacles, passing, rest); if (!contact) break; contacts.push(contact); - rest = slideAlong(contact.left, contact.normal, frictionBetween(contact.own, contact.by)); + rest = slideAlong( + contact.left, + contact.normal, + frictionBetween(contact.own, contact.by, contact.surface), + ); } if (contacts.length === 0) return NOT_BLOCKED; const pushed = new Set(); @@ -486,6 +492,7 @@ function sweep( const left = distance - i * step - (step - correction); return { by: obstacle.body, + surface: obstacle.surface, own: collider, normal: contactBetween(shapeOf(collider), obstacle.shape, forward).normal, left: { x: forward.x * left, y: forward.y * left }, @@ -510,12 +517,15 @@ function slideAlong(move: Vec2, normal: Vec2, friction: number): Vec2 { /** * The friction of a pair, as the physics engine combines it for its bodies (the average), so an - * object moved by the graph slides as far as one the engine moves. A tile has no material of its - * own and has what a new collider starts with. + * object moved by the graph slides as far as one the engine moves. A tile brings its own (#193). */ -function frictionBetween(own: Entity, obstacle: Entity): number { - const of = (e: Entity) => colliderMaterial(e.type === 'collider' ? e.props : {}).friction; - return (of(own) + of(obstacle)) / 2; +function frictionBetween(own: Entity, obstacle: Entity, surface?: TileMaterial): number { + return (surfaceOf(own).friction + surfaceOf(obstacle, surface).friction) / 2; +} + +/** What a shape of `body` offers to what meets it: a tile's own surface, else the collider's material. */ +export function surfaceOf(body: Entity, surface?: TileMaterial): TileMaterial { + return surface ?? colliderMaterial(body.type === 'collider' ? body.props : {}); } /** The rectangle around every shape, or null without one – nothing to narrow anything down with. */ @@ -552,15 +562,19 @@ function unitOf(v: Vec2): Vec2 { return length > 0 ? { x: v.x / length, y: v.y / length } : v; } -interface TilemapBlocks { +/** + * A map's blocking cells, one group per surface (#193) and side: `angle` null for the cells that + * block from everywhere, else the angle one-way cells land things within (#159). + */ +type TilemapBlocks = { + angle: number | null; + material: TileMaterial; rects: CellRect[]; polygons: CellPolygon[]; - /** The one-way cells, one group per angle their tiles land things within. */ - oneWay: { angle: number; rects: CellRect[]; polygons: CellPolygon[] }[]; -} +}[]; export function shapeOf(collider: Entity): ColliderShape { - return colliderShape(collider.props, worldMatrixOf(collider)); + return colliderShape(collider.props, worldMatrixOf(collider), collider.wornFrame?.()); } /** What of an entity stops at walls when it moves: its solid colliders that are switched on. */ diff --git a/src/viewport/items.ts b/src/viewport/items.ts index 967f2dcb..15b24849 100644 --- a/src/viewport/items.ts +++ b/src/viewport/items.ts @@ -17,6 +17,8 @@ import { import { findAsset, findAssetOf, type ObjectHolder, type Project } from '../core/model/Project'; import { fontFileOf } from '../core/model/font'; import { resolveTexture, type TextureRef } from '../core/model/texture'; +import type { FrameOutline } from '../core/model/frameOutline'; +import { colliderFrame } from '../core/properties/collider'; import { enabledAlongChain } from '../core/properties/enabled'; import { fontOf } from '../core/properties/fonts'; import { resolveSceneObjectProps } from '../core/properties/resolve'; @@ -49,6 +51,8 @@ export interface DrawItem { tilemap: TilemapDraw | null; /** How often an `Emit` node has restarted this source; a change is what makes it start over. */ emits: number; + /** The frame a collider of the shape "Frame collision" wears (#193), resolved here like a sprite's picture. */ + frame: FrameOutline | null; } interface DrawList { @@ -167,6 +171,7 @@ export function collectHolderItems( particles: asset.type === 'particles' ? asset : null, tilemap: asset.type === 'tilemap' ? resolveTilemap(project, asset.id, props) : null, emits: 0, + frame: asset.type === 'collider' ? colliderFrame(project, props) : null, }); } const byId = new Map(items.map((i) => [i.id, i])); @@ -204,6 +209,7 @@ export function collectWorldItems(world: World): DrawList { particles: e.type === 'particles' ? (findAssetOf(world.project, e.assetId, 'particles') ?? null) : null, tilemap: e.type === 'tilemap' ? resolveTilemap(world.project, e.assetId, e.props) : null, emits: e.emits ?? 0, + frame: e.wornFrame?.() ?? null, })); return { items, background: world.backgroundColor }; } diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts index 3a2ca4e0..079c959f 100644 --- a/src/viewport/symbols.ts +++ b/src/viewport/symbols.ts @@ -8,6 +8,7 @@ import { isTrigger, oneWayAngle, oneWayFacing, + outlinesOf, } from '../core/properties/collider'; import { SHEET_EDGE } from '../core/shader/nodes'; import type { NineSlice } from '../core/model/texture'; @@ -150,9 +151,10 @@ export function drawItem( case 'collider': { // Only the outline band is hit-testable, so clicks inside still reach the sprite that the // collider sits on. - const shape = colliderShape(item.props, IDENTITY); + const shape = colliderShape(item.props, IDENTITY, item.frame); traceShape(node, shape); - // A polygon that crosses itself collides with nothing, and says so in red. + // A polygon that crosses itself collides with nothing, and says so in red – as does a frame + // outline without a frame or without a drawn outline (#193). const color = shape.kind === 'polygon' && shape.pieces.length === 0 ? Theme.colors.danger @@ -201,7 +203,7 @@ function shapeBounds(shape: ColliderShape): Bounds { { x: p.x - shape.r, y: p.y - shape.r }, { x: p.x + shape.r, y: p.y + shape.r }, ]) - : shape.outline; + : outlinesOf(shape).flat(); return { left: Math.min(...points.map((p) => p.x)), top: Math.min(...points.map((p) => p.y)), @@ -280,7 +282,7 @@ function traceShape(node: Graphics, shape: ColliderShape): void { return; } case 'polygon': - if (shape.outline.length >= 2) node.poly(shape.outline, true); + for (const ring of outlinesOf(shape)) if (ring.length >= 2) node.poly(ring, true); return; } } @@ -299,10 +301,12 @@ function colliderOutlineHitArea( if (shape.kind === 'capsule') return { contains: (x, y) => Math.abs(distanceToSegment({ x, y }, shape.a, shape.b) - shape.r) <= band }; if (shape.kind === 'polygon') { - const ring = shape.outline; + const rings = outlinesOf(shape); return { contains: (x, y) => - ring.some((a, i) => distanceToSegment({ x, y }, a, ring[(i + 1) % ring.length]!) <= band), + rings.some((ring) => + ring.some((a, i) => distanceToSegment({ x, y }, a, ring[(i + 1) % ring.length]!) <= band), + ), }; } const hw = shape.w / 2; diff --git a/tests/core/one-way-angle.test.ts b/tests/core/one-way-angle.test.ts index a005409b..16754a86 100644 --- a/tests/core/one-way-angle.test.ts +++ b/tests/core/one-way-angle.test.ts @@ -105,8 +105,12 @@ describe('the one-way angle (#159)', () => { const angleRow = tile.find((d) => d.key === TILE_ONE_WAY_ANGLE_KEY)!; expect(isRowShown(angleRow, { [TILE_ONE_WAY_KEY]: false })).toBe(false); expect(isRowShown(angleRow, { [TILE_ONE_WAY_KEY]: true })).toBe(true); - // Rows without a switch are always there. - expect(tile.filter((d) => !isRowShown(d, {})).map((d) => d.key)).toEqual([TILE_ONE_WAY_ANGLE_KEY]); + // Rows without a switch are always there; the surface waits for Solid (#193). + expect(tile.filter((d) => !isRowShown(d, {})).map((d) => d.key)).toEqual([ + TILE_ONE_WAY_ANGLE_KEY, + 'friction', + 'bounciness', + ]); }); }); diff --git a/tests/core/surface.test.ts b/tests/core/surface.test.ts new file mode 100644 index 00000000..1364757b --- /dev/null +++ b/tests/core/surface.test.ts @@ -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, body?: Record) => { + 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); + }); +}); diff --git a/tools/e2e/40-tilemap.mjs b/tools/e2e/40-tilemap.mjs index 9661b880..843d2c68 100644 --- a/tools/e2e/40-tilemap.mjs +++ b/tools/e2e/40-tilemap.mjs @@ -715,6 +715,9 @@ export async function tilemap(t) { await openTab(t, 'scene'); await select({ kind: 'asset', assetId: 'at-grass' }); const solidRow = await json("__gm.inspectorField('Solid')"); + const surfaceRows = () => + json("[__gm.inspectorField('Friction'), __gm.inspectorField('Bounciness')].map(Boolean)"); + const surfaceBefore = await surfaceRows(); if (solidRow) await click(solidRow.x, solidRow.y); await settle(); check( @@ -722,6 +725,13 @@ export async function tilemap(t) { Boolean(solidRow) && (await t.asset('at-grass'))?.props.solid === true, JSON.stringify({ solidRow, props: (await t.asset('at-grass'))?.props }), ); + // Its surface (#193) is a solid tile's business: the rows come with the switch. + const surfaceAfter = await surfaceRows(); + check( + 'Friction and Bounciness show on a tile only once it is solid', + JSON.stringify(surfaceBefore) === '[false,false]' && JSON.stringify(surfaceAfter) === '[true,true]', + JSON.stringify({ surfaceBefore, surfaceAfter }), + ); await mutate(`__gm.lookAt(0, ${LANE})`); await settle(); const outlined = await json(`__gm.tilemap.solids(${JSON.stringify(MAP_OBJECT)})`); diff --git a/tools/e2e/42-shapes.mjs b/tools/e2e/42-shapes.mjs index c66c277e..687b3c7c 100644 --- a/tools/e2e/42-shapes.mjs +++ b/tools/e2e/42-shapes.mjs @@ -5,7 +5,8 @@ import { 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. + * 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). */ /** A 32 × 32 picture, pasted like a file: the texture the outline is drawn on. */ @@ -354,4 +355,70 @@ export async function shapes(t) { JSON.stringify({ frames: climb.length, highest, top: MAP_Y - 10, last: climb.at(-1) }), ); await clearShapes(); + + // 42e. A collider wears the same frame's outline (#193), linked: shape "Frame collision", the frame + // picked like a texture. It is the frame's size in the scene, and a walker climbs its slope – + // a box of that size would have stopped it at its side. + const F = Y; // the floor's top face; the wedge stands on it, its slope rising to x = 16 + await mutate(` + const scene = __gm.state.activeScene; + const assets = __gm.state.project.assets; + const texture = assets.find((a) => a.id === ${JSON.stringify(texture?.id)}); + texture.frames.find((f) => f.id === 'sh-frame').collision = [[{ x: 0, y: 32 }, { x: 32, y: 0 }, { x: 32, y: 32 }]]; + ${MAKE_OBJECT} + ${put} + const behavior = structuredClone(assets.find((a) => a.id === 'mover')); + Object.assign(behavior, { id: 'sh-climb', name: 'Climb', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify(climbGraph)} }); + assets.push(behavior); + assets.push({ id: 'sh-col-wedge', type: 'collider', name: 'sh-col-wedge', + props: { shape: 'frame', frame: 'sh-frame', isTrigger: false } }); + assets.push({ id: 'sh-col-floor', type: 'collider', name: 'sh-col-floor', + props: { shape: 'box', width: 400, height: 32, isTrigger: false } }); + assets.push({ id: 'sh-col-walker', type: 'collider', name: 'sh-col-walker', + props: { shape: 'box', width: 20, height: 20, friction: 0, isTrigger: false } }); + put('sh-floor', 'crate', '', 0, ${F + 16}, 'Floor', { width: 20, height: 20 }); + put('sh-floor-col', 'sh-col-floor', 'sh-floor', 0, 0); + put('sh-wedge', 'crate', '', 0, ${F - 16}, 'Wedge', { width: 20, height: 20 }); + put('sh-wedge-col', 'sh-col-wedge', 'sh-wedge', 0, 0); + put('sh-walker', 'crate', '', -100, ${F - 10}, 'Walker', { width: 20, height: 20 }); + put('sh-walker-col', 'sh-col-walker', 'sh-walker', 0, 0); + put('sh-walker-climb', 'sh-climb', 'sh-walker', 0, 0, 'Climb'); + __gm.state.select(null)`); + await evaluate(`__gm.lookAt(0, ${F})`); + await settle(); + const wedgeRect = await json(`__gm.objectRect('sh-wedge-col')`); + const floorRect = await json(`__gm.objectRect('sh-floor-col')`); + check( + 'a collider wearing a frame outline is the size of the frame in the scene, not its own width', + wedgeRect && + floorRect && + Math.abs(wedgeRect.w - wedgeRect.h) < 1 && + Math.abs(floorRect.w / wedgeRect.w - 400 / 32) < 0.2, + JSON.stringify({ wedgeRect, floorRect }), + ); + await shot('42-frame-collider'); + await play(); + await evaluate(`(() => { + const out = (window.__frameClimb = []); + const begin = performance.now(); + const sample = () => { + const w = __gm.world(); + if (!w) return; + const e = w.entities.find((x) => x.objectId === 'sh-walker'); + out.push({ x: e?.worldX, y: e?.worldY }); + if (performance.now() - begin < 3000) requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + })()`); + await sleep(3300); + const frameClimb = await json('window.__frameClimb'); + await stop(); + // Over the wedge's top corner (16, F − 32) the walker's centre stands 10 higher. + const peak = Math.min(...frameClimb.map((s) => s.y)); + check( + "a walker whose Move collides climbs the slope drawn in the collider's frame and goes on past it", + 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) }), + ); + await clearShapes(); }