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