Moving against a tilemap whose cells block with an outline no longer costs more than a whole frame: an outlined cell is its own polygon, split into convex parts, so a map that is a handful of merged rectangles when built from plain solid tiles is hundreds of solids under one entity - and Move brought every one of them into world coordinates and tested it exactly, including the ones on the far side of the map, which measured 148 ms per Move call on a 20x20 map with 16-corner outlines (34 ms at 8 corners, 0.4 ms with plain solid tiles), matching the report that standing still holds 60 fps while anything that moves stutters; the fix is a broad phase in two stages that decides nothing and only leaves out what cannot be reached - a move never ends further from its start than it is long, because sliding always shortens the rest, so every collider of the mover gets a rectangle grown by the move length and an obstacle whose rectangle does not meet it is never tested, a ray taking the rectangle around itself, and that rectangle is carried back into the map's own units with invert(m) and turned into cell coordinates (cellsWithin), a rotated map through its own axes, so a far cell is not brought into world form in the first place - solidShapesOf was O(cells) per call, which was the second cost block and the reason the 60x60 case still grew; CollisionSystem.overlaps takes the same range, so the editor run asks a map only for the cells within reach of the other side each frame, two maps narrowing nothing, and a Move obstacle now falls away at build time when its layer does not meet the mover's (#152) instead of inside the loop; the measurements after: 148 -> 0.26 ms, 34 -> 0.27 ms, 60x60 20 -> 3.5 ms, plain tiles unchanged; this is no regression of the recent commits - the same benchmark in worktrees against 947a6b1 (#141), 6a31f5e, 4f55f85 and ae3aa7d gives the same 85-148 ms, the cost has been there since outlined cells arrived with #141; new pure functions shapeBounds/grownBounds/boundsMeet in collider.ts and cellsWithin/cellsMeet in tilemapCollision.ts, 11 new unit tests in tests/core/broadphase.test.ts (the rectangles of all four shapes including a rotated box measuring its diagonal reach, touching counts as meeting, cellsWithin straight and turned by 90 degrees, a map handing out 400 cells without a range and under 10 with one, an 800 unit move from far away still stopping at the first cell, a rotated map blocking where its one painted cell was turned to, a ray across the whole map, and the editor run seeing a collider on a far cell), and four counter-checks hold: dropping the cell filter fails the counting test, padding without the move length fails 4 move tests, ignoring the rotation in cellsWithin fails 3, and a wrong range in the detection fails the touching test; one blunt test on the way - the first rotated map was painted on every cell, where even a wrongly computed cell range blocks and the counter-check stayed green, so exactly one cell is painted now and the test measures where it turned to; no e2e check and the check sum stays 1157, because a timing check in the browser would flutter under CI load while modules 40 and 42 cover the correctness anyway, the speed evidence being the node benchmark (#184)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ffdf0a8ddb
commit
eac50e4194
5 files changed
+394
-14
No files matched your search
@@ -24,7 +24,7 @@
|
||||
*/
|
||||
import type { TileAsset, TilemapAsset } from './Asset';
|
||||
import { tileQuadrants } from './autotile';
|
||||
import { angleOf, apply, type Mat, scaleOf } from './matrix';
|
||||
import { angleOf, apply, invert, type Mat, scaleOf } from './matrix';
|
||||
import { boundaryOf, type BoundaryLine, clipToRect, convexPieces, signedArea } from './polygon';
|
||||
import type { PropertyBag, Vec2 } from './Properties';
|
||||
import { findAssetOf, type Project } from './Project';
|
||||
@@ -38,8 +38,8 @@ import {
|
||||
tileKind,
|
||||
type TileKind,
|
||||
} from './tile';
|
||||
import { cellOrigin, inGrid, neighboursOf, parseCellKey, tilemapTileSize } from './tilemap';
|
||||
import { type ColliderShape, oneWayAngle } from '../properties/collider';
|
||||
import { cellOrigin, inGrid, neighboursOf, parseCellKey, tilemapSize, tilemapTileSize } from './tilemap';
|
||||
import { type ColliderShape, oneWayAngle, type ShapeBounds } from '../properties/collider';
|
||||
|
||||
/** A rectangle in cells: top-left corner and size. Whole cells at collision scale 1, parts of one otherwise. */
|
||||
export interface CellRect {
|
||||
@@ -449,6 +449,51 @@ export function cellRectShape(props: PropertyBag, rect: CellRect, m: Mat): Colli
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Which cells of a placed map can reach into a world rectangle (#184): the rectangle taken back
|
||||
* into the map's own units, as cell coordinates – a turned map gives the rectangle around its
|
||||
* turned self, which is wider than needed but never too narrow.
|
||||
*/
|
||||
export function cellsWithin(props: PropertyBag, m: Mat, bounds: ShapeBounds): CellBounds {
|
||||
const back = invert(m);
|
||||
const tile = tilemapTileSize(props);
|
||||
const size = tilemapSize(props);
|
||||
const corners = [
|
||||
{ x: bounds.left, y: bounds.top },
|
||||
{ x: bounds.right, y: bounds.top },
|
||||
{ x: bounds.right, y: bounds.bottom },
|
||||
{ x: bounds.left, y: bounds.bottom },
|
||||
].map((p) => apply(back, p));
|
||||
const cell = (values: number[], half: number) => values.map((v) => (v + half) / tile);
|
||||
const xs = cell(
|
||||
corners.map((p) => p.x),
|
||||
size.w / 2,
|
||||
);
|
||||
const ys = cell(
|
||||
corners.map((p) => p.y),
|
||||
size.h / 2,
|
||||
);
|
||||
return {
|
||||
left: Math.min(...xs),
|
||||
top: Math.min(...ys),
|
||||
right: Math.max(...xs),
|
||||
bottom: Math.max(...ys),
|
||||
};
|
||||
}
|
||||
|
||||
/** A rectangle in cell coordinates, as `cellsWithin` reports it. */
|
||||
export interface CellBounds {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
/** Whether a rectangle of cells touches the range at all – touching counts, so nothing near is lost. */
|
||||
export function cellsMeet(range: CellBounds, x: number, y: number, w = 1, h = 1): boolean {
|
||||
return x <= range.right && x + w >= range.left && y <= range.bottom && y + h >= range.top;
|
||||
}
|
||||
|
||||
/** An outlined cell as it sits in the world: a polygon turning and growing with the placed map. */
|
||||
export function cellPolygonShape(props: PropertyBag, polygon: CellPolygon, m: Mat): ColliderShape {
|
||||
const world = (p: Vec2) => apply(m, cellOrigin(props, p.x, p.y));
|
||||
|
||||
@@ -374,6 +374,64 @@ export function contactBetween(
|
||||
return { separation, normal: { x: sign * world.x + 0, y: sign * world.y + 0 } };
|
||||
}
|
||||
|
||||
/** An axis-aligned rectangle in world units, as `shapeBounds` reports it. */
|
||||
export interface ShapeBounds {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The smallest world rectangle the shape fits in – what a broad phase looks at before working out
|
||||
* an exact contact (#184). A turned box measures its slant, a capsule and a circle their radius, a
|
||||
* polygon its outline (its pieces never reach beyond it).
|
||||
*/
|
||||
export function shapeBounds(shape: ColliderShape): ShapeBounds {
|
||||
const around = (x: number, y: number, hw: number, hh: number) => ({
|
||||
left: x - hw,
|
||||
top: y - hh,
|
||||
right: x + hw,
|
||||
bottom: y + hh,
|
||||
});
|
||||
switch (shape.kind) {
|
||||
case 'circle':
|
||||
return around(shape.x, shape.y, shape.r, shape.r);
|
||||
case 'box':
|
||||
return around(shape.x, shape.y, reach(shape, { x: 1, y: 0 }), reach(shape, { x: 0, y: 1 }));
|
||||
case 'capsule':
|
||||
return {
|
||||
left: Math.min(shape.a.x, shape.b.x) - shape.r,
|
||||
top: Math.min(shape.a.y, shape.b.y) - shape.r,
|
||||
right: Math.max(shape.a.x, shape.b.x) + shape.r,
|
||||
bottom: Math.max(shape.a.y, shape.b.y) + shape.r,
|
||||
};
|
||||
case 'polygon':
|
||||
return boundsOfPoints(shape.outline);
|
||||
}
|
||||
}
|
||||
|
||||
function boundsOfPoints(points: readonly Vec2[]): ShapeBounds {
|
||||
const bounds = { left: Infinity, top: Infinity, right: -Infinity, bottom: -Infinity };
|
||||
for (const p of points) {
|
||||
bounds.left = Math.min(bounds.left, p.x);
|
||||
bounds.top = Math.min(bounds.top, p.y);
|
||||
bounds.right = Math.max(bounds.right, p.x);
|
||||
bounds.bottom = Math.max(bounds.bottom, p.y);
|
||||
}
|
||||
return bounds;
|
||||
}
|
||||
|
||||
/** The rectangle grown by `pad` on every side. */
|
||||
export function grownBounds(b: ShapeBounds, pad: number): ShapeBounds {
|
||||
return { left: b.left - pad, top: b.top - pad, right: b.right + pad, bottom: b.bottom + pad };
|
||||
}
|
||||
|
||||
/** Whether two rectangles share any area or edge – touching counts, so nothing near is dropped. */
|
||||
export function boundsMeet(a: ShapeBounds, b: ShapeBounds): boolean {
|
||||
return a.left <= b.right && b.left <= a.right && a.top <= b.bottom && b.top <= a.bottom;
|
||||
}
|
||||
|
||||
/** How deep two shapes overlap by the measure `shapesOverlap` uses: 0 when they only touch. */
|
||||
export function overlapDepth(separation: number): number {
|
||||
return separation < -EPSILON ? -separation : 0;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { World } from '../World';
|
||||
import type { Vec2 } from '../../core/model/Properties';
|
||||
import {
|
||||
bodyOf,
|
||||
boundsAround,
|
||||
type Contact,
|
||||
type CollisionPair,
|
||||
type CollisionSource,
|
||||
@@ -67,8 +68,12 @@ export class CollisionSystem implements System, CollisionSource {
|
||||
|
||||
/** Live test of two bodies (world positions applied): any shape of one over any of the other. */
|
||||
private overlaps(world: World, a: Entity, b: Entity): boolean {
|
||||
const others = this.solids.shapesOf(world, b);
|
||||
return this.solids.shapesOf(world, a).some((sa) => others.some((sb) => shapesOverlap(sa, sb)));
|
||||
// A map takes part with a shape per cell, so only the cells within reach of the other side are
|
||||
// worked out at all (#184); between two maps neither narrows the other down.
|
||||
if (a.type === 'tilemap' && b.type !== 'tilemap') return this.overlaps(world, b, a);
|
||||
const mine = this.solids.shapesOf(world, a);
|
||||
const others = this.solids.shapesOf(world, b, a.type === 'tilemap' ? undefined : boundsAround(mine));
|
||||
return mine.some((sa) => others.some((sb) => shapesOverlap(sa, sb)));
|
||||
}
|
||||
|
||||
/** Whether any collider of `a` overlaps any collider of `b` (see `partsOf`). */
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
cellPolygonShape,
|
||||
type CellRect,
|
||||
cellRectShape,
|
||||
cellsMeet,
|
||||
cellsWithin,
|
||||
oneWayAngles,
|
||||
solidPolygons,
|
||||
solidRects,
|
||||
@@ -20,10 +22,12 @@ import {
|
||||
maskHas,
|
||||
} from '../../core/model/collisionLayers';
|
||||
import {
|
||||
boundsMeet,
|
||||
type ColliderShape,
|
||||
colliderShape,
|
||||
contactBetween,
|
||||
exitDistance,
|
||||
grownBounds,
|
||||
isTrigger,
|
||||
landsOn,
|
||||
movedShape,
|
||||
@@ -31,6 +35,8 @@ import {
|
||||
oneWayFacing,
|
||||
type OneWaySide,
|
||||
overlapDepth,
|
||||
shapeBounds,
|
||||
type ShapeBounds,
|
||||
shapeExtent,
|
||||
shapesOverlap,
|
||||
} from '../../core/properties/collider';
|
||||
@@ -47,6 +53,8 @@ const MAX_PASSES = 4;
|
||||
const DEEPER = 1e-9;
|
||||
/** A normal this close to square with an axis does not stand against a move along it. */
|
||||
const SQUARE = 1e-6;
|
||||
/** What the broad phase adds to the reach of a move, so rounding never drops a shape (#184). */
|
||||
const REACH_SLACK = 1e-6;
|
||||
|
||||
/**
|
||||
* Two bodies that overlap (or overlapped) – always the same pair order per key. Either side is a
|
||||
@@ -217,15 +225,23 @@ export class Solids {
|
||||
* top (#155) within their tile's angle (#159). A tilemap placed before the first tick is worked out
|
||||
* on the spot.
|
||||
*/
|
||||
solidShapesOf(world: World, body: Entity): SolidShape[] {
|
||||
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);
|
||||
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.map((rect) => ({ shape: cellRectShape(body.props, rect, m), facing })),
|
||||
...outlines.map((polygon) => ({ shape: cellPolygonShape(body.props, polygon, m), facing })),
|
||||
...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),
|
||||
@@ -235,8 +251,8 @@ export class Solids {
|
||||
];
|
||||
}
|
||||
|
||||
shapesOf(world: World, body: Entity): ColliderShape[] {
|
||||
return this.solidShapesOf(world, body).map(({ shape }) => shape);
|
||||
shapesOf(world: World, body: Entity, within?: ShapeBounds): ColliderShape[] {
|
||||
return this.solidShapesOf(world, body, within).map(({ shape }) => shape);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -247,9 +263,10 @@ export class Solids {
|
||||
raycast(world: World, from: Vec2, direction: Vec2, length: number, filter: RayFilter): RayResult | null {
|
||||
const d = unitOf(direction);
|
||||
if ((d.x === 0 && d.y === 0) || !(length > 0)) return null;
|
||||
const area = rayBounds(from, d, length);
|
||||
let best: RayResult | null = null;
|
||||
for (const body of this.rayBodies(world, filter)) {
|
||||
for (const { shape, facing } of this.solidShapesOf(world, body)) {
|
||||
for (const { shape, facing } of this.solidShapesOf(world, body, area)) {
|
||||
const hit = rayCast(shape, from, d, best?.distance ?? length);
|
||||
if (!hit || (facing && !landsOn(hit.normal, facing))) continue;
|
||||
if (best && hit.distance >= best.distance) continue;
|
||||
@@ -275,13 +292,14 @@ export class Solids {
|
||||
): RayResult[] {
|
||||
const d = unitOf(direction);
|
||||
if ((d.x === 0 && d.y === 0) || !(length > 0)) return [];
|
||||
const area = rayBounds(from, d, length);
|
||||
const hits: RayResult[] = [];
|
||||
for (const body of this.rayBodies(world, filter)) {
|
||||
// One solid shape is one hit, except for a collider: its pieces are one shape to the world,
|
||||
// so only the nearest of them counts (a polygon in three parts is not three enemies).
|
||||
const perShape = body.type === 'tilemap';
|
||||
let nearest: RayResult | null = null;
|
||||
for (const { shape, facing } of this.solidShapesOf(world, body)) {
|
||||
for (const { shape, facing } of this.solidShapesOf(world, body, area)) {
|
||||
const hit = rayCast(shape, from, d, length);
|
||||
if (!hit || (facing && !landsOn(hit.normal, facing))) continue;
|
||||
const result = this.rayResult(body, from, d, hit);
|
||||
@@ -341,12 +359,21 @@ export class Solids {
|
||||
entity.y += dy;
|
||||
return NOT_BLOCKED;
|
||||
}
|
||||
// Nothing that blocks moves while this one does, so its shapes are taken once for the whole move.
|
||||
// Nothing that blocks moves while this one does, so its shapes are taken once for the whole
|
||||
// move. Only what the move can reach is kept: sliding never lengthens what is left, so the
|
||||
// mover never gets farther from where it starts than the whole move is long (#184).
|
||||
const reach = own.map((a) =>
|
||||
grownBounds(shapeBounds(shapeOf(a.collider)), Math.hypot(wanted.x, wanted.y) + REACH_SLACK),
|
||||
);
|
||||
const area = reach.reduce(unionBounds);
|
||||
const obstacles: Obstacle[] = world.entities
|
||||
.filter((c) => blocksMover(c, entity))
|
||||
.flatMap((body) => {
|
||||
const filter = collisionFilterOf(body.props);
|
||||
return this.solidShapesOf(world, body).map((solid) => ({ ...solid, body, filter }));
|
||||
if (!own.some((a) => layersMeet(a.filter, filter))) return [];
|
||||
return this.solidShapesOf(world, body, area)
|
||||
.filter((solid) => reach.some((near) => boundsMeet(near, shapeBounds(solid.shape))))
|
||||
.map((solid) => ({ ...solid, body, filter }));
|
||||
});
|
||||
// A one-way solid the mover is inside already, or told to fall through, lets it pass for the
|
||||
// whole move: it is on its way through, from below or from the side.
|
||||
@@ -491,6 +518,35 @@ function frictionBetween(own: Entity, obstacle: Entity): number {
|
||||
return (of(own) + of(obstacle)) / 2;
|
||||
}
|
||||
|
||||
/** The rectangle around every shape, or null without one – nothing to narrow anything down with. */
|
||||
export function boundsAround(shapes: readonly ColliderShape[]): ShapeBounds | undefined {
|
||||
return shapes.length > 0 ? shapes.map(shapeBounds).reduce(unionBounds) : undefined;
|
||||
}
|
||||
|
||||
/** The rectangle around both rectangles. */
|
||||
function unionBounds(a: ShapeBounds, b: ShapeBounds): ShapeBounds {
|
||||
return {
|
||||
left: Math.min(a.left, b.left),
|
||||
top: Math.min(a.top, b.top),
|
||||
right: Math.max(a.right, b.right),
|
||||
bottom: Math.max(a.bottom, b.bottom),
|
||||
};
|
||||
}
|
||||
|
||||
/** The rectangle around a ray, so a map only works out the cells it runs through (#184). */
|
||||
function rayBounds(from: Vec2, d: Vec2, length: number): ShapeBounds {
|
||||
const to = { x: from.x + d.x * length, y: from.y + d.y * length };
|
||||
return grownBounds(
|
||||
{
|
||||
left: Math.min(from.x, to.x),
|
||||
top: Math.min(from.y, to.y),
|
||||
right: Math.max(from.x, to.x),
|
||||
bottom: Math.max(from.y, to.y),
|
||||
},
|
||||
REACH_SLACK,
|
||||
);
|
||||
}
|
||||
|
||||
function unitOf(v: Vec2): Vec2 {
|
||||
const length = Math.hypot(v.x, v.y);
|
||||
return length > 0 ? { x: v.x / length, y: v.y / length } : v;
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||||
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
|
||||
import { AddFrameCommand, SetFrameCollisionCommand } from '../../src/core/commands/frameCommands';
|
||||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import { AddTilePictureCommand } from '../../src/core/commands/tileCommands';
|
||||
import { PaintTilesCommand } from '../../src/core/commands/tilemapCommands';
|
||||
import type { TextureAsset, TilemapAsset } from '../../src/core/model/Asset';
|
||||
import { IDENTITY, transformMatrix } from '../../src/core/model/matrix';
|
||||
import type { PropertyValue, Vec2 } from '../../src/core/model/Properties';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { TILE_SOLID_KEY } from '../../src/core/model/tile';
|
||||
import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../../src/core/model/tilemap';
|
||||
import { cellsMeet, cellsWithin } from '../../src/core/model/tilemapCollision';
|
||||
import { boundsMeet, grownBounds, shapeBounds } from '../../src/core/properties/collider';
|
||||
import type { Entity } from '../../src/runtime/Entity';
|
||||
import { loadPhysics } from '../../src/runtime/physics/engine';
|
||||
import { Solids } from '../../src/runtime/systems/solids';
|
||||
import { COLLISION_SYSTEMS, makeSpriteScene, place, startWorld } from '../helpers';
|
||||
|
||||
beforeAll(async () => {
|
||||
await loadPhysics();
|
||||
});
|
||||
|
||||
/** A jagged ring inside a 32 px cell, like the lake outlines of #184. */
|
||||
function jagged(corners: number, r: number): Vec2[] {
|
||||
return Array.from({ length: corners }, (_, i) => {
|
||||
const a = (i / corners) * Math.PI * 2;
|
||||
const rad = r * (i % 2 === 0 ? 1 : 0.7);
|
||||
return { x: 16 + Math.cos(a) * rad, y: 16 + Math.sin(a) * rad };
|
||||
});
|
||||
}
|
||||
|
||||
function scene() {
|
||||
const f = makeSpriteScene('Thing');
|
||||
const add = (at: Vec2, collider: Record<string, PropertyValue>) => {
|
||||
const id = place(f, f.spriteId, at);
|
||||
const command = new AddColliderCommand(f.sceneId, id);
|
||||
f.stack.execute(command);
|
||||
for (const [key, value] of Object.entries(collider))
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, command.objectId, key, value));
|
||||
return { id, collider: command.objectId };
|
||||
};
|
||||
return { ...f, add };
|
||||
}
|
||||
|
||||
/**
|
||||
* A map of `cells` × `cells` cells of 32 px, every one painted with a tile whose picture carries an
|
||||
* eight-cornered outline – the shape of #184. The map sits at the origin, so it spans
|
||||
* −16·`cells` … 16·`cells` on both axes.
|
||||
*/
|
||||
function outlinedMap(cells: number, angle = 0, only?: Vec2) {
|
||||
const f = scene();
|
||||
const texture = new CreateAssetCommand('texture', 'Sheet');
|
||||
const tile = new CreateAssetCommand('tile', 'Lake');
|
||||
const map = new CreateAssetCommand('tilemap', 'Ground');
|
||||
for (const command of [texture, tile, map]) f.stack.execute(command);
|
||||
const sheet = findAsset(f.project, texture.asset.id) as TextureAsset;
|
||||
sheet.file = `${'a'.repeat(64)}.png`;
|
||||
sheet.width = 32;
|
||||
sheet.height = 32;
|
||||
const frame = new AddFrameCommand(texture.asset.id, { x: 0, y: 0, w: 32, h: 32 });
|
||||
f.stack.execute(frame);
|
||||
f.stack.execute(new AddTilePictureCommand(tile.asset.id, frame.frameId));
|
||||
f.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_SOLID_KEY, true));
|
||||
f.stack.execute(new SetFrameCollisionCommand(texture.asset.id, frame.frameId, [jagged(8, 15)]));
|
||||
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, cells));
|
||||
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, cells));
|
||||
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_TILE_KEY, 32));
|
||||
const mapId = place(f, map.asset.id, { x: 0, y: 0 });
|
||||
if (angle !== 0) f.stack.execute(new SetOverrideCommand(f.sceneId, mapId, 'rotation', angle));
|
||||
const asset = findAsset(f.project, map.asset.id) as TilemapAsset;
|
||||
const painted: { x: number; y: number; tileId: string }[] = only
|
||||
? [{ x: only.x, y: only.y, tileId: tile.asset.id }]
|
||||
: [];
|
||||
if (!only)
|
||||
for (let x = 0; x < cells; x++)
|
||||
for (let y = 0; y < cells; y++) painted.push({ x, y, tileId: tile.asset.id });
|
||||
f.stack.execute(new PaintTilesCommand(map.asset.id, asset.layers[0]!.id, painted));
|
||||
return { ...f, mapId };
|
||||
}
|
||||
|
||||
describe('the broad phase of a move and a ray (#184)', () => {
|
||||
it('a rectangle around a shape holds every corner of it, turned as it is', () => {
|
||||
const square = shapeBounds({ kind: 'box', x: 0, y: 0, w: 10, h: 10, angle: Math.PI / 4 });
|
||||
expect(square.right).toBeCloseTo(Math.hypot(5, 5), 9);
|
||||
expect(square.left).toBeCloseTo(-Math.hypot(5, 5), 9);
|
||||
expect(shapeBounds({ kind: 'circle', x: 3, y: -2, r: 4 })).toEqual({
|
||||
left: -1,
|
||||
top: -6,
|
||||
right: 7,
|
||||
bottom: 2,
|
||||
});
|
||||
const capsule = shapeBounds({
|
||||
kind: 'capsule',
|
||||
x: 0,
|
||||
y: 0,
|
||||
angle: 0,
|
||||
a: { x: 0, y: -5 },
|
||||
b: { x: 0, y: 5 },
|
||||
r: 2,
|
||||
});
|
||||
expect(capsule).toEqual({ left: -2, top: -7, right: 2, bottom: 7 });
|
||||
const polygon = shapeBounds({
|
||||
kind: 'polygon',
|
||||
x: 0,
|
||||
y: 0,
|
||||
angle: 0,
|
||||
outline: [
|
||||
{ x: -3, y: 0 },
|
||||
{ x: 4, y: 1 },
|
||||
{ x: 0, y: 9 },
|
||||
],
|
||||
pieces: [],
|
||||
});
|
||||
expect(polygon).toEqual({ left: -3, top: 0, right: 4, bottom: 9 });
|
||||
});
|
||||
|
||||
it('two rectangles that share an edge still count as meeting, so nothing near is dropped', () => {
|
||||
const a = { left: 0, top: 0, right: 10, bottom: 10 };
|
||||
expect(boundsMeet(a, { left: 10, top: 10, right: 20, bottom: 20 })).toBe(true);
|
||||
expect(boundsMeet(a, { left: 10.001, top: 0, right: 20, bottom: 10 })).toBe(false);
|
||||
expect(boundsMeet(a, grownBounds({ left: 12, top: 0, right: 20, bottom: 10 }, 2))).toBe(true);
|
||||
});
|
||||
|
||||
it('the cells of a map within a rectangle are read in cells, a turned map through its own axes', () => {
|
||||
// A 4 × 4 map of 32 px cells at the origin spans −64 … 64; cell (0,0) is its top left.
|
||||
const props = { [TILEMAP_COLUMNS_KEY]: 4, [TILEMAP_ROWS_KEY]: 4, [TILEMAP_TILE_KEY]: 32 };
|
||||
const straight = cellsWithin(props, IDENTITY, { left: -64, top: -64, right: -32, bottom: -32 });
|
||||
expect(straight).toEqual({ left: 0, top: 0, right: 1, bottom: 1 });
|
||||
expect(cellsMeet(straight, 0, 0)).toBe(true);
|
||||
expect(cellsMeet(straight, 2, 2)).toBe(false);
|
||||
// Turned by 90°, the world rectangle of the top left corner covers the map's top right one.
|
||||
const turned = cellsWithin(props, transformMatrix(0, 0, 90, 1, false, false), {
|
||||
left: -64,
|
||||
top: -64,
|
||||
right: -32,
|
||||
bottom: -32,
|
||||
});
|
||||
expect(turned.left).toBeCloseTo(0, 9);
|
||||
expect(turned.right).toBeCloseTo(1, 9);
|
||||
expect(turned.top).toBeCloseTo(3, 9);
|
||||
expect(turned.bottom).toBeCloseTo(4, 9);
|
||||
});
|
||||
|
||||
it('a map hands out only the cells of the rectangle it is asked about, and all of them without one', () => {
|
||||
const f = outlinedMap(20);
|
||||
const { world } = startWorld(f);
|
||||
const map = world.findByObjectId(f.mapId)!;
|
||||
const solids = new Solids();
|
||||
expect(solids.solidShapesOf(world, map)).toHaveLength(400);
|
||||
// One cell of the map, grown by a hair: its own shape and those of the cells it touches.
|
||||
const near = solids.solidShapesOf(world, map, { left: -320, top: -320, right: -300, bottom: -300 });
|
||||
expect(near.length).toBeGreaterThan(0);
|
||||
expect(near.length).toBeLessThan(10);
|
||||
expect(solids.solidShapesOf(world, map, { left: 1e6, top: 1e6, right: 2e6, bottom: 2e6 })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(COLLISION_SYSTEMS)('$system: a move sees everything it can reach (#184)', ({ physics }) => {
|
||||
const mover = (f: ReturnType<typeof outlinedMap>, at: Vec2) =>
|
||||
f.add(at, { shape: 'box', width: 16, height: 16 });
|
||||
|
||||
it('a long move into a map far away still stops at its first cell, however far it starts', () => {
|
||||
const f = outlinedMap(20);
|
||||
const m = mover(f, { x: -1000, y: 0 });
|
||||
const started = startWorld(f, { physics, collisions: true });
|
||||
const e: Entity = started.world.findByObjectId(m.id)!;
|
||||
// The map spans −320 … 320, so the move of 800 units has to be stopped well before its end.
|
||||
const result = started.collisions.moveAndSlide(started.world, e, 800, 0);
|
||||
expect(result.blockedX).toBe(true);
|
||||
expect(e.x).toBeGreaterThan(-400);
|
||||
expect(e.x).toBeLessThan(-300);
|
||||
});
|
||||
|
||||
it('a turned map blocks where its one painted cell has turned to, not where it lies in the asset', () => {
|
||||
// Turned by 90°, a point of the map lands at (−y, x): the cell at the left middle of the asset
|
||||
// (own units −304, −16) sits at the top middle of the world, so a move down runs into it.
|
||||
const f = outlinedMap(20, 90, { x: 0, y: 9 });
|
||||
const m = mover(f, { x: 16, y: -1000 });
|
||||
const started = startWorld(f, { physics, collisions: true });
|
||||
const e: Entity = started.world.findByObjectId(m.id)!;
|
||||
const result = started.collisions.moveAndSlide(started.world, e, 0, 800);
|
||||
expect(result.blockedY).toBe(true);
|
||||
expect(e.y).toBeLessThan(-300);
|
||||
expect(e.y).toBeGreaterThan(-340);
|
||||
});
|
||||
|
||||
it('a ray across the whole map still meets its first cell', () => {
|
||||
const f = outlinedMap(20);
|
||||
const started = startWorld(f, { physics, collisions: true });
|
||||
const hit = started.collisions.raycast(started.world, { x: -1000, y: -16 }, { x: 1, y: 0 }, 2000, {
|
||||
ignore: null,
|
||||
triggers: false,
|
||||
layers: 0xffff,
|
||||
});
|
||||
expect(hit).not.toBeNull();
|
||||
expect(hit!.distance).toBeGreaterThan(600);
|
||||
expect(hit!.distance).toBeLessThan(700);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the editor run over a big map (#184)', () => {
|
||||
it('a collider on a far cell of a big map still counts as touching it', () => {
|
||||
// The only painted cell is the bottom right one: its middle sits at (304, 304).
|
||||
const f = outlinedMap(20, 0, { x: 19, y: 19 });
|
||||
const on = f.add({ x: 304, y: 304 }, { shape: 'box', width: 16, height: 16 });
|
||||
const off = f.add({ x: 304, y: 100 }, { shape: 'box', width: 16, height: 16 });
|
||||
const started = startWorld(f, { collisions: true });
|
||||
const map = started.world.findByObjectId(f.mapId)!;
|
||||
expect(started.collisions.touching(started.world, started.world.findByObjectId(on.id)!, map)).toBe(true);
|
||||
expect(started.collisions.touching(started.world, started.world.findByObjectId(off.id)!, map)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user