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>
286 lines
9.7 KiB
TypeScript
286 lines
9.7 KiB
TypeScript
/**
|
||
* Owns everything that only exists while the project is playing: the world, its systems, the
|
||
* keyboard and the per-frame step. Play never touches the saved model – the world is built
|
||
* from the runtime copy `state.enterPlay()` swaps in and `exitPlay()` throws away.
|
||
*/
|
||
import { emptySharedState, type SharedState, worldPositionOf } from '../core/graph';
|
||
import {
|
||
addGameSystems,
|
||
type BehaviorSystem,
|
||
behaviorError,
|
||
type Trace,
|
||
} from '../runtime/systems/BehaviorSystem';
|
||
import type { AnimationPlayer } from '../core/model/animation';
|
||
import type { PropertyBag } from '../core/model/Properties';
|
||
import { gameClock } from '../core/time';
|
||
import { loadPhysics, readyPhysics } from '../runtime/physics/engine';
|
||
import { Input } from '../runtime/systems/Input';
|
||
import { World } from '../runtime/World';
|
||
import { ui } from '../ui';
|
||
import { soundLibrary } from '../viewport/SoundLibrary';
|
||
import { saveArea } from './saves';
|
||
import type { GameViewport } from './viewport/GameViewport';
|
||
import type { GraphEditor } from './graph/GraphEditor';
|
||
import type { LiveMachineState } from './graph/GraphCanvas';
|
||
import type { StateMachineEditor } from './statemachine/StateMachineEditor';
|
||
import type { EditorState } from './state';
|
||
|
||
/** Facts about the running world for the check scripts. */
|
||
export interface WorldSnapshot {
|
||
entities: {
|
||
objectId: string;
|
||
assetId: string;
|
||
name: string;
|
||
x: number;
|
||
y: number;
|
||
worldX: number;
|
||
worldY: number;
|
||
parent: string;
|
||
props: Record<string, unknown>;
|
||
animations: AnimationPlayer[];
|
||
}[];
|
||
/** What `global` declarations share this run – no entity carries it, so it is reported apart. */
|
||
shared: SharedState;
|
||
log: string[];
|
||
}
|
||
|
||
export interface PlayHost {
|
||
gameView: GameViewport;
|
||
graphEditor: GraphEditor;
|
||
stateMachineEditor: StateMachineEditor;
|
||
showGame(): void;
|
||
/** Leave the game view if it is the one on screen. */
|
||
leaveGame(): void;
|
||
}
|
||
|
||
export class PlaySession {
|
||
private world: World | null = null;
|
||
private behaviors: BehaviorSystem | null = null;
|
||
private readonly input = new Input();
|
||
/** Set while Play waits for the physics engine, so a second press does not queue a second run. */
|
||
private waiting = false;
|
||
|
||
constructor(
|
||
private readonly state: EditorState,
|
||
private readonly host: PlayHost,
|
||
) {}
|
||
|
||
get isPlaying(): boolean {
|
||
return this.world !== null;
|
||
}
|
||
|
||
get isPaused(): boolean {
|
||
return this.world !== null && this.state.paused;
|
||
}
|
||
|
||
/** Paused by a behavior (Pause game) rather than by the toolbar – the game's own pause. */
|
||
get isGamePaused(): boolean {
|
||
return this.world?.paused ?? false;
|
||
}
|
||
|
||
/** Pause or resume; does nothing while not playing. */
|
||
togglePause(): void {
|
||
if (!this.world) return;
|
||
this.state.setPaused(!this.state.paused);
|
||
// A held note is part of the picture a pause freezes: the sounds stop where they are and carry
|
||
// on mid-note, rather than playing to their end over a still image.
|
||
soundLibrary.setSuspended(this.state.paused);
|
||
ui().focus.blur();
|
||
this.state.setNotice(this.playNotice(), 'quiet');
|
||
}
|
||
|
||
traceFor(assetId: string): Trace | null {
|
||
return this.behaviors?.traceFor(assetId) ?? null;
|
||
}
|
||
|
||
/** Which state each running instance of a machine holds; empty when nothing is playing. */
|
||
machineStates(machineId: string): LiveMachineState[] {
|
||
return this.behaviors?.machineStates(machineId) ?? [];
|
||
}
|
||
|
||
get time(): number {
|
||
return this.world?.time ?? 0;
|
||
}
|
||
|
||
/** The scene the run is in – not the editor's active one, which a `Load scene` leaves alone. */
|
||
get sceneId(): string {
|
||
return this.world?.sceneId ?? '';
|
||
}
|
||
|
||
syncFromModel(): void {
|
||
this.world?.syncFromModel();
|
||
this.behaviors?.syncDeclarations();
|
||
}
|
||
|
||
/** The values a Variables asset shares in this run; null when nothing is playing. */
|
||
liveVariables(assetId: string): PropertyBag | null {
|
||
return this.behaviors?.liveVariables(assetId) ?? null;
|
||
}
|
||
|
||
play(): void {
|
||
const scene = this.state.sceneToPlay();
|
||
if (!scene) return;
|
||
// The editor loads the engine right after opening, so it is almost always there already; a game
|
||
// without it would collide differently, so Play waits for it rather than running without (#146).
|
||
const physics = readyPhysics();
|
||
if (!physics) {
|
||
this.waitForPhysics();
|
||
return;
|
||
}
|
||
ui().focus.blur();
|
||
// A run starts at second zero, whatever the editor's previews have been counting up to.
|
||
gameClock.reset();
|
||
const runtime = this.state.enterPlay();
|
||
const world = World.instantiate(runtime, scene.id);
|
||
this.world = world;
|
||
this.input.attach();
|
||
this.behaviors = addGameSystems(world, runtime, this.input, {
|
||
audio: soundLibrary,
|
||
physics,
|
||
clock: gameClock,
|
||
saves: saveArea(runtime.id, 'play'),
|
||
});
|
||
this.host.gameView.setWorld(world);
|
||
this.host.showGame();
|
||
this.state.setNotice(this.playNotice(), 'quiet');
|
||
if (world.stopRequested) this.stop();
|
||
}
|
||
|
||
/**
|
||
* Swaps the running scene for another one, inside the same run: the world is torn down and built
|
||
* again, while what belongs to the run – the clock, the shared variables, the global machines –
|
||
* carries on. The sounds of the old scene end with it, because the behaviors playing them are
|
||
* destroyed; a voice that was started detached is nobody's and plays on.
|
||
*/
|
||
private loadScene(sceneId: string): void {
|
||
const old = this.world;
|
||
const runtime = this.state.runtimeProject;
|
||
if (!old || !runtime) return;
|
||
const carried = this.behaviors?.shared ?? null;
|
||
const physics = readyPhysics();
|
||
old.destroy();
|
||
const world = World.instantiate(runtime, sceneId);
|
||
this.world = world;
|
||
this.behaviors = addGameSystems(world, runtime, this.input, {
|
||
audio: soundLibrary,
|
||
carried,
|
||
physics,
|
||
clock: gameClock,
|
||
saves: saveArea(runtime.id, 'play'),
|
||
});
|
||
this.host.gameView.setWorld(world);
|
||
this.state.setNotice(this.playNotice(), 'quiet');
|
||
if (world.stopRequested) this.stop();
|
||
}
|
||
|
||
private waitForPhysics(): void {
|
||
if (this.waiting) return;
|
||
this.waiting = true;
|
||
this.state.setNotice('Loading the physics engine…', 'quiet');
|
||
loadPhysics().then(
|
||
() => {
|
||
this.waiting = false;
|
||
if (this.state.mode === 'edit') this.play();
|
||
},
|
||
(error: unknown) => {
|
||
this.waiting = false;
|
||
const reason = error instanceof Error ? error.message : String(error);
|
||
console.error('[physics] Play needs the physics engine, which could not be loaded:', error);
|
||
this.state.setNotice(`Play needs the physics engine, which could not be loaded: ${reason}`, 'error');
|
||
},
|
||
);
|
||
}
|
||
|
||
private playNotice(): string {
|
||
if (this.state.paused) return 'Paused · Ctrl+Enter resumes · Esc stops';
|
||
if (this.world?.paused) return 'The game paused itself · Esc stops';
|
||
return this.world?.activeCamera()
|
||
? 'Playing · edits are not saved · Ctrl+Enter pauses · Esc stops'
|
||
: 'No camera is switched on – nothing is rendered · Esc stops';
|
||
}
|
||
|
||
stop(): void {
|
||
// The run's seconds end with it: the editor goes back to the picture of a freshly loaded scene.
|
||
gameClock.reset();
|
||
// And its sounds end with it, looping music included – nothing may outlive the world.
|
||
soundLibrary.setSuspended(false);
|
||
soundLibrary.stopAll();
|
||
const world = this.world;
|
||
this.world = null;
|
||
this.behaviors = null;
|
||
this.input.detach();
|
||
ui().focus.blur();
|
||
world?.destroy();
|
||
this.host.gameView.setWorld(null);
|
||
this.state.exitPlay();
|
||
this.host.leaveGame();
|
||
const lastError = world && behaviorError(world);
|
||
this.state.setNotice(lastError ?? '', 'error');
|
||
}
|
||
|
||
/**
|
||
* `step` is what the global clock moved this frame – already scaled and capped. `awake` is the
|
||
* step it would have moved without the game's own pause, which stops the clock but not the
|
||
* behaviors that run while paused.
|
||
*/
|
||
tick(step: number, awake: number): void {
|
||
const world = this.world;
|
||
if (!world) return;
|
||
if (this.state.paused) {
|
||
// Keys and clicks that happen while paused are dropped instead of piling up – otherwise
|
||
// resuming would deliver every one of them in a single frame.
|
||
this.input.endFrame();
|
||
world.dropPointerEdges();
|
||
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');
|
||
if (world.stopRequested) {
|
||
this.stop();
|
||
return;
|
||
}
|
||
if (world.restartRequested) {
|
||
this.stop();
|
||
this.play();
|
||
return;
|
||
}
|
||
if (world.loadSceneRequested) {
|
||
this.loadScene(world.loadSceneRequested);
|
||
return;
|
||
}
|
||
// Mirror what the systems did into the runtime copy so the panels show live values.
|
||
if (world.syncToModel().structure) this.state.preview();
|
||
this.state.runtimeTick();
|
||
if (this.host.graphEditor.visible) this.host.graphEditor.canvas.redraw();
|
||
// The machine tab shows the state a run is in, so it repaints for the same reason the graph does.
|
||
if (this.host.stateMachineEditor.visible) this.host.stateMachineEditor.canvas.redraw();
|
||
}
|
||
|
||
snapshot(): WorldSnapshot | null {
|
||
const world = this.world;
|
||
if (!world) return null;
|
||
return {
|
||
entities: world.entities.map((e) => {
|
||
const w = worldPositionOf(e);
|
||
return {
|
||
objectId: e.objectId,
|
||
assetId: e.assetId,
|
||
name: e.name,
|
||
x: e.x,
|
||
y: e.y,
|
||
worldX: w.x,
|
||
worldY: w.y,
|
||
parent: e.parent?.objectId ?? '',
|
||
props: { ...e.props },
|
||
animations: structuredClone(e.animations ?? []),
|
||
};
|
||
}),
|
||
shared: this.behaviors?.shared ?? emptySharedState(),
|
||
log: [...world.log],
|
||
};
|
||
}
|
||
}
|