Transform: scale, rotation and mirror as properties, world transform as a matrix chain, oriented colliders (#76)
game-maker / suite (push) Successful in 9m5s
game-maker / suite (push) Successful in 9m5s
Four new rows on sprite, text, collider and container (block TRANSFORM): scale, rotation in degrees clockwise, mirrorX, mirrorY. As properties they get override/reset/apply, loading of old projects, write-back after play and the Properties node for free; camera and behavior have none. The hierarchy composes T*R*S per level (composeAlongChain replaces sumAlongChain), so children rotate and scale with their parent. Reparenting and grouping keep the look (carriedTransform). Drawing goes through placeNode, the gizmo is a polygon, cull and objectRect use the AABB, drags are computed in the parent's system. Colliders are oriented boxes: SAT for box/box, box-circle in the box frame; exitDistance replaces axisSeparation/axisCorrection, moveAndSlide moves along the object's own axes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
f85606427b
commit
016e83c9b5
33 files changed
+1346
-274
No files matched your search
@@ -1,11 +1,13 @@
|
||||
import { isHandleEnabled, worldPositionOf } from '../../core/graph';
|
||||
import { isHandleEnabled, worldMatrixOf } from '../../core/graph';
|
||||
import { applyVector, IDENTITY } from '../../core/model/matrix';
|
||||
import type { Vec2 } from '../../core/model/Properties';
|
||||
import { parentChain } from '../../core/model/walk';
|
||||
import {
|
||||
axisCorrection,
|
||||
colliderShape,
|
||||
colliderSize,
|
||||
exitDistance,
|
||||
isTrigger,
|
||||
type PlaneAxis,
|
||||
shapeExtent,
|
||||
shapesOverlap,
|
||||
} from '../../core/properties/collider';
|
||||
import type { Entity } from '../Entity';
|
||||
@@ -78,6 +80,7 @@ export class CollisionSystem implements System {
|
||||
/**
|
||||
* Moves one axis at a time, stopping each at contact so the object slides along walls. An existing
|
||||
* overlap is never resolved (no jolt) but never deepened, so an object can leave a wall but not burrow in.
|
||||
* The axes are the entity's own: under a turned parent, "x" runs wherever that parent points it.
|
||||
*/
|
||||
moveAndSlide(
|
||||
world: World,
|
||||
@@ -107,12 +110,13 @@ export class CollisionSystem implements System {
|
||||
delta: number,
|
||||
): boolean {
|
||||
if (delta === 0 || own.length === 0) return false;
|
||||
const travel = axisTravel(entity, axis);
|
||||
const steps = Math.min(
|
||||
MAX_STEPS,
|
||||
Math.max(1, Math.ceil(Math.abs(delta) / stepLimit(own, obstacles, axis))),
|
||||
Math.max(1, Math.ceil((Math.abs(delta) * travel.length) / stepLimit(own, obstacles, travel.unit))),
|
||||
);
|
||||
for (let i = 0; i < steps; i++)
|
||||
if (this.stepAxis(entity, own, obstacles, axis, delta / steps)) return true;
|
||||
if (this.stepAxis(entity, own, obstacles, axis, delta / steps, travel)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -123,8 +127,10 @@ export class CollisionSystem implements System {
|
||||
obstacles: Entity[],
|
||||
axis: PlaneAxis,
|
||||
delta: number,
|
||||
travel: AxisTravel,
|
||||
): boolean {
|
||||
const dir: 1 | -1 = delta > 0 ? 1 : -1;
|
||||
const forward = { x: dir * travel.unit.x, y: dir * travel.unit.y };
|
||||
// How deep each pair already sits in the other before the step. A step may never deepen
|
||||
// that, but resolving it is not our job (no jolt, and leaving a wall is always possible).
|
||||
// Overlaps below the skin count as plain contact – that is where rounding leftovers of a
|
||||
@@ -132,36 +138,51 @@ export class CollisionSystem implements System {
|
||||
const before: [Entity, Entity, number][] = [];
|
||||
for (const a of own) {
|
||||
for (const b of obstacles) {
|
||||
const p = penetration(a, b, axis, dir);
|
||||
const p = penetration(a, b, forward);
|
||||
before.push([a, b, p > SKIN ? p : 0]);
|
||||
}
|
||||
}
|
||||
entity[axis] += delta;
|
||||
let correction = 0;
|
||||
for (const [a, b, was] of before) {
|
||||
const t = penetration(a, b, axis, dir) - was;
|
||||
const t = penetration(a, b, forward) - was;
|
||||
if (t > correction) correction = t;
|
||||
}
|
||||
if (correction <= 0) return false;
|
||||
entity[axis] -= dir * Math.min(correction, Math.abs(delta));
|
||||
entity[axis] -= dir * Math.min(correction / travel.length, Math.abs(delta));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Where one unit along an entity's local axis goes in the world, and how far. */
|
||||
interface AxisTravel {
|
||||
unit: Vec2;
|
||||
length: number;
|
||||
}
|
||||
|
||||
function axisTravel(entity: Entity, axis: PlaneAxis): AxisTravel {
|
||||
const parent = entity.parent ? worldMatrixOf(entity.parent) : IDENTITY;
|
||||
const v = applyVector(parent, axis === 'x' ? { x: 1, y: 0 } : { x: 0, y: 1 });
|
||||
const length = Math.hypot(v.x, v.y);
|
||||
return length > 0
|
||||
? { unit: { x: v.x / length, y: v.y / length }, length }
|
||||
: { unit: { x: 1, y: 0 }, length: 1 };
|
||||
}
|
||||
|
||||
function shapeOf(collider: Entity) {
|
||||
return colliderShape(collider.props, worldPositionOf(collider));
|
||||
return colliderShape(collider.props, worldMatrixOf(collider));
|
||||
}
|
||||
|
||||
/**
|
||||
* How far `a` would have to be pushed back against `dir` to just clear `b` (0 when they are
|
||||
* apart). Once a's centre has passed b's centre along `dir` the shorter way out is forwards,
|
||||
* How far `a` would have to be pushed back against its travel to just clear `b` (0 when they are
|
||||
* apart). Once a's centre has passed b's centre along the travel the shorter way out is forwards,
|
||||
* so nothing blocks – that keeps an object that ended up inside something able to leave.
|
||||
*/
|
||||
function penetration(a: Entity, b: Entity, axis: PlaneAxis, dir: 1 | -1): number {
|
||||
function penetration(a: Entity, b: Entity, forward: Vec2): number {
|
||||
const mover = shapeOf(a);
|
||||
const obstacle = shapeOf(b);
|
||||
if (dir * (mover[axis] - obstacle[axis]) >= 0) return 0;
|
||||
return axisCorrection(mover, obstacle, axis, dir);
|
||||
if ((mover.x - obstacle.x) * forward.x + (mover.y - obstacle.y) * forward.y >= 0) return 0;
|
||||
return exitDistance(mover, obstacle, { x: -forward.x, y: -forward.y });
|
||||
}
|
||||
|
||||
/** Whether `collider` belongs to `entity` or to something parented (at any depth) under it. */
|
||||
@@ -170,19 +191,17 @@ function travelsWith(collider: Entity, entity: Entity): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Longest safe step along `axis`: half of the thinnest collider involved (never below 1). */
|
||||
function stepLimit(own: Entity[], obstacles: Entity[], axis: PlaneAxis): number {
|
||||
/**
|
||||
* Longest safe step in world units: half of the thinnest collider involved, measured along the
|
||||
* travel as the colliders sit in the world (never below 1).
|
||||
*/
|
||||
function stepLimit(own: Entity[], obstacles: Entity[], along: Vec2): number {
|
||||
let min = Infinity;
|
||||
for (const e of own) min = Math.min(min, extentOf(e, axis));
|
||||
for (const e of obstacles) min = Math.min(min, extentOf(e, axis));
|
||||
for (const e of own) min = Math.min(min, shapeExtent(shapeOf(e), along));
|
||||
for (const e of obstacles) min = Math.min(min, shapeExtent(shapeOf(e), along));
|
||||
return Math.max(1, min / 2);
|
||||
}
|
||||
|
||||
function extentOf(collider: Entity, axis: PlaneAxis): number {
|
||||
const size = colliderSize(collider.props);
|
||||
return axis === 'x' ? size.w : size.h;
|
||||
}
|
||||
|
||||
/** The object a collider belongs to: its parent, or the collider itself when top-level. */
|
||||
export function bodyOf(collider: Entity): Entity {
|
||||
return collider.parent ?? collider;
|
||||
|
||||
Reference in new issue
Block a user