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
+67
-21
@@ -3,16 +3,26 @@ import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||||
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
|
||||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { transformMatrix, translation } from '../../src/core/model/matrix';
|
||||
import {
|
||||
axisCorrection,
|
||||
axisSeparation,
|
||||
colliderShape,
|
||||
exitDistance,
|
||||
shapeContains,
|
||||
shapesOverlap,
|
||||
} from '../../src/core/properties/collider';
|
||||
import { graphEditor, makeBehavior, makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
|
||||
|
||||
const box = (x: number, y: number, w: number, h: number) => ({ kind: 'box' as const, x, y, w, h });
|
||||
const box = (x: number, y: number, w: number, h: number, degrees = 0) => ({
|
||||
kind: 'box' as const,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
angle: (degrees * Math.PI) / 180,
|
||||
});
|
||||
const circle = (x: number, y: number, r: number) => ({ kind: 'circle' as const, x, y, r });
|
||||
const LEFT = { x: -1, y: 0 };
|
||||
const UP = { x: 0, y: -1 };
|
||||
|
||||
describe('collider geometry', () => {
|
||||
it('box vs box: overlapping yes, touching edges no', () => {
|
||||
@@ -21,6 +31,19 @@ describe('collider geometry', () => {
|
||||
expect(shapesOverlap(box(0, 0, 100, 100), box(0, 150, 100, 100))).toBe(false);
|
||||
});
|
||||
|
||||
it('a turned box reaches where its corners are, not where its upright frame would be', () => {
|
||||
// A 100×20 bar turned by 45° reaches ≈ 42.4 along x; upright it would reach 50.
|
||||
expect(shapesOverlap(box(0, 0, 100, 20, 45), box(95, 0, 100, 100))).toBe(false);
|
||||
expect(shapesOverlap(box(0, 0, 100, 20), box(95, 0, 100, 100))).toBe(true);
|
||||
// A diamond (square turned by 45°, corners at ±70.7) and a square at (110, 110): their upright
|
||||
// frames overlap (120.7 > 110 on both axes) but the square's corner (60, 60) lies outside the
|
||||
// diamond's edge x + y = 70.7.
|
||||
expect(shapesOverlap(box(0, 0, 100, 100, 45), box(110, 110, 100, 100))).toBe(false);
|
||||
expect(shapesOverlap(box(0, 0, 100, 100, 45), box(95, 0, 100, 100))).toBe(true);
|
||||
expect(shapeContains(box(0, 0, 100, 20, 90), { x: 0, y: 45 })).toBe(true);
|
||||
expect(shapeContains(box(0, 0, 100, 20, 90), { x: 45, y: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
it('circle vs circle and box vs circle (corner case: the closest point is a box corner)', () => {
|
||||
expect(shapesOverlap(circle(0, 0, 10), circle(15, 0, 10))).toBe(true);
|
||||
expect(shapesOverlap(circle(0, 0, 10), circle(20, 0, 10))).toBe(false);
|
||||
@@ -29,28 +52,51 @@ describe('collider geometry', () => {
|
||||
// Corner at (50,50): a circle at (60,60) with r=10 has distance √200 ≈ 14.1 → no overlap; r=15 → overlap.
|
||||
expect(shapesOverlap(circle(60, 60, 10), box(0, 0, 100, 100))).toBe(false);
|
||||
expect(shapesOverlap(circle(60, 60, 15), box(0, 0, 100, 100))).toBe(true);
|
||||
// The same corner turned to (0, 70.7): the circle at (0, 80) with r=10 just misses it.
|
||||
expect(shapesOverlap(circle(0, 80, 10), box(0, 0, 100, 100, 45))).toBe(true);
|
||||
expect(shapesOverlap(circle(0, 81, 10), box(0, 0, 100, 100, 45))).toBe(false);
|
||||
});
|
||||
|
||||
it('builds shapes from properties with defaults', () => {
|
||||
expect(colliderShape({}, { x: 1, y: 2 })).toEqual(box(1, 2, 100, 100));
|
||||
expect(colliderShape({ shape: 'circle', radius: 7 }, { x: 0, y: 0 })).toEqual(circle(0, 0, 7));
|
||||
it('builds shapes from properties with defaults, sized and turned by the transform', () => {
|
||||
expect(colliderShape({}, translation(1, 2))).toEqual(box(1, 2, 100, 100));
|
||||
expect(colliderShape({ shape: 'circle', radius: 7 }, translation(0, 0))).toEqual(circle(0, 0, 7));
|
||||
const turned = colliderShape({ width: 40, height: 20 }, transformMatrix(5, 6, 30, 2, false, false));
|
||||
expect(turned).toMatchObject({ kind: 'box', x: 5, y: 6, w: 80, h: 40 });
|
||||
expect(turned.kind === 'box' && (turned.angle * 180) / Math.PI).toBeCloseTo(30);
|
||||
// A mirror keeps a box a box: the same shape, whichever way its x axis points.
|
||||
const mirrored = colliderShape({ width: 40, height: 20 }, transformMatrix(0, 0, 0, 1, true, false));
|
||||
expect(shapeContains(mirrored, { x: 19, y: 9 })).toBe(true);
|
||||
expect(shapeContains(mirrored, { x: 21, y: 9 })).toBe(false);
|
||||
expect(colliderShape({ shape: 'circle', radius: 7 }, transformMatrix(0, 0, 0, 3, false, false))).toEqual(
|
||||
circle(0, 0, 21),
|
||||
);
|
||||
});
|
||||
|
||||
it('axis separation: the centre distance at which shapes touch along one axis', () => {
|
||||
expect(axisSeparation(box(0, 0, 100, 60), box(0, 10, 40, 60), 'x')).toBe(70);
|
||||
expect(axisSeparation(box(0, 0, 100, 60), box(0, 100, 40, 60), 'x')).toBe(0); // apart on y
|
||||
expect(axisSeparation(circle(0, 0, 10), circle(0, 12, 10), 'x')).toBeCloseTo(16);
|
||||
expect(axisSeparation(box(0, 0, 100, 100), circle(0, 20, 10), 'x')).toBe(60); // flat side
|
||||
expect(axisSeparation(box(0, 0, 100, 100), circle(0, 56, 10), 'x')).toBeCloseTo(50 + 8); // corner: dy=6 → √(100−36)
|
||||
expect(axisSeparation(box(0, 0, 100, 100), circle(0, 70, 10), 'x')).toBe(0);
|
||||
});
|
||||
|
||||
it('axis correction: how far a mover must back off to just touch', () => {
|
||||
// Mover moved right into a box: centres 80 apart, need 100 → push back 20.
|
||||
expect(axisCorrection(box(0, 0, 100, 100), box(80, 0, 100, 100), 'x', 1)).toBe(20);
|
||||
expect(axisCorrection(box(80, 0, 100, 100), box(0, 0, 100, 100), 'x', -1)).toBe(20);
|
||||
expect(axisCorrection(box(0, 0, 100, 100), box(120, 0, 100, 100), 'x', 1)).toBe(0);
|
||||
expect(axisCorrection(box(0, 0, 100, 100), box(0, 90, 100, 100), 'y', 1)).toBe(10);
|
||||
it('exit distance: how far a mover must travel to just clear an obstacle', () => {
|
||||
// Mover moved right into a box: centres 80 apart, need 100 → 20 back to the left.
|
||||
expect(exitDistance(box(80, 0, 100, 100), box(0, 0, 100, 100), { x: 1, y: 0 })).toBe(20);
|
||||
expect(exitDistance(box(0, 0, 100, 100), box(80, 0, 100, 100), LEFT)).toBe(20);
|
||||
expect(exitDistance(box(0, 0, 100, 100), box(120, 0, 100, 100), LEFT)).toBe(0);
|
||||
expect(exitDistance(box(0, 0, 100, 100), box(0, 90, 100, 100), UP)).toBe(10);
|
||||
expect(exitDistance(box(0, 10, 40, 60), box(0, 0, 100, 60), LEFT)).toBe(70);
|
||||
expect(exitDistance(box(0, 100, 40, 60), box(0, 0, 100, 60), LEFT)).toBe(0); // apart on y
|
||||
expect(exitDistance(circle(0, 12, 10), circle(0, 0, 10), LEFT)).toBeCloseTo(16);
|
||||
expect(exitDistance(circle(0, 20, 10), box(0, 0, 100, 100), LEFT)).toBeCloseTo(60); // flat side
|
||||
expect(exitDistance(circle(0, 56, 10), box(0, 0, 100, 100), LEFT)).toBeCloseTo(50 + 8); // corner: dy=6 → √(100−36)
|
||||
expect(exitDistance(circle(0, 70, 10), box(0, 0, 100, 100), LEFT)).toBe(0);
|
||||
// Moving away diagonally clears a corner sooner than along one axis would.
|
||||
expect(exitDistance(box(80, 80, 100, 100), box(0, 0, 100, 100), { x: 1, y: 1 })).toBeCloseTo(
|
||||
20 * Math.SQRT2,
|
||||
);
|
||||
// A wall turned by 45°, a box moving left into its slanted face: level with the corner it must
|
||||
// back off to the corner (70.7 + 10 − 60), higher up the face is only half a unit deep along
|
||||
// its normal – an upright frame would still demand the 20.7.
|
||||
expect(exitDistance(box(-60, 0, 20, 20), box(0, 0, 100, 100, 45), LEFT)).toBeCloseTo(
|
||||
50 * Math.SQRT2 + 10 - 60,
|
||||
);
|
||||
expect(exitDistance(box(-60, 30, 20, 20), box(0, 0, 100, 100, 45), LEFT)).toBeCloseTo(
|
||||
(50 + 10 * Math.SQRT2 - 90 * Math.SQRT1_2) * Math.SQRT2,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user