Colliders take a frame's outline and tiles have their own surface (#193)
Issue #193 asked where friction, bounciness and density of a tile live. Tiles had a fixed surface (friction 0.5, bounciness 0) and a collider could not use the outline drawn on a texture frame (#141). Both gaps are closed here. - A collider has a fifth shape "Frame collision" with a Frame row. The frame is picked like a sprite's texture and stored as the value 'frame'. - The shape is linked, not copied. Redrawing the outline in the texture tab changes every collider using that frame, in the editor and on the next play. - The frame centre sits on the collider origin, one image pixel is one unit. The shape turns, scales and mirrors like any other. - Every outline ring blocks. Each ring is split into convex parts the same way a polygon is. - Without a frame, with a whole texture or with a frame that has no outline, the collider is drawn red as a box and blocks nothing. - Move, Rapier, raycast, detection, hit test, drawing and footprint all resolve the frame. At runtime an entity looks the frame up live in the current project. - A tile has Friction and Bounciness next to Solid and One-way. The rows only show when Solid is on. Defaults and mixing rules match the collider. Density is left out because a tilemap never moves. - Tilemap cells are grouped by surface as well as by one-way angle. Ice and stone never merge into one rectangle, and every cell brings its surface to Move and Rapier. Findings: - A tile with a collision scale below 1 is no longer flush with a neighbour of another surface. It is inset there like at an open side, the same as next to one-way tiles today. - A bounce test first measured 0.84. The ball had landed on the hero at the origin, not on the tile. On the tile it measures 0.993. Tests: - ./check.sh: 130 files, 1931 tests, exit 0 (was 129 / 1917). - 14 new unit tests in tests/core/surface.test.ts. The Move tests run against the editor run and the physics system. - 3 new e2e checks: 40l (surface rows follow Solid), 42e (collider has frame size, a walker climbs the slope in the frame). - GM_STEPS=1-8,39-43 freshly seeded: ALL PASSED, 376 checks. - Three counter-probes bite: Move ignores the tile surface (2 fail), Rapier ignores it (1 fails), frame shape falls back to a box (5 fail). - Check sum 1198 is computed (1195 + 3), the full run belongs to CI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3ff7e91dbf
commit
f4ec23d3d3
20 files changed
+729
-83
No files matched your search
@@ -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<ColliderShape, { kind: 'box' }>;
|
||||
type Circle = Extract<ColliderShape, { kind: 'circle' }>;
|
||||
type Polygon = Extract<ColliderShape, { kind: 'polygon' }>;
|
||||
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<object, Vec2[][]>();
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user