From 5385d9e152628809a1927c93dd5e1fbcc5658505 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sat, 19 Sep 2026 13:13:29 +0200 Subject: [PATCH] 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 --- README.md | 2 +- src/core/model/tile.ts | 2 + src/core/model/tilemapCollision.ts | 55 ++++++- src/core/properties/PropertySchema.ts | 33 +++- src/core/properties/collider.ts | 33 ++-- src/editor/inspector/assetSection.ts | 17 +- src/editor/inspector/objectSection.ts | 8 +- src/editor/viewport/SceneViewport.ts | 6 +- src/runtime/physics/PhysicsSystem.ts | 18 ++- src/runtime/systems/solids.ts | 34 ++-- src/viewport/items.ts | 22 ++- src/viewport/symbols.ts | 62 +++++++- tests/core/one-way-angle.test.ts | 214 ++++++++++++++++++++++++++ tools/e2e/43-queries.mjs | 10 ++ 14 files changed, 454 insertions(+), 62 deletions(-) create mode 100644 tests/core/one-way-angle.test.ts diff --git a/README.md b/README.md index b4ae2957..d4ce1f2a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/model/tile.ts b/src/core/model/tile.ts index 3f7076a4..be58544c 100644 --- a/src/core/model/tile.ts +++ b/src/core/model/tile.ts @@ -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; diff --git a/src/core/model/tilemapCollision.ts b/src/core/model/tilemapCollision.ts index 24dc2f1f..1db05255 100644 --- a/src/core/model/tilemapCollision.ts +++ b/src/core/model/tilemapCollision.ts @@ -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(); + const seen = new Set(); + 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(); 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[] { 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)); } diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 30390ed3..93f18d73 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -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 '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 = { - [P in K]: { key: string; label: string; kind: P; default: ValueOf

} & Extras

; + [P in K]: { key: string; label: string; kind: P; default: ValueOf

; shownWith?: string } & Extras

; }[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 = { { 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 = { { 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: [ { diff --git a/src/core/properties/collider.ts b/src/core/properties/collider.ts index a4f88f24..c3737834 100644 --- a/src/core/properties/collider.ts +++ b/src/core/properties/collider.ts @@ -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 = { }; /** - * 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. */ diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index 961b22a1..de1d69bb 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -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, diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index 1637f835..78adf3c0 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -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.'); diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index 00b99aef..f77d0b1f 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -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 }, ); } } diff --git a/src/runtime/physics/PhysicsSystem.ts b/src/runtime/physics/PhysicsSystem.ts index 1592060e..ea8d5931 100644 --- a/src/runtime/physics/PhysicsSystem.ts +++ b/src/runtime/physics/PhysicsSystem.ts @@ -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 }; } /** diff --git a/src/runtime/systems/solids.ts b/src/runtime/systems/solids.ts index 10123165..efbf159c 100644 --- a/src/runtime/systems/solids.ts +++ b/src/runtime/systems/solids.ts @@ -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 { diff --git a/src/viewport/items.ts b/src/viewport/items.ts index 7621d1e3..37a49dd5 100644 --- a/src/viewport/items.ts +++ b/src/viewport/items.ts @@ -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)), }, }; } diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts index 3eb10270..b74f3c6b 100644 --- a/src/viewport/symbols.ts +++ b/src/viewport/symbols.ts @@ -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`). */ diff --git a/tests/core/one-way-angle.test.ts b/tests/core/one-way-angle.test.ts new file mode 100644 index 00000000..0f084036 --- /dev/null +++ b/tests/core/one-way-angle.test.ts @@ -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, body?: Record) => { + 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) => + 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); + } + }); +}); diff --git a/tools/e2e/43-queries.mjs b/tools/e2e/43-queries.mjs index 59256f7f..a8101655 100644 --- a/tools/e2e/43-queries.mjs +++ b/tools/e2e/43-queries.mjs @@ -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) &&