Issue #193 asked where friction, bounciness and density of a tile live. Tiles had a fixed surface (friction 0.5, bounciness 0) and a collider could not use the outline drawn on a texture frame (#141). Both gaps are closed here. - A collider has a fifth shape "Frame collision" with a Frame row. The frame is picked like a sprite's texture and stored as the value 'frame'. - The shape is linked, not copied. Redrawing the outline in the texture tab changes every collider using that frame, in the editor and on the next play. - The frame centre sits on the collider origin, one image pixel is one unit. The shape turns, scales and mirrors like any other. - Every outline ring blocks. Each ring is split into convex parts the same way a polygon is. - Without a frame, with a whole texture or with a frame that has no outline, the collider is drawn red as a box and blocks nothing. - Move, Rapier, raycast, detection, hit test, drawing and footprint all resolve the frame. At runtime an entity looks the frame up live in the current project. - A tile has Friction and Bounciness next to Solid and One-way. The rows only show when Solid is on. Defaults and mixing rules match the collider. Density is left out because a tilemap never moves. - Tilemap cells are grouped by surface as well as by one-way angle. Ice and stone never merge into one rectangle, and every cell brings its surface to Move and Rapier. Findings: - A tile with a collision scale below 1 is no longer flush with a neighbour of another surface. It is inset there like at an open side, the same as next to one-way tiles today. - A bounce test first measured 0.84. The ball had landed on the hero at the origin, not on the tile. On the tile it measures 0.993. Tests: - ./check.sh: 130 files, 1931 tests, exit 0 (was 129 / 1917). - 14 new unit tests in tests/core/surface.test.ts. The Move tests run against the editor run and the physics system. - 3 new e2e checks: 40l (surface rows follow Solid), 42e (collider has frame size, a walker climbs the slope in the frame). - GM_STEPS=1-8,39-43 freshly seeded: ALL PASSED, 376 checks. - Three counter-probes bite: Move ignores the tile surface (2 fail), Rapier ignores it (1 fails), frame shape falls back to a box (5 fail). - Check sum 1198 is computed (1195 + 3), the full run belongs to CI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
132 lines
4.9 KiB
TypeScript
132 lines
4.9 KiB
TypeScript
/**
|
||
* Footprints in world units, shared by the viewport, by pointer hit tests in the running game and by
|
||
* "Add collider", so all three agree on how big a thing is – and, through the object's transform, on
|
||
* where its corners are.
|
||
*/
|
||
import { type AssetType, hasSceneIcon } from '../model/AssetTypes';
|
||
import type { FrameOutline } from '../model/frameOutline';
|
||
import {
|
||
apply,
|
||
boxCorners,
|
||
type Extent,
|
||
extentOfPoints,
|
||
invert,
|
||
type Mat,
|
||
scaleOf,
|
||
translation,
|
||
} from '../model/matrix';
|
||
import { type PropertyBag, textOf, type Vec2 } from '../model/Properties';
|
||
import { tilemapSize } from '../model/tilemap';
|
||
import { cameraFrame } from './camera';
|
||
import { colliderSize } from './collider';
|
||
import { defaultsFor } from './PropertySchema';
|
||
|
||
const TEXT_DEFAULTS = defaultsFor('text');
|
||
|
||
/** Symbol sizes in world units. */
|
||
export const BEHAVIOR_SYMBOL = 28;
|
||
export const CONTAINER_SYMBOL = 24;
|
||
export const PARTICLES_SYMBOL = 24;
|
||
export const RIGIDBODY_SYMBOL = 24;
|
||
|
||
/** Rough width of one character relative to the font size, for a plain sans. */
|
||
const CHAR_WIDTH = 0.55;
|
||
const LINE_HEIGHT = 1.25;
|
||
|
||
/** Estimated from the content and font size, without measuring the text. */
|
||
export function textSize(props: PropertyBag): { w: number; h: number } {
|
||
const size = Math.max(1, Number(props.fontSize ?? TEXT_DEFAULTS.fontSize));
|
||
const lines = textOf(props.text ?? TEXT_DEFAULTS.text).split('\n');
|
||
const longest = lines.reduce((max, line) => Math.max(max, line.length), 0);
|
||
return {
|
||
w: Math.max(size * CHAR_WIDTH, longest * size * CHAR_WIDTH),
|
||
h: lines.length * size * LINE_HEIGHT,
|
||
};
|
||
}
|
||
|
||
/** 0 = left edge on the position, 0.5 = centred, 1 = right. */
|
||
export function textAnchor(props: PropertyBag): number {
|
||
const align = textOf(props.align ?? TEXT_DEFAULTS.align);
|
||
return align === 'left' ? 0 : align === 'right' ? 1 : 0.5;
|
||
}
|
||
|
||
/**
|
||
* Centred on the object's position; text is anchored instead (see `textAnchor`). A collider that
|
||
* wears a frame (#193) is that frame's size, which only the caller can look up.
|
||
*/
|
||
export function footprintOf(
|
||
type: AssetType,
|
||
props: PropertyBag,
|
||
frame?: FrameOutline | null,
|
||
): { w: number; h: number } {
|
||
switch (type) {
|
||
case 'sprite':
|
||
return { w: Number(props.width ?? 1), h: Number(props.height ?? 1) };
|
||
case 'text':
|
||
return textSize(props);
|
||
case 'collider':
|
||
return colliderSize(props, frame);
|
||
case 'behavior':
|
||
return { w: BEHAVIOR_SYMBOL, h: BEHAVIOR_SYMBOL };
|
||
case 'container':
|
||
return { w: CONTAINER_SYMBOL, h: CONTAINER_SYMBOL };
|
||
case 'rigidbody':
|
||
return { w: RIGIDBODY_SYMBOL, h: RIGIDBODY_SYMBOL };
|
||
// The particles themselves are drawn wherever the graph puts them; what can be grabbed and
|
||
// framed is the source, which is a point with a handle around it.
|
||
case 'particles':
|
||
return { w: PARTICLES_SYMBOL, h: PARTICLES_SYMBOL };
|
||
// The whole grid, centred on the object's position like every other footprint – so its cells
|
||
// are the same squares for drawing, for picking and for the outline around it.
|
||
case 'tilemap':
|
||
return tilemapSize(props);
|
||
default:
|
||
return cameraFrame(props);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* How an item of this type is drawn: with its whole transform, or – for the icons of behaviors,
|
||
* containers, particle sources and the camera frame – upright and unscaled at its position, since an
|
||
* icon that turns with its parent would only be harder to read. A source's own frame still goes into
|
||
* every particle; what stays upright is the square that can be grabbed, so it covers its icon.
|
||
* Colliders are symbols too but follow their body.
|
||
*/
|
||
export function placementOf(type: AssetType, m: Mat): Mat {
|
||
return hasSceneIcon(type) && type !== 'collider' ? translation(m.tx, m.ty) : m;
|
||
}
|
||
|
||
function anchorOffset(type: AssetType, props: PropertyBag, w: number): number {
|
||
return type === 'text' ? (0.5 - textAnchor(props)) * w : 0;
|
||
}
|
||
|
||
/** The footprint's corners in world units, grown by `pad` world units on every side. */
|
||
export function footprintCorners(
|
||
type: AssetType,
|
||
props: PropertyBag,
|
||
m: Mat,
|
||
pad = 0,
|
||
frame?: FrameOutline | null,
|
||
): Vec2[] {
|
||
const placed = placementOf(type, m);
|
||
const { w, h } = footprintOf(type, props, frame);
|
||
const local = pad / (scaleOf(placed) || 1);
|
||
return boxCorners(placed, w + 2 * local, h + 2 * local, anchorOffset(type, props, w), 0);
|
||
}
|
||
|
||
/** The world rectangle around the drawn footprint – what culling, framing and labels need. */
|
||
export function footprintExtent(
|
||
type: AssetType,
|
||
props: PropertyBag,
|
||
m: Mat,
|
||
frame?: FrameOutline | null,
|
||
): Extent {
|
||
return extentOfPoints(footprintCorners(type, props, m, 0, frame));
|
||
}
|
||
|
||
export function footprintContains(type: AssetType, props: PropertyBag, m: Mat, point: Vec2): boolean {
|
||
const { w, h } = footprintOf(type, props);
|
||
const local = apply(invert(placementOf(type, m)), point);
|
||
return Math.abs(local.x - anchorOffset(type, props, w)) <= w / 2 && Math.abs(local.y) <= h / 2;
|
||
}
|