The one-way tolerance angle is a field: a one-way collider and a one-way tile gain One-way angle (0 to 90 degrees, default 45), a contact blocks when its normal is within that angle of the blocking side, without a value the limit is bit-identical to before, and the rule holds for Move, Raycast and dynamic bodies in Rapier; tiles with different angles never merge into one rectangle, the scene view draws the angle as a cone in the middle of the thick side, and the inspector shows Blocks from and One-way angle only while One-way is on, through the new schema option shownWith (#159)
game-maker / gate (push) Successful in 1m55s
game-maker / suite (push) Successful in 6m50s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-19 13:13:29 +02:00
1 parent d4ea04d4d6
commit 5385d9e152
14 files changed
+454 -62

No files matched your search

+1 -1
View File
@@ -38,7 +38,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:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1074 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1075 checks (GM_STEPS=20 for one step)
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
node tools/seed-actor.mjs && node tools/smoke-actor.mjs
+2
View File
@@ -22,6 +22,8 @@ export const TILE_PLAYBACK_KEY = 'playback';
export const TILE_SOLID_KEY = 'solid';
/** Whether a solid tile only blocks from above, as a platform one jumps through from below (#155). */
export const TILE_ONE_WAY_KEY = 'oneWay';
/** How far a contact may lean from straight up and still land on a one-way tile, in degrees (#159). */
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). */
export const TILE_COLLISION_SCALE_KEY = 'collisionScale';
export const TILE_COLLISION_SCALE_MIN = 0.1;
+47 -8
View File
@@ -19,7 +19,8 @@
* quarters it is drawn from (see `tileShape`).
*
* 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.
* each function takes `oneWay` – so a platform never merges with a wall into one rectangle, nor with
* a platform of another angle (#159).
*/
import type { TileAsset, TilemapAsset } from './Asset';
import { tileQuadrants } from './autotile';
@@ -28,9 +29,17 @@ import { boundaryOf, type BoundaryLine, clipToRect, convexPieces, signedArea } f
import type { PropertyBag, Vec2 } from './Properties';
import { findAssetOf, type Project } from './Project';
import { findFrame } from './texture';
import { isTileOneWay, isTileSolid, readTile, tileCollisionScale, tileKind, type TileKind } from './tile';
import {
isTileOneWay,
isTileSolid,
readTile,
TILE_ONE_WAY_ANGLE_KEY,
tileCollisionScale,
tileKind,
type TileKind,
} from './tile';
import { cellOrigin, inGrid, neighboursOf, parseCellKey, tilemapTileSize } from './tilemap';
import type { ColliderShape } from '../properties/collider';
import { type ColliderShape, oneWayAngle } 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 {
@@ -172,18 +181,48 @@ function placed(outline: TileOutline, x: number, y: number, size: number): TileO
return { outline: outline.outline.map(place), pieces: outline.pieces.map((piece) => piece.map(place)) };
}
/**
* Which solid tiles a function looks at: false the ones blocking from everywhere, true every one-way
* tile, a number the one-way tiles of that angle (#159) – so tiles of two angles never merge.
*/
export type OneWayFilter = boolean | number;
/** The angle a one-way tile lands things within, held to 0 … 90°. */
export function tileOneWayAngle(tile: TileAsset): number {
return oneWayAngle(tile.props[TILE_ONE_WAY_ANGLE_KEY]);
}
function passesFilter(tile: TileAsset, oneWay: OneWayFilter): boolean {
if (!isTileOneWay(tile)) return oneWay === false;
return oneWay === true || tileOneWayAngle(tile) === oneWay;
}
/** The angles of the solid one-way tiles painted on the map, each once, smallest first. */
export function oneWayAngles(project: Project, asset: TilemapAsset): number[] {
const angles = new Set<number>();
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) && isTileOneWay(tile)) angles.add(tileOneWayAngle(tile));
}
return [...angles].sort((a, b) => a - b);
}
function blockingCells(
project: Project,
props: PropertyBag,
asset: TilemapAsset,
oneWay: boolean,
oneWay: OneWayFilter,
): CellBlock[] {
const tiles = new Map<string, { scale: number; shape: TileShape | null } | null>();
const tileOf = (id: string) => {
if (tiles.has(id)) return tiles.get(id)!;
const tile = findAssetOf(project, id, 'tile');
const value =
tile && isTileSolid(tile) && isTileOneWay(tile) === oneWay
tile && isTileSolid(tile) && passesFilter(tile, oneWay)
? { scale: tileCollisionScale(tile), shape: tileShape(project, tile) }
: null;
tiles.set(id, value);
@@ -239,7 +278,7 @@ export function solidCells(
project: Project,
props: PropertyBag,
asset: TilemapAsset,
oneWay = false,
oneWay: OneWayFilter = false,
): Required<SolidCell>[] {
return blockingCells(project, props, asset, oneWay)
.filter((cell) => cell.box !== null)
@@ -256,7 +295,7 @@ export function solidPolygons(
project: Project,
props: PropertyBag,
asset: TilemapAsset,
oneWay = false,
oneWay: OneWayFilter = false,
): CellPolygon[] {
const polygons: CellPolygon[] = [];
for (const cell of blockingCells(project, props, asset, oneWay)) {
@@ -370,7 +409,7 @@ export function solidRects(
project: Project,
props: PropertyBag,
asset: TilemapAsset,
oneWay = false,
oneWay: OneWayFilter = false,
): CellRect[] {
return mergeCells(solidCells(project, props, asset, oneWay));
}
+31 -2
View File
@@ -26,6 +26,7 @@ import {
TILE_COLLISION_SCALE_MIN,
TILE_FPS_KEY,
TILE_KIND_KEY,
TILE_ONE_WAY_ANGLE_KEY,
TILE_ONE_WAY_KEY,
TILE_PLAYBACK_KEY,
TILE_SOLID_KEY,
@@ -68,11 +69,35 @@ type Extras<K extends FlatKind> = K extends 'number'
? { assetTypes?: readonly AssetType[] }
: unknown;
/** The definition of a flat kind: its default is a value of that kind, as the table says. */
/**
* The definition of a flat kind: its default is a value of that kind, as the table says.
* `shownWith` names a switch on the same bag; the inspector shows the row only while it is on
* (see `isRowShown`), everything else – graph, defaults, files – always has it.
*/
export type FlatDef<K extends FlatKind = FlatKind> = {
[P in K]: { key: string; label: string; kind: P; default: ValueOf<P> } & Extras<P>;
[P in K]: { key: string; label: string; kind: P; default: ValueOf<P>; shownWith?: string } & Extras<P>;
}[K];
/** Whether the inspector shows the row, given the values of the bag it is on. */
export function isRowShown(def: PropertyDef, values: PropertyBag): boolean {
return !('shownWith' in def) || def.shownWith === undefined || values[def.shownWith] === true;
}
/** How far a contact normal may lean from a one-way side and still land on it, in degrees (#155, #159). */
export const ONE_WAY_ANGLE_DEFAULT = 45;
export const ONE_WAY_ANGLE_MAX = 90;
const ONE_WAY_ANGLE_ROW: FlatDef<'number'> = {
key: 'oneWayAngle',
label: 'One-way angle',
kind: 'number',
default: ONE_WAY_ANGLE_DEFAULT,
min: 0,
max: ONE_WAY_ANGLE_MAX,
step: 5,
shownWith: 'oneWay',
};
/** The inspector is generated from these entries, so a new property is one line in a schema below. */
export type PropertyDef =
| FlatDef
@@ -265,6 +290,7 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
{ key: TILE_SOLID_KEY, label: 'Solid', kind: 'boolean', default: false },
// 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 },
{ ...ONE_WAY_ANGLE_ROW, key: TILE_ONE_WAY_ANGLE_KEY, shownWith: TILE_ONE_WAY_KEY },
// 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).
{
@@ -389,7 +415,10 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
{ value: 'left', label: 'Left' },
{ value: 'right', label: 'Right' },
],
shownWith: 'oneWay',
},
// How far a contact may lean from that side and still land (#159).
ONE_WAY_ANGLE_ROW,
],
rigidbody: [
{
+24 -9
View File
@@ -15,7 +15,7 @@ import {
preferredOnTie,
type Rounded,
} from './convex';
import { defaultsFor } from './PropertySchema';
import { defaultsFor, ONE_WAY_ANGLE_DEFAULT, ONE_WAY_ANGLE_MAX } from './PropertySchema';
const DEFAULTS = defaultsFor('collider');
@@ -112,27 +112,42 @@ const ONE_WAY_SIDES: Record<string, Vec2> = {
};
/**
* A contact normal at most this far from a one-way collider's side (45°) is a landing on it; any
* other contact passes through (#155).
* The world direction a one-way shape blocks from, and `cone`, the cosine of its angle: a contact
* whose normal lies within that angle of the direction is a landing, any other passes through.
*/
const ONE_WAY_CONE = Math.cos(Math.PI / 4) - 1e-9;
export interface OneWaySide extends Vec2 {
cone: number;
}
/** The angle held to 0 … 90°, anything unreadable the default. */
export function oneWayAngle(value: PropertyValue | undefined): number {
const angle = Number(value ?? ONE_WAY_ANGLE_DEFAULT);
if (typeof value === 'boolean' || !Number.isFinite(angle)) return ONE_WAY_ANGLE_DEFAULT;
return Math.min(ONE_WAY_ANGLE_MAX, Math.max(0, angle));
}
/** The cone of an angle in degrees, a hair wide so a normal exactly on its edge still lands. */
export function oneWayCone(angle: number): number {
return Math.cos((angle * Math.PI) / 180) - 1e-9;
}
/**
* The world direction a one-way collider blocks from – its top face for the default "up", turned
* and mirrored with it – or null for a collider that blocks from everywhere.
* and mirrored with it – with its cone, or null for a collider that blocks from everywhere.
*/
export function oneWayFacing(props: PropertyBag, m: Mat): Vec2 | null {
export function oneWayFacing(props: PropertyBag, m: Mat): OneWaySide | null {
if (!(props.oneWay ?? DEFAULTS.oneWay)) return null;
const side = ONE_WAY_SIDES[textOf(props.oneWayDirection ?? DEFAULTS.oneWayDirection)] ?? ONE_WAY_SIDES.up!;
const x = m.a * side.x + m.c * side.y;
const y = m.b * side.x + m.d * side.y;
const length = Math.hypot(x, y);
return length > 0 ? { x: x / length, y: y / length } : null;
const cone = oneWayCone(oneWayAngle(props.oneWayAngle));
return length > 0 ? { x: x / length, y: y / length, cone } : null;
}
/** Whether a contact with that normal (towards the mover) lands on the side a one-way shape blocks from. */
export function landsOn(normal: Vec2, facing: Vec2): boolean {
return dot(normal, facing) >= ONE_WAY_CONE;
export function landsOn(normal: Vec2, facing: OneWaySide): boolean {
return dot(normal, facing) >= facing.cone;
}
/** The shape with every point moved by `move` and turned by `turn` more – a body carrying it moved rigidly. */
+12 -5
View File
@@ -19,7 +19,12 @@ import { findAsset, type Project } from '../../core/model/Project';
import type { PropertyBag, PropertyValue } from '../../core/model/Properties';
import { isGlobal, readScope, scopeOf } from '../../core/model/Scope';
import { type StructFieldKind, VARIABLE_KINDS } from '../../core/properties/kinds';
import { type PropertyDef, schemaForAsset, type StructFieldDef } from '../../core/properties/PropertySchema';
import {
isRowShown,
type PropertyDef,
schemaForAsset,
type StructFieldDef,
} from '../../core/properties/PropertySchema';
import { markedAssetSchema } from '../../core/properties/deviation';
import { isModified, resolveAssetProps } from '../../core/properties/resolve';
import { Dropdown, HStack, Label, Menu, Theme } from '../../ui';
@@ -35,15 +40,17 @@ import { assetValue } from './valueSpecs';
const newField = (key: string, kind: StructFieldKind): StructFieldDef => ({ key, kind });
/**
* Structure-defining facts of an asset selection: the schema keys and kinds, plus the fields of a
* struct – those are rows of their own, so adding one has to rebuild the section.
* Structure-defining facts of an asset selection: the schema keys and kinds of the rows shown, plus
* the fields of a struct – those are rows of their own, so adding one has to rebuild the section.
*/
export function assetSignature(project: Project, assetId: string): string {
const asset = findAsset(project, assetId);
if (!asset) return 'missing';
// A machine's own rows are none, so its scope has to be named here or switching it rebuilds nothing.
const machine = asset.type === 'statemachine' ? `|${scopeOf(asset)}` : '';
return schemaForAsset(project, asset).map(defSignature).join(',') + machine;
const values = resolveAssetProps(project, asset);
const rows = schemaForAsset(project, asset).filter((def) => isRowShown(def, values));
return rows.map(defSignature).join(',') + machine;
}
export function defSignature(def: PropertyDef): string {
@@ -101,7 +108,7 @@ export function refreshAssetSection(host: InspectorHost, assetId: string): boole
function buildProps(host: InspectorHost, asset: Asset, schema: readonly PropertyDef[]): void {
const project = () => host.state.project;
const values = resolveAssetProps(project(), asset);
for (const def of schema) {
for (const def of schema.filter((d) => isRowShown(d, values))) {
const row = createFieldRow(
host,
def,
+5 -3
View File
@@ -31,6 +31,7 @@ import type { SceneObject } from '../../core/model/SceneObject';
import {
COMMON_PROPS,
isCommonProp,
isRowShown,
isTransformProp,
type PropertyDef,
schemaFor,
@@ -65,7 +66,7 @@ export function objectRows(project: Project, asset: Asset, object: SceneObject):
: schemaForObject(project, asset, object).filter((def) => !isCommonProp(def.key));
}
/** Structure-defining facts of an object selection: parent, template link, schema and painting. */
/** Structure-defining facts of an object selection: parent, template link, the rows shown and painting. */
export function objectSignature(
state: EditorState,
holderId: string,
@@ -84,7 +85,8 @@ export function objectSignature(
: isLiveInstance(project, holder, object)
? 'instance'
: '';
const rows = schemaForObject(project, asset, object);
const values = resolveSceneObjectProps(project, asset, object);
const rows = schemaForObject(project, asset, object).filter((def) => isRowShown(def, values));
// A tilemap warns when it is placed more than once, so the number of placements shapes it too.
const placed = asset.type === 'tilemap' ? `|placed:${findUsages(project, asset.id).length}` : '';
return `${object.parentId}|${kind}${placed}|` + rows.map(defSignature).join(',');
@@ -202,7 +204,7 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
// Asset properties (or behavior variables) with overrides; the switches are shown above.
const values = resolveSceneObjectProps(host.state.project, asset, object);
const own = objectRows(host.state.project, asset, object);
const own = objectRows(host.state.project, asset, object).filter((def) => isRowShown(def, values));
if (own.length > 0 || asset.type === 'behavior')
addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES');
if (own.length === 0 && asset.type === 'behavior') addNote(host, 'The behavior declares no variables.');
+4 -2
View File
@@ -61,7 +61,7 @@ import {
import { gripAt, withCornerAfter, withCornerMoved, withoutCorner } from '../polygonEdit';
import { collectHolderItems, type DrawItem } from '../../viewport/items';
import type { LayerFocus } from '../../viewport/tilemap';
import { dashedLine, drawSourceIcon, markBlockingSide, placeNode } from '../../viewport/symbols';
import { CONE_LENGTH, dashedLine, drawSourceIcon, markBlockingSide, placeNode } from '../../viewport/symbols';
import { screenToWorld, type View, worldToScreen } from '../../viewport/view';
import { type CastRay, RAY_SHOWN, type World } from '../../runtime/World';
import { FreeViewport } from './FreeViewport';
@@ -879,7 +879,8 @@ export class SceneViewport extends FreeViewport {
stroke(boundary.solid, width);
fill(oneWay.solids, oneWay.outlines, 0.06);
stroke(boundary.oneWay, width / 2);
// A platform's top is what blocks, so that is what stands out.
// A platform's top is what blocks, so that is what stands out, with the cone it lands within.
const length = CONE_LENGTH / (scale * scaleOf(item.m));
for (const rect of oneWay.solids) {
const a = cellOrigin(item.props, rect.x, rect.y);
const b = cellOrigin(item.props, rect.x + rect.w, rect.y + rect.h);
@@ -889,6 +890,7 @@ export class SceneViewport extends FreeViewport {
{ x: 0, y: -1 },
width * 2,
color,
{ angle: rect.angle, length },
);
}
}
+10 -8
View File
@@ -16,6 +16,7 @@ import {
contactBetween,
isTrigger,
landsOn,
type OneWaySide,
movedShape,
piecesOf,
shapeArea,
@@ -100,7 +101,7 @@ interface Body {
interface Part {
entity: Entity;
shape: ColliderShape;
facing: Vec2 | null;
facing: OneWaySide | null;
owner: Body;
}
@@ -497,7 +498,7 @@ export class PhysicsSystem implements System, CollisionSource {
local.map(({ entity, pieces, facing }) => [
entity.id,
pieces.map(pieceKey),
facing && [round(facing.x), round(facing.y)],
facing && [round(facing.x), round(facing.y), facing.cone],
entity.type === 'collider' ? [isTrigger(entity.props), colliderMaterial(entity.props)] : 0,
]),
) + JSON.stringify(want.settings?.mass ?? 0);
@@ -599,10 +600,11 @@ export class PhysicsSystem implements System, CollisionSource {
/**
* Whether each dynamic body near a one-way collider passes through it in the coming step (#155).
* It lands when it is on the blocking side (or sunk in no deeper than a landing sinks), the contact
* faces that side within 45° and it is not moving away from it; a body that landed is held as long
* as the two overlap, however deep a fast fall took it in one step. Otherwise it passes – from
* below, from the side, from inside – and so does a body told to ignore one-way colliders. One that
* is to pass is woken: a resting body sleeps, and a new answer alone would not move it.
* faces that side within the collider's angle (#159) and it is not moving away from it; a body
* that landed is held as long as the two overlap, however deep a fast fall took it in one step.
* Otherwise it passes – from below, from the side, from inside – and so does a body told to ignore
* one-way colliders. One that is to pass is woken: a resting body sleeps, and a new answer alone
* would not move it.
*/
private decideOneWay(world: World): void {
const platforms: [number, Part][] = [];
@@ -657,13 +659,13 @@ export class PhysicsSystem implements System, CollisionSource {
return movedShape(part.shape, move, turn);
}
private facingNow(part: Part): Vec2 {
private facingNow(part: Part): OneWaySide {
const facing = part.facing!;
if (!part.owner.body.isDynamic()) return facing;
const turn = part.owner.body.rotation() - part.owner.placed.angle;
const cos = Math.cos(turn);
const sin = Math.sin(turn);
return { x: facing.x * cos - facing.y * sin, y: facing.x * sin + facing.y * cos };
return { x: facing.x * cos - facing.y * sin, y: facing.x * sin + facing.y * cos, cone: facing.cone };
}
/**
+23 -11
View File
@@ -7,6 +7,7 @@ import {
cellPolygonShape,
type CellRect,
cellRectShape,
oneWayAngles,
solidPolygons,
solidRects,
} from '../../core/model/tilemapCollision';
@@ -19,7 +20,9 @@ import {
isTrigger,
landsOn,
movedShape,
oneWayCone,
oneWayFacing,
type OneWaySide,
overlapDepth,
shapeExtent,
shapesOverlap,
@@ -83,11 +86,11 @@ export const NOT_BLOCKED: MoveResult = Object.freeze({
/**
* A shape of a collider or tilemap, and for a one-way one the world direction it blocks from (#155):
* only a contact whose normal lies within 45° of it stops anything.
* only a contact whose normal lies within its angle of it stops anything (#159).
*/
export interface SolidShape {
shape: ColliderShape;
facing: Vec2 | null;
facing: OneWaySide | null;
}
/** A shape in the mover's way and the collider or tilemap it belongs to. */
@@ -167,10 +170,13 @@ export class Solids {
? {
rects: solidRects(project, props, asset),
polygons: solidPolygons(project, props, asset),
oneWayRects: solidRects(project, props, asset, true),
oneWayPolygons: solidPolygons(project, props, asset, true),
oneWay: oneWayAngles(project, asset).map((angle) => ({
angle,
rects: solidRects(project, props, asset, angle),
polygons: solidPolygons(project, props, asset, angle),
})),
}
: { rects: [], polygons: [], oneWayRects: [], oneWayPolygons: [] };
: { rects: [], polygons: [], oneWay: [] };
this.blocks.set(tilemap, blocks);
}
return blocks;
@@ -179,19 +185,25 @@ export class Solids {
/**
* The shapes a body occupies in the world: one for a collider; for a tilemap one box per merged
* rectangle and one polygon per outlined cell (#141), the one-way cells blocking from the map's
* top (#155). A tilemap placed before the first tick is worked out on the spot.
* 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[] {
const m = worldMatrixOf(body);
if (body.type !== 'tilemap')
return [{ shape: colliderShape(body.props, m), facing: oneWayFacing(body.props, m) }];
const { rects, polygons, oneWayRects, oneWayPolygons } = this.blocksOf(world, body);
const { rects, polygons, oneWay } = this.blocksOf(world, body);
const up = oneWayFacing({ oneWay: true }, m);
const shapes = (cells: CellRect[], outlines: CellPolygon[], facing: Vec2 | null) => [
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 })),
];
return [...shapes(rects, polygons, null), ...shapes(oneWayRects, oneWayPolygons, up)];
return [
...shapes(rects, polygons, null),
...oneWay.flatMap((group) =>
shapes(group.rects, group.polygons, up && { ...up, cone: oneWayCone(group.angle) }),
),
];
}
shapesOf(world: World, body: Entity): ColliderShape[] {
@@ -401,8 +413,8 @@ function unitOf(v: Vec2): Vec2 {
interface TilemapBlocks {
rects: CellRect[];
polygons: CellPolygon[];
oneWayRects: CellRect[];
oneWayPolygons: 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 {
+17 -5
View File
@@ -7,7 +7,13 @@ import type { PropertyBag } from '../core/model/Properties';
import type { ParticlesAsset } from '../core/model/Asset';
import { readTile, tileFps, type TileInfo, type TilePlayback, tilePlayback } from '../core/model/tile';
import { isLayerVisible, layerTiles, type TileDraw } from '../core/model/tilemap';
import { type CellPolygon, type CellRect, solidPolygons, solidRects } from '../core/model/tilemapCollision';
import {
type CellPolygon,
type CellRect,
oneWayAngles,
solidPolygons,
solidRects,
} from '../core/model/tilemapCollision';
import { findAsset, findAssetOf, type ObjectHolder, type Project } from '../core/model/Project';
import { fontFileOf } from '../core/model/font';
import { resolveTexture, type TextureRef } from '../core/model/texture';
@@ -70,8 +76,11 @@ export interface TilemapDraw {
solids: CellRect[];
/** The cells that block with an outline (#141), outlined the same way. */
outlines: CellPolygon[];
/** The same for one-way tiles (#155), which block only from above. */
oneWay: { solids: CellRect[]; outlines: CellPolygon[] };
/**
* The same for one-way tiles (#155), which block only from above; each rectangle with the angle
* its tiles land things within (#159), since tiles of two angles never merge.
*/
oneWay: { solids: (CellRect & { angle: number })[]; outlines: CellPolygon[] };
}
/**
@@ -99,14 +108,17 @@ function resolveTilemap(project: Project, assetId: string, props: PropertyBag):
});
}
}
const angles = oneWayAngles(project, asset);
return {
layers,
tiles: used,
solids: solidRects(project, props, asset),
outlines: solidPolygons(project, props, asset),
oneWay: {
solids: solidRects(project, props, asset, true),
outlines: solidPolygons(project, props, asset, true),
solids: angles.flatMap((angle) =>
solidRects(project, props, asset, angle).map((rect) => ({ ...rect, angle })),
),
outlines: angles.flatMap((angle) => solidPolygons(project, props, asset, angle)),
},
};
}
+54 -8
View File
@@ -9,7 +9,13 @@ import {
RIGIDBODY_SYMBOL,
textAnchor,
} from '../core/properties/bounds';
import { type ColliderShape, colliderShape, isTrigger, oneWayFacing } from '../core/properties/collider';
import {
type ColliderShape,
colliderShape,
isTrigger,
oneWayAngle,
oneWayFacing,
} from '../core/properties/collider';
import { SHEET_EDGE } from '../core/shader/nodes';
import type { NineSlice } from '../core/model/texture';
import type { Vec2 } from '../core/model/Properties';
@@ -193,7 +199,11 @@ export function drawItem(
: Theme.colors.collider;
node.fill({ color, alpha: 0.12 }).stroke({ width: 2 / scale, color });
const facing = oneWayFacing(item.props, IDENTITY);
if (facing) markBlockingSide(node, shapeBounds(shape), facing, 5 / scale, color);
if (facing)
markBlockingSide(node, shapeBounds(shape), facing, 5 / scale, color, {
angle: oneWayAngle(item.props.oneWayAngle),
length: CONE_LENGTH / scale,
});
node.hitArea = colliderOutlineHitArea(shape, COLLIDER_GRAB_BAND / scale);
return;
}
@@ -218,6 +228,9 @@ export function drawSourceIcon(node: Graphics): void {
}
}
/** How far the landing cone of a one-way side reaches, in screen pixels (#159). */
export const CONE_LENGTH = 24;
const DIAGONALS = [
[Math.SQRT1_2, Math.SQRT1_2],
[-Math.SQRT1_2, Math.SQRT1_2],
@@ -260,7 +273,9 @@ function shapeBounds(shape: ColliderShape): Bounds {
/**
* A one-way collider or tile (#155): a thick line along the side of its bounds it blocks from – the
* top of a platform. `facing` is one of the four axis directions in the same units.
* top of a platform. `facing` is one of the four axis directions in the same units. With `cone`,
* a fan of `length` stands on the middle of that line, `angle` degrees to either side of `facing`:
* the directions a contact may come from and still land (#159).
*/
export function markBlockingSide(
node: Graphics,
@@ -268,12 +283,43 @@ export function markBlockingSide(
facing: Vec2,
width: number,
color: number,
cone?: { angle: number; length: number },
): void {
if (facing.y < -0.5) node.moveTo(b.left, b.top).lineTo(b.right, b.top);
else if (facing.y > 0.5) node.moveTo(b.left, b.bottom).lineTo(b.right, b.bottom);
else if (facing.x < 0) node.moveTo(b.left, b.top).lineTo(b.left, b.bottom);
else node.moveTo(b.right, b.top).lineTo(b.right, b.bottom);
node.stroke({ width, color });
let from: Vec2;
let to: Vec2;
if (facing.y < -0.5)
[from, to] = [
{ x: b.left, y: b.top },
{ x: b.right, y: b.top },
];
else if (facing.y > 0.5)
[from, to] = [
{ x: b.left, y: b.bottom },
{ x: b.right, y: b.bottom },
];
else if (facing.x < 0)
[from, to] = [
{ x: b.left, y: b.top },
{ x: b.left, y: b.bottom },
];
else
[from, to] = [
{ x: b.right, y: b.top },
{ x: b.right, y: b.bottom },
];
node.moveTo(from.x, from.y).lineTo(to.x, to.y).stroke({ width, color });
if (!cone) return;
const tip = { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 };
const middle = Math.atan2(facing.y, facing.x);
const spread = (cone.angle * Math.PI) / 180;
const edge = (turn: number) => ({
x: tip.x + Math.cos(middle + turn) * cone.length,
y: tip.y + Math.sin(middle + turn) * cone.length,
});
const start = edge(-spread);
node.moveTo(tip.x, tip.y).lineTo(start.x, start.y);
if (spread > 0) node.arc(tip.x, tip.y, cone.length, middle - spread, middle + spread).closePath();
node.fill({ color, alpha: 0.15 }).stroke({ width: width / 3, color });
}
/** Adds the outline of a collider shape in its own units (see `colliderShape` with `IDENTITY`). */
+214
View File
@@ -0,0 +1,214 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { AddColliderCommand, AddRigidbodyCommand } from '../../src/core/commands/colliderCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { PaintTilesCommand } from '../../src/core/commands/tilemapCommands';
import type { TilemapAsset } from '../../src/core/model/Asset';
import { IDENTITY } from '../../src/core/model/matrix';
import type { PropertyValue, Vec2 } from '../../src/core/model/Properties';
import { findAsset } from '../../src/core/model/Project';
import { TILE_ONE_WAY_ANGLE_KEY, TILE_ONE_WAY_KEY, TILE_SOLID_KEY } from '../../src/core/model/tile';
import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY } from '../../src/core/model/tilemap';
import { oneWayAngles, solidRects } from '../../src/core/model/tilemapCollision';
import { landsOn, oneWayAngle, oneWayCone, oneWayFacing } 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, type SceneFixture, startWorld } from '../helpers';
beforeAll(async () => {
await loadPhysics();
});
function scene() {
const f = makeSpriteScene('Thing');
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) {
const rigidbody = new AddRigidbodyCommand(f.sceneId, id);
f.stack.execute(rigidbody);
for (const [key, value] of Object.entries(body))
f.stack.execute(new SetOverrideCommand(f.sceneId, rigidbody.objectId, key, value));
}
return id;
};
return { ...f, add };
}
function run(f: SceneFixture, physics: boolean) {
const started = startWorld(f, { physics, collisions: true });
const entity = (id: string) => started.world.findByObjectId(id)!;
const move = (id: string, dx: number, dy: number) =>
started.collisions.moveAndSlide(started.world, entity(id), dx, dy);
const seconds = (s: number) => {
for (let i = 0; i < Math.round(s * 60); i++) started.frame(1 / 60);
};
return { ...started, entity, move, seconds };
}
/**
* A one-way wedge blocking from above whose slope leans 25° from level: the contact on the slope
* has a normal 25° from straight up, so it lands within any angle of 25° or more. At x = 0 the slope
* is at y = 50 − 100·tan 25° ≈ 3.37. Gentler than friction 0.5 lets slide (tan 25° ≈ 0.47).
*/
const SLOPE = (25 * Math.PI) / 180;
const WEDGE_POINTS = [
{ x: -100, y: 50 },
{ x: 100, y: 50 - 200 * Math.tan(SLOPE) },
{ x: 100, y: 50 },
];
const wedge = (angle?: number) => ({
shape: 'polygon',
points: WEDGE_POINTS,
oneWay: true,
...(angle === undefined ? {} : { oneWayAngle: angle }),
});
const SLOPE_AT_0 = 50 - 100 * Math.tan(SLOPE);
describe('the one-way angle (#159)', () => {
it('is 45° when not set, so projects from before it keep landing exactly as they did', () => {
const facing = oneWayFacing({ oneWay: true }, IDENTITY)!;
expect(facing.cone).toBe(Math.cos(Math.PI / 4) - 1e-9);
expect(schemaFor('collider').find((d) => d.key === 'oneWayAngle')?.default).toBe(45);
expect(schemaFor('tile').find((d) => d.key === TILE_ONE_WAY_ANGLE_KEY)?.default).toBe(45);
});
it('is held to 0 … 90°, and anything unreadable is the default', () => {
expect([-10, 0, 30, 90, 120].map(oneWayAngle)).toEqual([0, 0, 30, 90, 90]);
expect([undefined, 'wide', true, Number.NaN].map((v) => oneWayAngle(v as PropertyValue))).toEqual([
45, 45, 45, 45,
]);
});
it('lands a normal exactly on the edge of the cone, and not one a hair beyond it', () => {
const up = { x: 0, y: -1 };
const at = (deg: number) => ({ x: Math.sin((deg * Math.PI) / 180), y: -Math.cos((deg * Math.PI) / 180) });
for (const angle of [0, 20, 45, 90]) {
const side = { ...up, cone: oneWayCone(angle) };
expect(landsOn(at(angle), side)).toBe(true);
expect(landsOn(at(angle + 0.01), side)).toBe(false);
}
});
it('the inspector shows the angle and "Blocks from" only while One-way is on', () => {
const collider = schemaFor('collider');
const shown = (values: Record<string, PropertyValue>) =>
collider.filter((d) => isRowShown(d, values)).map((d) => d.key);
expect(shown({ oneWay: false })).not.toContain('oneWayAngle');
expect(shown({ oneWay: false })).not.toContain('oneWayDirection');
expect(shown({ oneWay: true })).toEqual(expect.arrayContaining(['oneWayAngle', 'oneWayDirection']));
const tile = schemaFor('tile');
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]: true })).toBe(true);
// Rows without a switch are always there.
expect(tile.filter((d) => !isRowShown(d, {})).map((d) => d.key)).toEqual([TILE_ONE_WAY_ANGLE_KEY]);
});
});
describe.each(COLLISION_SYSTEMS)('$system: Move and the one-way angle (#159)', ({ physics }) => {
it('a box falling onto a 25° slope lands at the default 45° and at 25°, and falls through at 20°', () => {
for (const [angle, lands] of [
[undefined, true],
[25, true],
[20, false],
] as const) {
const s = scene();
s.add({ x: 0, y: 0 }, wedge(angle));
const box = s.add({ x: 0, y: -100 }, { width: 20, height: 20 });
const r = run(s, physics);
let hit = false;
for (let i = 0; i < 60; i++) hit = r.move(box, 0, 5).hit || hit;
expect({ angle, hit }).toEqual({ angle, hit: lands });
if (lands) expect(r.entity(box).y).toBeLessThan(SLOPE_AT_0 - 10 + 1e-6);
else expect(r.entity(box).y).toBeCloseTo(200, 9);
}
});
it('at 90° the sides of a platform block too, while at 45° a box walks through them', () => {
for (const [angle, blocks] of [
[45, false],
[90, true],
] as const) {
const s = scene();
s.add({ x: 0, y: 104 }, { width: 200, height: 8, oneWay: true, oneWayAngle: angle });
const walker = s.add({ x: -200, y: 104 }, { width: 20, height: 20 });
const r = run(s, physics);
for (let i = 0; i < 20; i++) r.move(walker, 10, 0);
expect(r.entity(walker).x).toBeCloseTo(blocks ? -110 : 0, 9);
}
});
it('a ray straight down meets the slope within the angle and passes it beyond', () => {
for (const [angle, meets] of [
[45, true],
[20, false],
] as const) {
const s = scene();
s.add({ x: 0, y: 0 }, wedge(angle));
const r = run(s, physics);
const hit = r.collisions.raycast(r.world, { x: 0, y: -100 }, { x: 0, y: 1 }, 500, {
ignore: null,
triggers: false,
});
if (meets) expect(hit?.point.y).toBeCloseTo(SLOPE_AT_0, 9);
else expect(hit).toBeNull();
}
});
it('one-way tiles of two angles stay apart, and each blocks by its own', () => {
const s = scene();
const [steep, wide] = [new CreateAssetCommand('tile', 'Steep'), new CreateAssetCommand('tile', 'Wide')];
const map = new CreateAssetCommand('tilemap', 'Ground');
for (const command of [steep, wide, map]) s.stack.execute(command);
for (const tile of [steep, wide]) {
s.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_SOLID_KEY, true));
s.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_ONE_WAY_KEY, true));
}
s.stack.execute(new SetAssetPropertyCommand(wide.asset.id, TILE_ONE_WAY_ANGLE_KEY, 90));
s.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, 10));
s.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, 10));
place(s, map.asset.id, { x: 0, y: 0 });
const asset = () => findAsset(s.project, map.asset.id) as TilemapAsset;
// Cells of 48, centred: cell (x, y) spans x·48 − 240. Row 5 (y 0 … 48): the default tile at
// x = 0 … 2, the 90° one at x = 5 … 7.
const cells = [
...[0, 1, 2].map((x) => ({ x, y: 5, tileId: steep.asset.id })),
...[5, 6, 7].map((x) => ({ x, y: 5, tileId: wide.asset.id })),
...[3, 4].map((x) => ({ x, y: 7, tileId: wide.asset.id })),
];
s.stack.execute(new PaintTilesCommand(map.asset.id, asset().layers[0]!.id, cells));
expect(oneWayAngles(s.project, asset())).toEqual([45, 90]);
expect(solidRects(s.project, {}, asset(), 45)).toEqual([{ x: 0, y: 5, w: 3, h: 1 }]);
expect(solidRects(s.project, {}, asset(), 90)).toEqual([
{ x: 5, y: 5, w: 3, h: 1 },
{ x: 3, y: 7, w: 2, h: 1 },
]);
// Walking in along row 5 from the left: through the 45° tiles, stopped by the side of the 90° ones.
const walker = s.add({ x: -300, y: 24 }, { width: 20, height: 20 });
const r = run(s, physics);
for (let i = 0; i < 40; i++) r.move(walker, 10, 0);
expect(r.entity(walker).x).toBeCloseTo(-10, 9);
});
});
describe('the physics engine and the one-way angle (#159)', () => {
it('a dynamic box rests on the 25° slope at 45°, and falls through onto the ground at 20°', () => {
for (const [angle, rests] of [
[45, true],
[20, false],
] as const) {
const s = scene();
s.add({ x: 0, y: 0 }, wedge(angle));
s.add({ x: 0, y: 304 }, { width: 400, height: 8 });
const box = s.add({ x: 0, y: -100 }, { width: 20, height: 20 }, { fixedRotation: true });
const r = run(s, true);
r.seconds(2);
if (rests) expect(r.entity(box).y).toBeLessThan(SLOPE_AT_0);
else expect(r.entity(box).y).toBeCloseTo(290, 1);
}
});
});
+10
View File
@@ -165,9 +165,19 @@ export async function queries(t) {
objectIds: ['q-platform-col'],
});
const toggle = await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('One-way')");
// Side and angle mean nothing on a collider blocking from everywhere, so they wait for the switch (#159).
const hidden = await json(
"[__gm.inspectorField('Blocks from'), __gm.inspectorField('One-way angle')].every((f) => f === null)",
);
if (toggle) await click(toggle.x, toggle.y);
await settle();
const blocksFrom = await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Blocks from')");
const angleRow = await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('One-way angle')");
check(
'Blocks from and One-way angle show only once One-way is switched on',
hidden === true && Boolean(blocksFrom) && Boolean(angleRow),
JSON.stringify({ hidden, blocksFrom, angleRow }),
);
check(
"a collider's One-way switch in the inspector turns it one-way, blocking from above",
Boolean(toggle) &&