Keep a dragged rigidbody on the mouse while the game plays
game-maker / gate (push) Successful in 2m55s
game-maker / suite (push) Successful in 9m16s

Dragging a sprite with a dynamic rigidbody in the scene view during
play barely moved it. Only a quick drag and release got through. A
dragged object should follow the mouse as if it had no rigidbody and
start from rest when it is let go.

- The scene view reports the dragged objects as held through
  EditorState. The game tick no longer writes its own position over a
  drag position that has not been read in yet. This affected every
  object in play, not only rigidbodies.
- A held dynamic body turns kinematic and is set straight to the drag
  target. Gravity and velocity no longer pull it away between two
  mouse moves, and the "moved directly" warning stays quiet.
- A body counts as held when the object itself, an object above it
  or its rigidbody is dragged.
- On release the body turns dynamic again with linear and angular
  velocity set to 0.

Not changed: a press on an unselected crate still lands on its
same-sized collider (rule from #203). Dragging while play is paused
and in the editor run is untouched.

Tests:
- ./check.sh: 155 files, 2273 tests, CHECK EXIT 0 (before 2271).
- e2e 41 alone: 21 checks. e2e 1-16,39-43: 641 checks, all passed.
- 2 unit tests: a held body stays where the drag puts it and starts
  from rest, also for a grip shorter than one physics step. The tick
  does not overwrite a drag position that is not read in yet.
- 1 e2e check in 41b: the crate is lifted 150 units with the real
  mouse, holds exactly at -3490 for a second, then falls from rest
  (1.3 px, then 5.1 px) with an empty log.
- Counter-checks: without holding in physics the crate falls back to
  the ground and the old warning shows. With the tick writing back
  the unit test fails. Without zeroing, a short grip keeps 490 px/s
  and the test fails.
- Check total 1356 + 1 = 1357, calculated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-23 21:16:06 +02:00
1 parent d444b27fae
commit d257298bc7
8 files changed
+136 -4

No files matched your search

+1 -1
View File
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1356 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1357 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+1
View File
@@ -234,6 +234,7 @@ export class PlaySession {
return;
}
const wasPaused = world.paused;
world.held = this.state.heldObjectIds;
world.tick(world.paused ? awake : step);
this.input.endFrame();
if (world.paused !== wasPaused) this.state.setNotice(this.playNotice(), 'quiet');
+2
View File
@@ -227,6 +227,8 @@ export class EditorState {
tilemapPaint: TilemapPaint = { tool: 'select', tileId: '', layerId: '', focus: 'all' };
/** The asset the last `pingAsset` pointed at. */
pingedAssetId: string | null = null;
/** Objects the scene view is dragging: a running game holds their bodies to the pointer. */
heldObjectIds: ReadonlySet<string> = new Set();
/** The real (saved) model and its undo stack while `project` is the play-mode copy. */
private edit: { project: Project; commands: CommandStack } | null = null;
+2
View File
@@ -240,6 +240,7 @@ export class SceneViewport extends FreeViewport {
const dy = (local.y - this.dragging.pointer.y) / this.view.zoom;
if (!this.dragging.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
this.dragging.moved = true;
this.state.heldObjectIds = new Set(this.dragging.items.map((item) => item.objectId));
const holder = this.holder();
if (!holder) return;
for (const item of this.dragging.items) {
@@ -321,6 +322,7 @@ export class SceneViewport extends FreeViewport {
}
const drag = this.dragging;
this.dragging = null;
this.state.heldObjectIds = new Set();
this.perFrame.cancel();
if (drag && !drag.moved && drag.onClick)
this.state.select(objectSelection(drag.holderId, [drag.onClick]));
+11
View File
@@ -81,6 +81,8 @@ export class World {
pressed: false,
released: false,
};
/** Objects the editor is dragging (ids): the physics puts them where the drag does, at rest. */
held: ReadonlySet<string> = new Set();
/** Messages logged by behaviors (Log node), newest last. */
readonly log: string[] = [];
/** The rays cast lately (#153), for the editor run to draw: where from, where to, whether they met anything. */
@@ -163,6 +165,13 @@ export class World {
return false;
}
/** Whether a drag holds the object: itself, an object above it, or its rigidbody is dragged. */
holds(entity: Entity): boolean {
if (this.held.size === 0) return false;
for (let e: Entity | null = entity; e; e = e.parent) if (this.held.has(e.objectId)) return true;
return this.childrenOf(entity).some((c) => c.type === 'rigidbody' && this.held.has(c.objectId));
}
/** The asset of a placed tilemap; undefined for anything else. */
private tilemapAssetOf(entity: Entity): TilemapAsset | undefined {
return entity.type === 'tilemap' ? findAssetOf(this.project, entity.assetId, 'tilemap') : undefined;
@@ -288,6 +297,8 @@ export class World {
if (asset && isPlaceableType(asset.type) && !alive.has(object.id)) scene.objects.splice(i, 1);
}
for (const object of resolveObjects(this.project, scene)) {
// A dragged object's place belongs to the drag, which reaches the entity on the next re-read.
if (this.held.has(object.id)) continue;
const entity = this.findByObjectId(object.id);
const asset = entity && findAsset(this.project, entity.assetId);
if (!entity || !asset) continue;
+16 -2
View File
@@ -88,6 +88,8 @@ interface Body {
turns: boolean;
/** The mass it carries of its own because no collider gives it one (kg, 0 = none). */
ownMass: number;
/** A dynamic body the editor is dragging: kinematic until let go, then at rest (`World.holds`). */
held: boolean;
/** Where the pose was last put, by us or by the object moving. */
placed: Pose;
/** A dynamic body's pose before and after the last step, for drawing in between. */
@@ -426,6 +428,7 @@ export class PhysicsSystem implements System, CollisionSource {
settingsKey: '',
turns: false,
ownMass: 0,
held: false,
placed: pose,
previous: pose,
current: pose,
@@ -451,21 +454,32 @@ export class PhysicsSystem implements System, CollisionSource {
/**
* Puts the body where its object is. A dynamic body is only moved when something else moved the
* object since we last wrote it – that is a teleport, and the body keeps its velocity (#146, 3).
* One the editor drags goes where the drag puts it, as if it had no rigidbody, and is let go at rest.
*/
private place(body: Body, want: Wanted, key: Entity, world: World): void {
const settings = want.settings;
const held = settings?.type === 'dynamic' && world.holds(key);
const letGo = body.held && !held;
if (held !== body.held) {
body.held = held;
body.settingsKey = '';
}
const settingsKey = settings ? JSON.stringify(settings) + hasTransformProps(key.type) : '';
if (settings && settingsKey !== body.settingsKey) {
body.settingsKey = settingsKey;
this.applySettings(body, settings, key);
}
if (letGo) {
body.body.setLinvel({ x: 0, y: 0 }, true);
body.body.setAngvel(0, true);
}
const on = isHandleEnabled(key);
if (body.body.isEnabled() !== on) body.body.setEnabled(on);
const pose = this.poseWanted(want, key);
if (samePose(pose, body.placed)) return;
body.placed = pose;
const t = { x: toMetres(pose.x), y: toMetres(pose.y) };
if (body.body.isKinematic() && want.kind === 'rigid') {
if (body.body.isKinematic() && want.kind === 'rigid' && !body.held) {
body.body.setNextKinematicTranslation(t);
body.body.setNextKinematicRotation(pose.angle);
return;
@@ -485,7 +499,7 @@ export class PhysicsSystem implements System, CollisionSource {
private applySettings(target: Body, settings: RigidbodySettings, owner: Entity): void {
const body = target.body;
body.setBodyType(this.bodyType(settings.type), true);
body.setBodyType(this.bodyType(target.held ? 'kinematic' : settings.type), true);
body.setGravityScale(settings.gravityScale, true);
body.setLinearDamping(settings.linearDamping);
body.setAngularDamping(settings.angularDamping);
+54
View File
@@ -641,6 +641,60 @@ describe('physics from the graph (#147)', () => {
]);
});
it('a dragged dynamic body goes where the drag puts it, and is let go at rest', () => {
const f = physicsScene();
f.gravity(0, 980);
const box = f.add(0, 0, { w: 32, body: {} });
const { seconds, frame, entity, physics, world } = run(f);
seconds(0.5);
expect(physics.velocityOf(entity(box))!.y).toBeGreaterThan(400);
world.held = new Set([box]);
seconds(0.5, () => {
entity(box).x += 4;
entity(box).y = -50;
});
seconds(0.25);
expect(entity(box)).toMatchObject({ x: 120, y: -50 });
world.held = new Set();
seconds(1 / 60);
expect(Math.abs(entity(box).y + 50)).toBeLessThan(1);
const velocity = physics.velocityOf(entity(box))!;
expect(velocity.x).toBe(0);
expect(velocity.y).toBeCloseTo(980 / 60, 3);
// Dragging the rigidbody holds the body it gives the object just as well.
world.held = new Set([f.rigidbodyOf(box)]);
const y = entity(box).y;
seconds(0.25);
expect(entity(box).y).toBe(y);
// A grab too short for a physics step lets go at rest as well.
world.held = new Set();
seconds(0.5);
world.held = new Set([box]);
frame(0.001);
world.held = new Set();
frame(0.001);
expect(physics.velocityOf(entity(box))).toEqual({ x: 0, y: 0 });
expect(world.log).toEqual([]);
});
it('a tick does not write the old place back over a drag before the drag is read', () => {
const f = physicsScene();
f.gravity(0, 980);
const box = f.add(0, 0, { w: 32, body: {} });
const { seconds, entity, world } = run(f);
seconds(0.25);
// As the scene view does it: the drag writes the model, the game ticks and mirrors itself into
// the model, and only then does the change event make the world re-read it.
world.held = new Set([box]);
f.object(box).position = { x: 80, y: -40, z: 0 };
seconds(1 / 60);
world.syncToModel();
world.syncFromModel();
seconds(1 / 60);
expect(entity(box)).toMatchObject({ x: 80, y: -40 });
expect(f.object(box).position).toMatchObject({ x: 80, y: -40 });
});
it('in the editor run the physics nodes do nothing', () => {
const f = physicsScene();
f.gravity(0, 0);
+49 -1
View File
@@ -69,7 +69,7 @@ const rollGraph = {
export async function physics(t) {
const { check, json, waitUntil, consoleLog, evaluate, mutate, select, click, clickText, press, typeText } =
t;
const { has, object, asset, objects, state, settle, sleep, play, stop, shot } = t;
const { has, object, asset, objects, state, settle, sleep, play, stop, shot, mouse, showTab } = t;
await waitUntil(`['ready', 'failed'].includes(__gm.physics().state)`, { timeout: 15000 });
const status = await json('__gm.physics()');
@@ -271,6 +271,54 @@ export async function physics(t) {
(await object('phys-crate'))?.position.y === Y && (await object('phys-ball'))?.position.y === Y,
);
// A body dragged in the scene view while the game runs sticks to the pointer as if it had no
// rigidbody: lifted off the ground it hangs where it is held, and is let go at rest.
const LIFT = 150;
await play();
await sleep(1500);
await showTab('scene');
await selectObject('phys-crate');
await settle();
const grab = await json(`__gm.objectRect('phys-crate')`);
const scale = grab.w / 40;
// Below the middle, where the collider's and the rigidbody's icons are not.
const from = { x: grab.x, y: grab.y + grab.h * 0.35 };
const to = { x: from.x, y: from.y - LIFT * scale };
await mouse('mouseMoved', from.x, from.y);
await mouse('mousePressed', from.x, from.y, 'left');
for (let i = 1; i <= 10; i++) {
await mouse('mouseMoved', from.x, from.y + ((to.y - from.y) * i) / 10, 'left', { buttons: 1 });
await sleep(16);
}
const crateY = "__gm.world()?.entities.find((e) => e.objectId === 'phys-crate')?.worldY";
const sampleCrate = async (ms) => {
const ys = [];
const start = Date.now();
while (Date.now() - start < ms) {
ys.push(await evaluate(crateY));
await sleep(50);
}
return ys;
};
const held = await sampleCrate(1000);
await shot('41-drag-while-playing');
const dragInfo = { grab, selected: (await state()).selection?.objectId };
await mouse('mouseReleased', to.x, to.y, 'left');
const released = await sampleCrate(150);
const log = (await json('__gm.world()'))?.log ?? [];
await stop();
const target = REST - LIFT;
check(
'a body dragged while playing hangs where the pointer holds it and is let go at rest',
held.length > 10 &&
held.every((y) => Math.abs(y - target) < 1.5) &&
Math.abs(released[0] - target) < 3 &&
released.at(-1) > target + 1 &&
released.at(-1) < target + 40 &&
!log.some((line) => line.includes('moved directly')),
JSON.stringify({ target, held: [held[0], held.at(-1)], released, log, dragInfo }),
);
const bodies = [crateBody?.id, ballBody?.id].filter(Boolean);
await mutate(`
const p = __gm.state.project;