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

+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. */