Files
feuerwerk/src/core/properties/bounds.ts
T
adminandClaude Opus 5 f4ec23d3d3
game-maker / gate (push) Successful in 2m11s
game-maker / suite (push) Successful in 7m33s
Colliders take a frame's outline and tiles have their own surface (#193)
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>
2026-09-21 07:29:26 +02:00

132 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}