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
+725 -79

No files matched your search

+1 -1
View File
@@ -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: The browser suite and the three example games need a machine with a GPU:
```bash ```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 # every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+46
View File
@@ -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<TextureFrame, { from: TextureFrame['collision']; outline: FrameOutline }>();
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;
}
+3
View File
@@ -26,6 +26,9 @@ export const TILE_ONE_WAY_KEY = 'oneWay';
export const TILE_ONE_WAY_ANGLE_KEY = 'oneWayAngle'; 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). */ /** How big the blocking box of a cell is against the cell, 1 = the whole cell (#140). */
export const TILE_COLLISION_SCALE_KEY = 'collisionScale'; 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_MIN = 0.1;
export const TILE_COLLISION_SCALE_MAX = 2; export const TILE_COLLISION_SCALE_MAX = 2;
+52 -6
View File
@@ -20,7 +20,8 @@
* *
* One-way tiles (#155) block only from above. They are worked out on their own, by the same rules – * 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 * 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 type { TileAsset, TilemapAsset } from './Asset';
import { tileQuadrants } from './autotile'; import { tileQuadrants } from './autotile';
@@ -40,6 +41,7 @@ import {
} from './tile'; } from './tile';
import { cellOrigin, inGrid, neighboursOf, parseCellKey, tilemapSize, tilemapTileSize } from './tilemap'; import { cellOrigin, inGrid, neighboursOf, parseCellKey, tilemapSize, tilemapTileSize } from './tilemap';
import { type ColliderShape, oneWayAngle, type ShapeBounds } from '../properties/collider'; 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. */ /** A rectangle in cells: top-left corner and size. Whole cells at collision scale 1, parts of one otherwise. */
export interface CellRect { export interface CellRect {
@@ -192,11 +194,51 @@ function tileOneWayAngle(tile: TileAsset): number {
return oneWayAngle(tile.props[TILE_ONE_WAY_ANGLE_KEY]); 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; if (!isTileOneWay(tile)) return oneWay === false;
return oneWay === true || tileOneWayAngle(tile) === oneWay; 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<string>();
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. */ /** The angles of the solid one-way tiles painted on the map, each once, smallest first. */
export function oneWayAngles(project: Project, asset: TilemapAsset): number[] { export function oneWayAngles(project: Project, asset: TilemapAsset): number[] {
const angles = new Set<number>(); const angles = new Set<number>();
@@ -216,13 +258,14 @@ function blockingCells(
props: PropertyBag, props: PropertyBag,
asset: TilemapAsset, asset: TilemapAsset,
oneWay: OneWayFilter, oneWay: OneWayFilter,
material?: TileMaterial,
): CellBlock[] { ): CellBlock[] {
const tiles = new Map<string, { scale: number; shape: TileShape | null } | null>(); const tiles = new Map<string, { scale: number; shape: TileShape | null } | null>();
const tileOf = (id: string) => { const tileOf = (id: string) => {
if (tiles.has(id)) return tiles.get(id)!; if (tiles.has(id)) return tiles.get(id)!;
const tile = findAssetOf(project, id, 'tile'); const tile = findAssetOf(project, id, 'tile');
const value = const value =
tile && isTileSolid(tile) && passesFilter(tile, oneWay) tile && isTileSolid(tile) && passesFilter(tile, oneWay, material)
? { scale: tileCollisionScale(tile), shape: tileShape(project, tile) } ? { scale: tileCollisionScale(tile), shape: tileShape(project, tile) }
: null; : null;
tiles.set(id, value); tiles.set(id, value);
@@ -279,8 +322,9 @@ export function solidCells(
props: PropertyBag, props: PropertyBag,
asset: TilemapAsset, asset: TilemapAsset,
oneWay: OneWayFilter = false, oneWay: OneWayFilter = false,
material?: TileMaterial,
): Required<SolidCell>[] { ): Required<SolidCell>[] {
return blockingCells(project, props, asset, oneWay) return blockingCells(project, props, asset, oneWay, material)
.filter((cell) => cell.box !== null) .filter((cell) => cell.box !== null)
.map(({ x, y, box }) => ({ x, y, scale: box! })); .map(({ x, y, box }) => ({ x, y, scale: box! }));
} }
@@ -296,9 +340,10 @@ export function solidPolygons(
props: PropertyBag, props: PropertyBag,
asset: TilemapAsset, asset: TilemapAsset,
oneWay: OneWayFilter = false, oneWay: OneWayFilter = false,
material?: TileMaterial,
): CellPolygon[] { ): CellPolygon[] {
const polygons: 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; if (cell.box !== null) continue;
for (const { outline, pieces } of cell.polygons) polygons.push({ x: cell.x, y: cell.y, outline, pieces }); 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, props: PropertyBag,
asset: TilemapAsset, asset: TilemapAsset,
oneWay: OneWayFilter = false, oneWay: OneWayFilter = false,
material?: TileMaterial,
): CellRect[] { ): CellRect[] {
return mergeCells(solidCells(project, props, asset, oneWay)); return mergeCells(solidCells(project, props, asset, oneWay, material));
} }
/** /**
+29 -2
View File
@@ -22,10 +22,12 @@ import {
import { findAssetOf, type Project } from '../model/Project'; import { findAssetOf, type Project } from '../model/Project';
import { ALL_LAYERS, COLLIDES_WITH_KEY, COLLISION_LAYER_KEY } from '../model/collisionLayers'; import { ALL_LAYERS, COLLIDES_WITH_KEY, COLLISION_LAYER_KEY } from '../model/collisionLayers';
import { import {
TILE_BOUNCINESS_KEY,
TILE_COLLISION_SCALE_KEY, TILE_COLLISION_SCALE_KEY,
TILE_COLLISION_SCALE_MAX, TILE_COLLISION_SCALE_MAX,
TILE_COLLISION_SCALE_MIN, TILE_COLLISION_SCALE_MIN,
TILE_FPS_KEY, TILE_FPS_KEY,
TILE_FRICTION_KEY,
TILE_KIND_KEY, TILE_KIND_KEY,
TILE_ONE_WAY_ANGLE_KEY, TILE_ONE_WAY_ANGLE_KEY,
TILE_ONE_WAY_KEY, TILE_ONE_WAY_KEY,
@@ -105,6 +107,25 @@ const ONE_WAY_ANGLE_ROW: FlatDef<'number'> = {
shownWith: 'oneWay', 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 * 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 * 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<AssetType, PropertySchema> = {
// A solid tile that only blocks from above, like a platform one jumps through (#155). // 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 }, { 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 }, { ...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 // 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). // blocking neighbour, so a wall of such cells stays closed (#140).
{ {
@@ -433,6 +457,7 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
{ value: 'circle', label: 'Circle' }, { value: 'circle', label: 'Circle' },
{ value: 'capsule', label: 'Capsule' }, { value: 'capsule', label: 'Capsule' },
{ value: 'polygon', label: 'Polygon' }, { 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. // A box and a capsule fill width × height; the capsule is rounded along the longer side.
@@ -450,10 +475,12 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
{ x: -50, y: 50 }, { 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 }, { key: 'isTrigger', label: 'Trigger (no blocking)', kind: 'boolean', default: false },
// The material, read by the physics engine where a rigidbody takes part (#146). // 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 }, FRICTION_ROW,
{ key: 'bounciness', label: 'Bounciness', kind: 'number', default: 0, min: 0, max: 1, step: 0.05 }, BOUNCINESS_ROW,
{ key: 'density', label: 'Density', kind: 'number', default: 1, min: 0, step: 0.1 }, { 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). // 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 }, { key: 'oneWay', label: 'One-way', kind: 'boolean', default: false },
+26 -7
View File
@@ -4,6 +4,7 @@
* where its corners are. * where its corners are.
*/ */
import { type AssetType, hasSceneIcon } from '../model/AssetTypes'; import { type AssetType, hasSceneIcon } from '../model/AssetTypes';
import type { FrameOutline } from '../model/frameOutline';
import { import {
apply, apply,
boxCorners, boxCorners,
@@ -49,15 +50,22 @@ export function textAnchor(props: PropertyBag): number {
return align === 'left' ? 0 : align === 'right' ? 1 : 0.5; 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) { switch (type) {
case 'sprite': case 'sprite':
return { w: Number(props.width ?? 1), h: Number(props.height ?? 1) }; return { w: Number(props.width ?? 1), h: Number(props.height ?? 1) };
case 'text': case 'text':
return textSize(props); return textSize(props);
case 'collider': case 'collider':
return colliderSize(props); return colliderSize(props, frame);
case 'behavior': case 'behavior':
return { w: BEHAVIOR_SYMBOL, h: BEHAVIOR_SYMBOL }; return { w: BEHAVIOR_SYMBOL, h: BEHAVIOR_SYMBOL };
case 'container': 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. */ /** 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 placed = placementOf(type, m);
const { w, h } = footprintOf(type, props); const { w, h } = footprintOf(type, props, frame);
const local = pad / (scaleOf(placed) || 1); const local = pad / (scaleOf(placed) || 1);
return boxCorners(placed, w + 2 * local, h + 2 * local, anchorOffset(type, props, w), 0); 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. */ /** The world rectangle around the drawn footprint – what culling, framing and labels need. */
export function footprintExtent(type: AssetType, props: PropertyBag, m: Mat): Extent { export function footprintExtent(
return extentOfPoints(footprintCorners(type, props, m)); 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 { export function footprintContains(type: AssetType, props: PropertyBag, m: Mat, point: Vec2): boolean {
+69 -7
View File
@@ -3,8 +3,10 @@
* "overlaps" means. A box is oriented: it turns and grows with the body it sits on. A capsule and a * "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. * 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 { angleOf, apply, type Mat, scaleOf } from '../model/matrix';
import { convexPieces, pointsOf, signedArea } from '../model/polygon'; 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 { type PropertyBag, type PropertyValue, textOf, type Vec2 } from '../model/Properties';
import { dot } from '../util/vec'; import { dot } from '../util/vec';
import { import {
@@ -28,15 +30,55 @@ export type ColliderShape =
| { kind: 'circle'; x: number; y: number; r: number } | { kind: 'circle'; x: number; y: number; r: number }
/** The segment `a`–`b` grown by `r`. */ /** The segment `a`–`b` grown by `r`. */
| { kind: 'capsule'; x: number; y: number; angle: number; a: Vec2; b: Vec2; r: number } | { 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 Box = Extract<ColliderShape, { kind: 'box' }>;
type Circle = Extract<ColliderShape, { kind: 'circle' }>; type Circle = Extract<ColliderShape, { kind: 'circle' }>;
type Polygon = Extract<ColliderShape, { kind: 'polygon' }>;
type Classic = Box | Circle; 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). */ /** Where a polygon collider keeps its corners (#151). */
export const COLLIDER_POINTS_KEY = 'points'; 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. */ /** A polygon's pieces in its own units, cut once per stored list rather than on every test. */
const piecesCache = new WeakMap<object, Vec2[][]>(); 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 }; 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); const scale = scaleOf(m);
if (props.shape === 'circle') if (props.shape === 'circle')
return { return {
@@ -96,6 +142,20 @@ export function colliderShape(props: PropertyBag, m: Mat): ColliderShape {
pieces: localPieces(value).map((piece) => piece.map((p) => apply(m, p))), 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) }; 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, ...centre,
angle: shape.angle + turn, angle: shape.angle + turn,
outline: shape.outline.map(move), outline: shape.outline.map(move),
...(shape.rings && { rings: shape.rings.map((ring) => ring.map(move)) }),
pieces: shape.pieces.map((piece) => piece.map(move)), pieces: shape.pieces.map((piece) => piece.map(move)),
}; };
} }
} }
/** The size in the collider's own units, before any transform. */ /** The size in the collider's own units, before any transform; a worn frame (#193) is its own size. */
export function colliderSize(props: PropertyBag): { w: number; h: number } { 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. // Centred like every footprint, so it reaches as far as the farthest corner on each axis.
if (props.shape === 'polygon') { if (props.shape === 'polygon') {
const points = colliderPoints(props); 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, bottom: Math.max(shape.a.y, shape.b.y) + shape.r,
}; };
case 'polygon': case 'polygon':
return boundsOfPoints(shape.outline); return boundsOfPoints(outlinesOf(shape).flat());
} }
} }
+1 -1
View File
@@ -34,7 +34,7 @@ export abstract class FreeViewport extends WorldViewport {
fit(): void { fit(): void {
const { items } = this.collectFrame(); const { items } = this.collectFrame();
const boxes = items.map((item) => { 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 }; 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 })!; const { minX, minY, maxX, maxY } = boundsOf(boxes, { minX: 0, minY: 0, maxX: 0, maxY: 0 })!;
+1 -1
View File
@@ -1094,7 +1094,7 @@ export class SceneViewport extends FreeViewport {
if (!item) continue; if (!item) continue;
// Two screen pixels outside the footprint, whichever way it is turned. // Two screen pixels outside the footprint, whichever way it is turned.
const corners = this.screenCorners( 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, view,
); );
// A switched-off object draws nothing, so its outline is the only way to see where it // A switched-off object draws nothing, so its outline is the only way to see where it
+3 -3
View File
@@ -191,7 +191,7 @@ export abstract class WorldViewport extends Widget {
const view = this.currentView(); const view = this.currentView();
const item = this.lastItems.find((i) => i.id === objectId); const item = this.lastItems.find((i) => i.id === objectId);
if (!item || !view) return null; 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 local = worldToScreen(view, (extent.left + extent.right) / 2, (extent.top + extent.bottom) / 2);
const origin = this.getGlobalPosition(); const origin = this.getGlobalPosition();
return { return {
@@ -229,8 +229,8 @@ export abstract class WorldViewport extends Widget {
const seen = new Set<string>(); const seen = new Set<string>();
for (const item of items) { for (const item of items) {
seen.add(item.id); seen.add(item.id);
const size = footprintOf(item.type, item.props); const size = footprintOf(item.type, item.props, item.frame);
const extent = footprintExtent(item.type, item.props, item.m); 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 // 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 // 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. // own little square: its particles may be anywhere on screen while it is not.
+6
View File
@@ -1,6 +1,7 @@
import type { ENTITY_HANDLE } from '../core/graph/NodeContext'; import type { ENTITY_HANDLE } from '../core/graph/NodeContext';
import type { AnimationPlayer } from '../core/model/animation'; import type { AnimationPlayer } from '../core/model/animation';
import type { AssetType } from '../core/model/AssetTypes'; import type { AssetType } from '../core/model/AssetTypes';
import type { FrameOutline } from '../core/model/frameOutline';
import type { PropertyBag } from '../core/model/Properties'; 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. */ /** 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[]; animations?: AnimationPlayer[];
/** How often an `Emit` node has restarted this particle source; out of `props` for the same reason. */ /** How often an `Emit` node has restarted this particle source; out of `props` for the same reason. */
emits?: number; 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 { findAsset, findScene, type Project } from '../core/model/Project';
import { createSceneObject, type SceneObject } from '../core/model/SceneObject'; import { createSceneObject, type SceneObject } from '../core/model/SceneObject';
import { footprintContains } from '../core/properties/bounds'; 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 { resolveSceneObjectProps } from '../core/properties/resolve';
import type { Entity } from './Entity'; import type { Entity } from './Entity';
import type { System } from './systems/System'; import type { System } from './systems/System';
@@ -176,6 +176,10 @@ export class World {
props: {}, props: {},
parent: null, parent: null,
}; };
if (asset.type === 'collider') {
const collider = entity;
collider.wornFrame = () => colliderFrame(this.project, collider.props);
}
this.entities.push(entity); this.entities.push(entity);
} }
entity.assetId = asset.id; entity.assetId = asset.id;
@@ -309,7 +313,9 @@ export class World {
const parts = [entity, ...this.descendantsOf(entity)].filter((e) => isHandleEnabled(e)); const parts = [entity, ...this.descendantsOf(entity)].filter((e) => isHandleEnabled(e));
const colliders = parts.filter((e) => e.type === 'collider'); const colliders = parts.filter((e) => e.type === 'collider');
if (colliders.length > 0) 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( return parts.some(
(e) => (e) =>
(e.type === 'sprite' || e.type === 'text') && (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 { signedArea } from '../../core/model/polygon';
import { parentChain } from '../../core/model/walk'; import { parentChain } from '../../core/model/walk';
import { collisionFilterOf, interactionGroups } from '../../core/model/collisionLayers'; import { collisionFilterOf, interactionGroups } from '../../core/model/collisionLayers';
import type { TileMaterial } from '../../core/model/tilemapCollision';
import { import {
type ColliderShape, type ColliderShape,
contactBetween, contactBetween,
@@ -47,6 +48,7 @@ import {
shapeOf, shapeOf,
type SolidShape, type SolidShape,
Solids, Solids,
surfaceOf,
} from '../systems/solids'; } from '../systems/solids';
import type { System } from '../systems/System'; import type { System } from '../systems/System';
import { type Rapier, toMetres, toPixels } from './engine'; 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 local = shapes.map((solid) => ({ ...solid, pieces: piecesInBody(origin, solid.shape) }));
const shapeKey = const shapeKey =
JSON.stringify( JSON.stringify(
local.map(({ entity, pieces, facing }) => [ local.map(({ entity, pieces, facing, surface }) => [
entity.id, entity.id,
pieces.map(pieceKey), pieces.map(pieceKey),
facing && [round(facing.x), round(facing.y), facing.cone], facing && [round(facing.x), round(facing.y), facing.cone],
surface ?? 0,
entity.type === 'collider' ? [isTrigger(entity.props), colliderMaterial(entity.props)] : 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). // The layers sit on the engine collider, so changing one has to rebuild it (#152).
interactionGroups(collisionFilterOf(entity.props)), interactionGroups(collisionFilterOf(entity.props)),
@@ -530,10 +533,10 @@ export class PhysicsSystem implements System, CollisionSource {
(sum, { entity, shape }) => sum + (isSolid(entity) ? shapeArea(shape) : 0), (sum, { entity, shape }) => sum + (isSolid(entity) ? shapeArea(shape) : 0),
0, 0,
); );
for (const { entity, shape, facing, pieces } of local) for (const { entity, shape, facing, surface, pieces } of local)
for (const piece of pieces) { for (const piece of pieces) {
const share = mass > 0 && area > 0 ? (mass * pieceArea(piece)) / area : 0; 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; if (!desc) continue;
const collider = this.engine!.createCollider(desc, body.body); const collider = this.engine!.createCollider(desc, body.body);
body.colliders.push(collider); 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. */ /** 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; const R = this.rapier;
if (pieceArea(piece) <= 0) return null; if (pieceArea(piece) <= 0) return null;
let desc: ColliderDesc | null; let desc: ColliderDesc | null;
@@ -597,9 +606,10 @@ export class PhysicsSystem implements System, CollisionSource {
if (!desc) return null; if (!desc) return null;
if (piece.kind !== 'convex') if (piece.kind !== 'convex')
desc.setTranslation(toMetres(piece.x), toMetres(piece.y)).setRotation(piece.angle); 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 : {}); 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. // The livelier side wins, as in Box2D: a ball that should bounce does so on plain ground too.
desc.setRestitutionCombineRule(R.CoefficientCombineRule.Max); desc.setRestitutionCombineRule(R.CoefficientCombineRule.Max);
if (!isSolid(entity)) desc.setSensor(true).setDensity(0); if (!isSolid(entity)) desc.setSensor(true).setDensity(0);
+49 -35
View File
@@ -13,6 +13,8 @@ import {
oneWayAngles, oneWayAngles,
solidPolygons, solidPolygons,
solidRects, solidRects,
type TileMaterial,
tileMaterials,
} from '../../core/model/tilemapCollision'; } from '../../core/model/tilemapCollision';
import { parentChain } from '../../core/model/walk'; import { parentChain } from '../../core/model/walk';
import { import {
@@ -106,6 +108,8 @@ export const NOT_BLOCKED: MoveResult = Object.freeze({
export interface SolidShape { export interface SolidShape {
shape: ColliderShape; shape: ColliderShape;
facing: OneWaySide | null; 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. */ /** 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. */ /** Where a sweep stopped: what it met, with which own collider, the normal, and the move left over. */
interface SweepContact { interface SweepContact {
by: Entity; by: Entity;
/** The surface of the shape met when it is a tile's (#193). */
surface?: TileMaterial;
own: Entity; own: Entity;
normal: Vec2; normal: Vec2;
left: Vec2; left: Vec2;
@@ -203,17 +209,15 @@ export class Solids {
const asset = findAssetOf(world.project, tilemap.assetId, 'tilemap'); const asset = findAssetOf(world.project, tilemap.assetId, 'tilemap');
const { project } = world; const { project } = world;
const { props } = tilemap; const { props } = tilemap;
blocks = asset // One group per side and surface, so cells only merge with cells that block alike (#159, #193).
? { const groups = (angle: number | null) =>
rects: solidRects(project, props, asset), tileMaterials(project, asset!, angle ?? false).map((material) => ({
polygons: solidPolygons(project, props, asset),
oneWay: oneWayAngles(project, asset).map((angle) => ({
angle, angle,
rects: solidRects(project, props, asset, angle), material,
polygons: solidPolygons(project, props, asset, angle), rects: solidRects(project, props, asset!, angle ?? false, material),
})), polygons: solidPolygons(project, props, asset!, angle ?? false, material),
} }));
: { rects: [], polygons: [], oneWay: [] }; blocks = asset ? [...groups(null), ...oneWayAngles(project, asset).flatMap(groups)] : [];
this.blocks.set(tilemap, blocks); this.blocks.set(tilemap, blocks);
} }
return blocks; return blocks;
@@ -228,27 +232,25 @@ export class Solids {
solidShapesOf(world: World, body: Entity, within?: ShapeBounds): SolidShape[] { solidShapesOf(world: World, body: Entity, within?: ShapeBounds): SolidShape[] {
const m = worldMatrixOf(body); const m = worldMatrixOf(body);
if (body.type !== 'tilemap') if (body.type !== 'tilemap')
return [{ shape: colliderShape(body.props, m), facing: oneWayFacing(body.props, m) }]; return [
const { rects, polygons, oneWay } = this.blocksOf(world, body); { shape: colliderShape(body.props, m, body.wornFrame?.()), facing: oneWayFacing(body.props, m) },
];
const up = oneWayFacing({ oneWay: true }, 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 // 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). // them is worked out in world units at all (#184).
const range = within ? cellsWithin(body.props, m, within) : null; 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 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) => [ return this.blocksOf(world, body).flatMap(({ angle, material: surface, rects, polygons }) => {
...cells const facing = angle === null ? null : up && { ...up, cone: oneWayCone(angle) };
.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 [ return [
...shapes(rects, polygons, null), ...rects
...oneWay.flatMap((group) => .filter((rect) => near(rect.x, rect.y, rect.w, rect.h))
shapes(group.rects, group.polygons, up && { ...up, cone: oneWayCone(group.angle) }), .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[] { shapesOf(world: World, body: Entity, within?: ShapeBounds): ColliderShape[] {
@@ -391,7 +393,11 @@ export class Solids {
const contact = sweep(entity, frame, own, obstacles, passing, rest); const contact = sweep(entity, frame, own, obstacles, passing, rest);
if (!contact) break; if (!contact) break;
contacts.push(contact); 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; if (contacts.length === 0) return NOT_BLOCKED;
const pushed = new Set<Entity>(); const pushed = new Set<Entity>();
@@ -486,6 +492,7 @@ function sweep(
const left = distance - i * step - (step - correction); const left = distance - i * step - (step - correction);
return { return {
by: obstacle.body, by: obstacle.body,
surface: obstacle.surface,
own: collider, own: collider,
normal: contactBetween(shapeOf(collider), obstacle.shape, forward).normal, normal: contactBetween(shapeOf(collider), obstacle.shape, forward).normal,
left: { x: forward.x * left, y: forward.y * left }, 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 * 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 * object moved by the graph slides as far as one the engine moves. A tile brings its own (#193).
* own and has what a new collider starts with.
*/ */
function frictionBetween(own: Entity, obstacle: Entity): number { function frictionBetween(own: Entity, obstacle: Entity, surface?: TileMaterial): number {
const of = (e: Entity) => colliderMaterial(e.type === 'collider' ? e.props : {}).friction; return (surfaceOf(own).friction + surfaceOf(obstacle, surface).friction) / 2;
return (of(own) + of(obstacle)) / 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. */ /** 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; 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[]; rects: CellRect[];
polygons: CellPolygon[]; 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 { 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. */ /** What of an entity stops at walls when it moves: its solid colliders that are switched on. */
+6
View File
@@ -17,6 +17,8 @@ import {
import { findAsset, findAssetOf, type ObjectHolder, type Project } from '../core/model/Project'; import { findAsset, findAssetOf, type ObjectHolder, type Project } from '../core/model/Project';
import { fontFileOf } from '../core/model/font'; import { fontFileOf } from '../core/model/font';
import { resolveTexture, type TextureRef } from '../core/model/texture'; 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 { enabledAlongChain } from '../core/properties/enabled';
import { fontOf } from '../core/properties/fonts'; import { fontOf } from '../core/properties/fonts';
import { resolveSceneObjectProps } from '../core/properties/resolve'; import { resolveSceneObjectProps } from '../core/properties/resolve';
@@ -49,6 +51,8 @@ export interface DrawItem {
tilemap: TilemapDraw | null; tilemap: TilemapDraw | null;
/** How often an `Emit` node has restarted this source; a change is what makes it start over. */ /** How often an `Emit` node has restarted this source; a change is what makes it start over. */
emits: number; 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 { interface DrawList {
@@ -167,6 +171,7 @@ export function collectHolderItems(
particles: asset.type === 'particles' ? asset : null, particles: asset.type === 'particles' ? asset : null,
tilemap: asset.type === 'tilemap' ? resolveTilemap(project, asset.id, props) : null, tilemap: asset.type === 'tilemap' ? resolveTilemap(project, asset.id, props) : null,
emits: 0, emits: 0,
frame: asset.type === 'collider' ? colliderFrame(project, props) : null,
}); });
} }
const byId = new Map(items.map((i) => [i.id, i])); 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, particles: e.type === 'particles' ? (findAssetOf(world.project, e.assetId, 'particles') ?? null) : null,
tilemap: e.type === 'tilemap' ? resolveTilemap(world.project, e.assetId, e.props) : null, tilemap: e.type === 'tilemap' ? resolveTilemap(world.project, e.assetId, e.props) : null,
emits: e.emits ?? 0, emits: e.emits ?? 0,
frame: e.wornFrame?.() ?? null,
})); }));
return { items, background: world.backgroundColor }; return { items, background: world.backgroundColor };
} }
+9 -5
View File
@@ -8,6 +8,7 @@ import {
isTrigger, isTrigger,
oneWayAngle, oneWayAngle,
oneWayFacing, oneWayFacing,
outlinesOf,
} from '../core/properties/collider'; } from '../core/properties/collider';
import { SHEET_EDGE } from '../core/shader/nodes'; import { SHEET_EDGE } from '../core/shader/nodes';
import type { NineSlice } from '../core/model/texture'; import type { NineSlice } from '../core/model/texture';
@@ -150,9 +151,10 @@ export function drawItem(
case 'collider': { case 'collider': {
// Only the outline band is hit-testable, so clicks inside still reach the sprite that the // Only the outline band is hit-testable, so clicks inside still reach the sprite that the
// collider sits on. // collider sits on.
const shape = colliderShape(item.props, IDENTITY); const shape = colliderShape(item.props, IDENTITY, item.frame);
traceShape(node, shape); 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 = const color =
shape.kind === 'polygon' && shape.pieces.length === 0 shape.kind === 'polygon' && shape.pieces.length === 0
? Theme.colors.danger ? 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 },
{ 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 { return {
left: Math.min(...points.map((p) => p.x)), left: Math.min(...points.map((p) => p.x)),
top: Math.min(...points.map((p) => p.y)), top: Math.min(...points.map((p) => p.y)),
@@ -280,7 +282,7 @@ function traceShape(node: Graphics, shape: ColliderShape): void {
return; return;
} }
case 'polygon': 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; return;
} }
} }
@@ -299,10 +301,12 @@ function colliderOutlineHitArea(
if (shape.kind === 'capsule') if (shape.kind === 'capsule')
return { contains: (x, y) => Math.abs(distanceToSegment({ x, y }, shape.a, shape.b) - shape.r) <= band }; return { contains: (x, y) => Math.abs(distanceToSegment({ x, y }, shape.a, shape.b) - shape.r) <= band };
if (shape.kind === 'polygon') { if (shape.kind === 'polygon') {
const ring = shape.outline; const rings = outlinesOf(shape);
return { return {
contains: (x, y) => contains: (x, y) =>
rings.some((ring) =>
ring.some((a, i) => distanceToSegment({ x, y }, a, ring[(i + 1) % ring.length]!) <= band), ring.some((a, i) => distanceToSegment({ x, y }, a, ring[(i + 1) % ring.length]!) <= band),
),
}; };
} }
const hw = shape.w / 2; const hw = shape.w / 2;
+6 -2
View File
@@ -105,8 +105,12 @@ describe('the one-way angle (#159)', () => {
const angleRow = tile.find((d) => d.key === TILE_ONE_WAY_ANGLE_KEY)!; 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]: false })).toBe(false);
expect(isRowShown(angleRow, { [TILE_ONE_WAY_KEY]: true })).toBe(true); expect(isRowShown(angleRow, { [TILE_ONE_WAY_KEY]: true })).toBe(true);
// Rows without a switch are always there. // 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]); expect(tile.filter((d) => !isRowShown(d, {})).map((d) => d.key)).toEqual([
TILE_ONE_WAY_ANGLE_KEY,
'friction',
'bounciness',
]);
}); });
}); });
+316
View File
@@ -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<string, PropertyValue>, body?: 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));
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);
});
});
+10
View File
@@ -715,6 +715,9 @@ export async function tilemap(t) {
await openTab(t, 'scene'); await openTab(t, 'scene');
await select({ kind: 'asset', assetId: 'at-grass' }); await select({ kind: 'asset', assetId: 'at-grass' });
const solidRow = await json("__gm.inspectorField('Solid')"); 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); if (solidRow) await click(solidRow.x, solidRow.y);
await settle(); await settle();
check( check(
@@ -722,6 +725,13 @@ export async function tilemap(t) {
Boolean(solidRow) && (await t.asset('at-grass'))?.props.solid === true, Boolean(solidRow) && (await t.asset('at-grass'))?.props.solid === true,
JSON.stringify({ solidRow, props: (await t.asset('at-grass'))?.props }), 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 mutate(`__gm.lookAt(0, ${LANE})`);
await settle(); await settle();
const outlined = await json(`__gm.tilemap.solids(${JSON.stringify(MAP_OBJECT)})`); const outlined = await json(`__gm.tilemap.solids(${JSON.stringify(MAP_OBJECT)})`);
+68 -1
View File
@@ -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 * 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 * (#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 * 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. */ /** 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) }), JSON.stringify({ frames: climb.length, highest, top: MAP_Y - 10, last: climb.at(-1) }),
); );
await clearShapes(); 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();
} }