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
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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
@@ -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`). */
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||||
import { AddColliderCommand, AddRigidbodyCommand } from '../../src/core/commands/colliderCommands';
|
||||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import { PaintTilesCommand } from '../../src/core/commands/tilemapCommands';
|
||||
import type { TilemapAsset } from '../../src/core/model/Asset';
|
||||
import { IDENTITY } from '../../src/core/model/matrix';
|
||||
import type { PropertyValue, Vec2 } from '../../src/core/model/Properties';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { TILE_ONE_WAY_ANGLE_KEY, TILE_ONE_WAY_KEY, TILE_SOLID_KEY } from '../../src/core/model/tile';
|
||||
import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY } from '../../src/core/model/tilemap';
|
||||
import { oneWayAngles, solidRects } from '../../src/core/model/tilemapCollision';
|
||||
import { landsOn, oneWayAngle, oneWayCone, oneWayFacing } from '../../src/core/properties/collider';
|
||||
import { isRowShown, schemaFor } from '../../src/core/properties/PropertySchema';
|
||||
import { loadPhysics } from '../../src/runtime/physics/engine';
|
||||
import { COLLISION_SYSTEMS, makeSpriteScene, place, type SceneFixture, startWorld } from '../helpers';
|
||||
|
||||
beforeAll(async () => {
|
||||
await loadPhysics();
|
||||
});
|
||||
|
||||
function scene() {
|
||||
const f = makeSpriteScene('Thing');
|
||||
const add = (at: Vec2, collider: Record<string, PropertyValue>, body?: Record<string, PropertyValue>) => {
|
||||
const id = place(f, f.spriteId, at);
|
||||
const command = new AddColliderCommand(f.sceneId, id);
|
||||
f.stack.execute(command);
|
||||
for (const [key, value] of Object.entries(collider))
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, command.objectId, key, value));
|
||||
if (body) {
|
||||
const rigidbody = new AddRigidbodyCommand(f.sceneId, id);
|
||||
f.stack.execute(rigidbody);
|
||||
for (const [key, value] of Object.entries(body))
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, rigidbody.objectId, key, value));
|
||||
}
|
||||
return id;
|
||||
};
|
||||
return { ...f, add };
|
||||
}
|
||||
|
||||
function run(f: SceneFixture, physics: boolean) {
|
||||
const started = startWorld(f, { physics, collisions: true });
|
||||
const entity = (id: string) => started.world.findByObjectId(id)!;
|
||||
const move = (id: string, dx: number, dy: number) =>
|
||||
started.collisions.moveAndSlide(started.world, entity(id), dx, dy);
|
||||
const seconds = (s: number) => {
|
||||
for (let i = 0; i < Math.round(s * 60); i++) started.frame(1 / 60);
|
||||
};
|
||||
return { ...started, entity, move, seconds };
|
||||
}
|
||||
|
||||
/**
|
||||
* A one-way wedge blocking from above whose slope leans 25° from level: the contact on the slope
|
||||
* has a normal 25° from straight up, so it lands within any angle of 25° or more. At x = 0 the slope
|
||||
* is at y = 50 − 100·tan 25° ≈ 3.37. Gentler than friction 0.5 lets slide (tan 25° ≈ 0.47).
|
||||
*/
|
||||
const SLOPE = (25 * Math.PI) / 180;
|
||||
const WEDGE_POINTS = [
|
||||
{ x: -100, y: 50 },
|
||||
{ x: 100, y: 50 - 200 * Math.tan(SLOPE) },
|
||||
{ x: 100, y: 50 },
|
||||
];
|
||||
const wedge = (angle?: number) => ({
|
||||
shape: 'polygon',
|
||||
points: WEDGE_POINTS,
|
||||
oneWay: true,
|
||||
...(angle === undefined ? {} : { oneWayAngle: angle }),
|
||||
});
|
||||
const SLOPE_AT_0 = 50 - 100 * Math.tan(SLOPE);
|
||||
|
||||
describe('the one-way angle (#159)', () => {
|
||||
it('is 45° when not set, so projects from before it keep landing exactly as they did', () => {
|
||||
const facing = oneWayFacing({ oneWay: true }, IDENTITY)!;
|
||||
expect(facing.cone).toBe(Math.cos(Math.PI / 4) - 1e-9);
|
||||
expect(schemaFor('collider').find((d) => d.key === 'oneWayAngle')?.default).toBe(45);
|
||||
expect(schemaFor('tile').find((d) => d.key === TILE_ONE_WAY_ANGLE_KEY)?.default).toBe(45);
|
||||
});
|
||||
|
||||
it('is held to 0 … 90°, and anything unreadable is the default', () => {
|
||||
expect([-10, 0, 30, 90, 120].map(oneWayAngle)).toEqual([0, 0, 30, 90, 90]);
|
||||
expect([undefined, 'wide', true, Number.NaN].map((v) => oneWayAngle(v as PropertyValue))).toEqual([
|
||||
45, 45, 45, 45,
|
||||
]);
|
||||
});
|
||||
|
||||
it('lands a normal exactly on the edge of the cone, and not one a hair beyond it', () => {
|
||||
const up = { x: 0, y: -1 };
|
||||
const at = (deg: number) => ({ x: Math.sin((deg * Math.PI) / 180), y: -Math.cos((deg * Math.PI) / 180) });
|
||||
for (const angle of [0, 20, 45, 90]) {
|
||||
const side = { ...up, cone: oneWayCone(angle) };
|
||||
expect(landsOn(at(angle), side)).toBe(true);
|
||||
expect(landsOn(at(angle + 0.01), side)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('the inspector shows the angle and "Blocks from" only while One-way is on', () => {
|
||||
const collider = schemaFor('collider');
|
||||
const shown = (values: Record<string, PropertyValue>) =>
|
||||
collider.filter((d) => isRowShown(d, values)).map((d) => d.key);
|
||||
expect(shown({ oneWay: false })).not.toContain('oneWayAngle');
|
||||
expect(shown({ oneWay: false })).not.toContain('oneWayDirection');
|
||||
expect(shown({ oneWay: true })).toEqual(expect.arrayContaining(['oneWayAngle', 'oneWayDirection']));
|
||||
const tile = schemaFor('tile');
|
||||
const angleRow = tile.find((d) => d.key === TILE_ONE_WAY_ANGLE_KEY)!;
|
||||
expect(isRowShown(angleRow, { [TILE_ONE_WAY_KEY]: false })).toBe(false);
|
||||
expect(isRowShown(angleRow, { [TILE_ONE_WAY_KEY]: true })).toBe(true);
|
||||
// Rows without a switch are always there.
|
||||
expect(tile.filter((d) => !isRowShown(d, {})).map((d) => d.key)).toEqual([TILE_ONE_WAY_ANGLE_KEY]);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(COLLISION_SYSTEMS)('$system: Move and the one-way angle (#159)', ({ physics }) => {
|
||||
it('a box falling onto a 25° slope lands at the default 45° and at 25°, and falls through at 20°', () => {
|
||||
for (const [angle, lands] of [
|
||||
[undefined, true],
|
||||
[25, true],
|
||||
[20, false],
|
||||
] as const) {
|
||||
const s = scene();
|
||||
s.add({ x: 0, y: 0 }, wedge(angle));
|
||||
const box = s.add({ x: 0, y: -100 }, { width: 20, height: 20 });
|
||||
const r = run(s, physics);
|
||||
let hit = false;
|
||||
for (let i = 0; i < 60; i++) hit = r.move(box, 0, 5).hit || hit;
|
||||
expect({ angle, hit }).toEqual({ angle, hit: lands });
|
||||
if (lands) expect(r.entity(box).y).toBeLessThan(SLOPE_AT_0 - 10 + 1e-6);
|
||||
else expect(r.entity(box).y).toBeCloseTo(200, 9);
|
||||
}
|
||||
});
|
||||
|
||||
it('at 90° the sides of a platform block too, while at 45° a box walks through them', () => {
|
||||
for (const [angle, blocks] of [
|
||||
[45, false],
|
||||
[90, true],
|
||||
] as const) {
|
||||
const s = scene();
|
||||
s.add({ x: 0, y: 104 }, { width: 200, height: 8, oneWay: true, oneWayAngle: angle });
|
||||
const walker = s.add({ x: -200, y: 104 }, { width: 20, height: 20 });
|
||||
const r = run(s, physics);
|
||||
for (let i = 0; i < 20; i++) r.move(walker, 10, 0);
|
||||
expect(r.entity(walker).x).toBeCloseTo(blocks ? -110 : 0, 9);
|
||||
}
|
||||
});
|
||||
|
||||
it('a ray straight down meets the slope within the angle and passes it beyond', () => {
|
||||
for (const [angle, meets] of [
|
||||
[45, true],
|
||||
[20, false],
|
||||
] as const) {
|
||||
const s = scene();
|
||||
s.add({ x: 0, y: 0 }, wedge(angle));
|
||||
const r = run(s, physics);
|
||||
const hit = r.collisions.raycast(r.world, { x: 0, y: -100 }, { x: 0, y: 1 }, 500, {
|
||||
ignore: null,
|
||||
triggers: false,
|
||||
});
|
||||
if (meets) expect(hit?.point.y).toBeCloseTo(SLOPE_AT_0, 9);
|
||||
else expect(hit).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('one-way tiles of two angles stay apart, and each blocks by its own', () => {
|
||||
const s = scene();
|
||||
const [steep, wide] = [new CreateAssetCommand('tile', 'Steep'), new CreateAssetCommand('tile', 'Wide')];
|
||||
const map = new CreateAssetCommand('tilemap', 'Ground');
|
||||
for (const command of [steep, wide, map]) s.stack.execute(command);
|
||||
for (const tile of [steep, wide]) {
|
||||
s.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_SOLID_KEY, true));
|
||||
s.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_ONE_WAY_KEY, true));
|
||||
}
|
||||
s.stack.execute(new SetAssetPropertyCommand(wide.asset.id, TILE_ONE_WAY_ANGLE_KEY, 90));
|
||||
s.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, 10));
|
||||
s.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, 10));
|
||||
place(s, map.asset.id, { x: 0, y: 0 });
|
||||
const asset = () => findAsset(s.project, map.asset.id) as TilemapAsset;
|
||||
// Cells of 48, centred: cell (x, y) spans x·48 − 240. Row 5 (y 0 … 48): the default tile at
|
||||
// x = 0 … 2, the 90° one at x = 5 … 7.
|
||||
const cells = [
|
||||
...[0, 1, 2].map((x) => ({ x, y: 5, tileId: steep.asset.id })),
|
||||
...[5, 6, 7].map((x) => ({ x, y: 5, tileId: wide.asset.id })),
|
||||
...[3, 4].map((x) => ({ x, y: 7, tileId: wide.asset.id })),
|
||||
];
|
||||
s.stack.execute(new PaintTilesCommand(map.asset.id, asset().layers[0]!.id, cells));
|
||||
expect(oneWayAngles(s.project, asset())).toEqual([45, 90]);
|
||||
expect(solidRects(s.project, {}, asset(), 45)).toEqual([{ x: 0, y: 5, w: 3, h: 1 }]);
|
||||
expect(solidRects(s.project, {}, asset(), 90)).toEqual([
|
||||
{ x: 5, y: 5, w: 3, h: 1 },
|
||||
{ x: 3, y: 7, w: 2, h: 1 },
|
||||
]);
|
||||
// Walking in along row 5 from the left: through the 45° tiles, stopped by the side of the 90° ones.
|
||||
const walker = s.add({ x: -300, y: 24 }, { width: 20, height: 20 });
|
||||
const r = run(s, physics);
|
||||
for (let i = 0; i < 40; i++) r.move(walker, 10, 0);
|
||||
expect(r.entity(walker).x).toBeCloseTo(-10, 9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the physics engine and the one-way angle (#159)', () => {
|
||||
it('a dynamic box rests on the 25° slope at 45°, and falls through onto the ground at 20°', () => {
|
||||
for (const [angle, rests] of [
|
||||
[45, true],
|
||||
[20, false],
|
||||
] as const) {
|
||||
const s = scene();
|
||||
s.add({ x: 0, y: 0 }, wedge(angle));
|
||||
s.add({ x: 0, y: 304 }, { width: 400, height: 8 });
|
||||
const box = s.add({ x: 0, y: -100 }, { width: 20, height: 20 }, { fixedRotation: true });
|
||||
const r = run(s, true);
|
||||
r.seconds(2);
|
||||
if (rests) expect(r.entity(box).y).toBeLessThan(SLOPE_AT_0);
|
||||
else expect(r.entity(box).y).toBeCloseTo(290, 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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) &&
|
||||
|
||||
Reference in new issue
Block a user