Physics stage 1a runs on Rapier: a new Rigidbody child object (body type, mass, gravity scale, damping, fixed rotation, CCD) makes its parent fall, bounce and come to rest in play, colliders gain friction, bounciness and density, the scene gains gravity (0, 980), the new PhysicsSystem steps at a fixed 60 Hz with interpolation in world coordinates and treats a tilemap as static ground, On collision enter gains normal and impulse, play waits for Rapier and does not start without it, and the editor run keeps the old CollisionSystem; the existing collision tests run against both systems unchanged (#146)
game-maker / gate (push) Successful in 1m46s
game-maker / suite (push) Successful in 6m17s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-18 18:11:43 +02:00
1 parent d02bcca266
commit 78899b1ace
34 files changed
+2291 -676

No files matched your search

+2
View File
@@ -35,6 +35,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
return { ...base, type: 'variables', variables: [] };
case 'collider':
return { ...base, type: 'collider' };
case 'rigidbody':
return { ...base, type: 'rigidbody' };
case 'container':
return { ...base, type: 'container', objects: [] };
case 'animation':
+49 -2
View File
@@ -1,7 +1,7 @@
import { newId } from '../ids';
import { isLiveInstance } from '../model/instance';
import type { Project } from '../model/Project';
import { createSceneObject } from '../model/SceneObject';
import { findAsset, type ObjectHolder, type Project } from '../model/Project';
import { createSceneObject, type SceneObject } from '../model/SceneObject';
import { resolveSceneObjectProps } from '../properties/resolve';
import { removeById } from '../util/list';
import { buildAsset } from './assetCommands';
@@ -51,3 +51,50 @@ export class AddColliderCommand implements Command {
project.assets = removeById(project.assets, this.assetId);
}
}
/** Why the object cannot take a rigidbody, or null when it can – the one rule command and menu share. */
export function rigidbodyRefusal(project: Project, holder: ObjectHolder, target: SceneObject): string | null {
const type = findAsset(project, target.assetId)?.type;
if (type === 'collider' || type === 'rigidbody' || type === 'behavior')
return `A ${type} cannot have a rigidbody`;
if (type === 'tilemap') return 'A tilemap is static ground and takes no rigidbody';
if (isLiveInstance(project, holder, target))
return 'A placed container shows its template; add the rigidbody inside it';
const taken = holder.objects.some(
(o) => o.parentId === target.id && findAsset(project, o.assetId)?.type === 'rigidbody',
);
return taken ? 'The object has a rigidbody already' : null;
}
/** Creates a rigidbody asset with the schema defaults and places it as a child at the object's centre. */
export class AddRigidbodyCommand implements Command {
readonly label = 'Add rigidbody';
readonly assetId = newId();
readonly objectId = newId();
constructor(
private readonly holderId: string,
private readonly targetId: string,
) {}
execute(project: Project): void {
const holder = requireHolder(project, this.holderId);
const target = requireStructuralObject(project, holder, this.targetId);
const targetAsset = requireAsset(project, target.assetId);
const refusal = rigidbodyRefusal(project, holder, target);
if (refusal) throw new CommandError(refusal);
const asset = buildAsset(
'rigidbody',
nextAssetName(project, `${target.name || targetAsset.name} rigidbody`),
this.assetId,
);
project.assets.push(asset);
holder.objects.push(createSceneObject(this.objectId, this.assetId, { x: 0, y: 0, z: 0 }, this.targetId));
}
undo(project: Project): void {
const holder = requireHolder(project, this.holderId);
holder.objects = removeById(holder.objects, this.objectId);
project.assets = removeById(project.assets, this.assetId);
}
}
@@ -1,12 +1,15 @@
import type { NodeDef } from '../../NodeRegistry';
import { collisionOutputs } from '../shared';
import { collisionOutputs, contactOutputs } from '../shared';
export const OnCollisionEnter: NodeDef = {
type: 'OnCollisionEnter',
label: 'On collision enter',
category: 'event',
description: 'Runs once when a collider of the owner starts overlapping another collider.',
description:
'Runs once when a collider of the owner starts overlapping another collider. While playing, ' +
'normal x/y point from the other collider towards the own one and impulse says how hard they met ' +
'(both 0 for a trigger).',
inputs: [],
outputs: collisionOutputs(),
outputs: [...collisionOutputs(), ...contactOutputs()],
params: [],
};
+10
View File
@@ -31,6 +31,16 @@ export const collisionOutputs = (): PortDef[] => [
obj('collider'),
];
/**
* What only the start of a contact knows: which way the other side pushes (a unit vector pointing
* from it towards the own collider) and how hard they met. The physics engine works both out (#146).
*/
export const contactOutputs = (): PortDef[] => [
num('normalX', 'normal x'),
num('normalY', 'normal y'),
num('impulse'),
];
export const keyParam: PropertyDef = { key: 'key', label: 'Key', kind: 'key', default: 'ArrowRight' };
/** No `assetTypes`: any placeable asset is a valid answer here. */
+1
View File
@@ -19,6 +19,7 @@ export const PROPERTY_TARGETS = [
'sprite',
'text',
'collider',
'rigidbody',
'camera',
'container',
'behavior',
+9
View File
@@ -51,6 +51,14 @@ export interface ColliderAsset extends AssetBase {
type: 'collider';
}
/**
* Placed as a child of the object it moves – at most one per object. Its props say how the physics
* engine treats that object: falling, resting or pushed along by the graph (see `properties/rigidbody.ts`).
*/
export interface RigidbodyAsset extends AssetBase {
type: 'rigidbody';
}
/**
* `objects` is a template, not content: a placed container shows it without copying it, so an
* edit here reaches every instance (see `model/instance.ts`). Positions are local to the
@@ -236,6 +244,7 @@ export type Asset =
| BehaviorAsset
| VariablesAsset
| ColliderAsset
| RigidbodyAsset
| ContainerAsset
| TextureAsset
| AnimationAsset
+9 -1
View File
@@ -6,6 +6,7 @@ export const ASSET_TYPES = [
'behavior',
'variables',
'collider',
'rigidbody',
'container',
'texture',
'animation',
@@ -28,6 +29,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
behavior: 'Behavior',
variables: 'Variables',
collider: 'Collider',
rigidbody: 'Rigidbody',
container: 'Container',
texture: 'Texture',
animation: 'Animation',
@@ -75,7 +77,13 @@ export function isAssetType(value: unknown): value is AssetType {
/** Types drawn as an icon in the overlay layer instead of as scene content. */
export function isSymbolType(type: AssetType): boolean {
return type === 'camera' || type === 'behavior' || type === 'collider' || type === 'container';
return (
type === 'camera' ||
type === 'behavior' ||
type === 'collider' ||
type === 'rigidbody' ||
type === 'container'
);
}
/**
+30 -1
View File
@@ -181,7 +181,12 @@ export function isValidVariableName(name: string): boolean {
/** Position is deliberately absent: it belongs to scene objects, not to asset types. */
const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
scene: [{ key: 'backgroundColor', label: 'Background', kind: 'color', default: 0x2a2d34 }],
scene: [
{ key: 'backgroundColor', label: 'Background', kind: 'color', default: 0x2a2d34 },
// Pulls on every dynamic rigidbody, in world units per second squared: 980 is Earth at 100 px = 1 m.
{ key: 'gravityX', label: 'Gravity X', kind: 'number', default: 0, step: 10 },
{ key: 'gravityY', label: 'Gravity Y', kind: 'number', default: 980, step: 10 },
],
camera: [
// A view is a pixel raster, so half a pixel of it means nothing.
{ key: 'width', label: 'View width', kind: 'number', default: 640, min: 16, step: 1, decimals: 0 },
@@ -350,6 +355,30 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
{ key: 'height', label: 'Height', kind: 'number', default: 100, min: 1, step: 1 },
{ key: 'radius', label: 'Radius', kind: 'number', default: 50, min: 1, step: 1 },
{ key: 'isTrigger', label: 'Trigger (no blocking)', kind: 'boolean', default: false },
// The material, read by the physics engine where a rigidbody takes part (#146).
{ key: 'friction', label: 'Friction', kind: 'number', default: 0.5, min: 0, step: 0.05 },
{ key: 'bounciness', label: 'Bounciness', kind: 'number', default: 0, min: 0, max: 1, step: 0.05 },
{ key: 'density', label: 'Density', kind: 'number', default: 1, min: 0, step: 0.1 },
],
rigidbody: [
{
key: 'bodyType',
label: 'Body type',
kind: 'enum',
default: 'dynamic',
options: [
{ value: 'dynamic', label: 'Dynamic' },
{ value: 'static', label: 'Static' },
{ value: 'kinematic', label: 'Kinematic' },
],
},
// 0 leaves the mass to the colliders' density and size; anything else is the mass itself.
{ key: 'mass', label: 'Mass (0 = auto)', kind: 'number', default: 0, min: 0, step: 0.1 },
{ key: 'gravityScale', label: 'Gravity scale', kind: 'number', default: 1, step: 0.1 },
{ key: 'linearDamping', label: 'Linear damping', kind: 'number', default: 0, min: 0, step: 0.1 },
{ key: 'angularDamping', label: 'Angular damping', kind: 'number', default: 0, min: 0, step: 0.1 },
{ key: 'fixedRotation', label: 'Fixed rotation', kind: 'boolean', default: false },
{ key: 'ccd', label: 'Continuous (CCD)', kind: 'boolean', default: false },
],
// A container is pure structure: its content is the template, not a property.
container: [],
+3
View File
@@ -26,6 +26,7 @@ const TEXT_DEFAULTS = defaultsFor('text');
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;
@@ -61,6 +62,8 @@ export function footprintOf(type: AssetType, props: PropertyBag): { w: number; h
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':
+61
View File
@@ -0,0 +1,61 @@
/** What the physics engine reads off a rigidbody, a collider's material and the scene (#146). */
import type { PropertyBag, Vec2 } from '../model/Properties';
import { defaultsFor } from './PropertySchema';
const BODY = defaultsFor('rigidbody');
const COLLIDER = defaultsFor('collider');
const SCENE = defaultsFor('scene');
/**
* `dynamic` falls and is pushed, `static` never moves on its own, `kinematic` goes where the graph
* puts it and pushes what is in the way.
*/
export type BodyType = 'dynamic' | 'static' | 'kinematic';
export interface RigidbodySettings {
type: BodyType;
/** 0: the colliders' density decides. */
mass: number;
gravityScale: number;
linearDamping: number;
angularDamping: number;
fixedRotation: boolean;
ccd: boolean;
}
export interface ColliderMaterial {
friction: number;
bounciness: number;
density: number;
}
function num(value: unknown, fallback: unknown): number {
const n = Number(value ?? fallback);
return Number.isFinite(n) ? n : Number(fallback);
}
export function rigidbodySettings(props: PropertyBag): RigidbodySettings {
const type = props.bodyType;
return {
type: type === 'static' || type === 'kinematic' ? type : 'dynamic',
mass: Math.max(0, num(props.mass, BODY.mass)),
gravityScale: num(props.gravityScale, BODY.gravityScale),
linearDamping: Math.max(0, num(props.linearDamping, BODY.linearDamping)),
angularDamping: Math.max(0, num(props.angularDamping, BODY.angularDamping)),
fixedRotation: Boolean(props.fixedRotation ?? BODY.fixedRotation),
ccd: Boolean(props.ccd ?? BODY.ccd),
};
}
export function colliderMaterial(props: PropertyBag): ColliderMaterial {
return {
friction: Math.max(0, num(props.friction, COLLIDER.friction)),
bounciness: Math.min(1, Math.max(0, num(props.bounciness, COLLIDER.bounciness))),
density: Math.max(0, num(props.density, COLLIDER.density)),
};
}
/** World units per second squared. */
export function sceneGravity(props: PropertyBag): Vec2 {
return { x: num(props.gravityX, SCENE.gravityX), y: num(props.gravityY, SCENE.gravityY) };
}