diff --git a/README.md b/README.md index 312b7800..5ff95da8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/editor/PlaySession.ts b/src/editor/PlaySession.ts index b2cae1c5..7df7f12d 100644 --- a/src/editor/PlaySession.ts +++ b/src/editor/PlaySession.ts @@ -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'); diff --git a/src/editor/state.ts b/src/editor/state.ts index 3ee6250f..65a0dfcb 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -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 = 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; diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index 75eda446..633b0d84 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -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])); diff --git a/src/runtime/World.ts b/src/runtime/World.ts index 6a749961..b153209e 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -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 = 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; diff --git a/src/runtime/physics/PhysicsSystem.ts b/src/runtime/physics/PhysicsSystem.ts index d8726a48..caee2ffc 100644 --- a/src/runtime/physics/PhysicsSystem.ts +++ b/src/runtime/physics/PhysicsSystem.ts @@ -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); diff --git a/tests/core/physics-system.test.ts b/tests/core/physics-system.test.ts index 6552953c..20a53d7a 100644 --- a/tests/core/physics-system.test.ts +++ b/tests/core/physics-system.test.ts @@ -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); diff --git a/tools/e2e/41-physics.mjs b/tools/e2e/41-physics.mjs index de888d2f..42eafbc8 100644 --- a/tools/e2e/41-physics.mjs +++ b/tools/e2e/41-physics.mjs @@ -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;