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)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d4ea04d4d6
commit
5385d9e152
14 files changed
+454
-62
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user