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
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user