Colliders take a frame's outline and tiles have their own surface (#193)
game-maker / gate (push) Successful in 2m11s
game-maker / suite (push) Successful in 7m33s

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:
adminandClaude Opus 5 committed 2026-09-21 07:29:26 +02:00
1 parent 3ff7e91dbf
commit f4ec23d3d3
20 files changed
+729 -83

No files matched your search

+6
View File
@@ -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;
}
+8 -2
View File
@@ -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') &&
+16 -6
View File
@@ -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);
+52 -38
View File
@@ -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<Entity>();
@@ -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. */