Remove comments that only restate the code, keeping the section banners and the notes that carry a reason, a measured fact or an external contract. ButtonOptions.width claimed to be a fixed width but only set minWidth, so the Graph and Texture pickers grew with the name they showed. It is now what it promises, with a separate minWidth for the buttons that have two forms. Narrow buttons shrink their padding, otherwise a 24px icon button had 8px left for its glyph and truncate dropped it entirely. __gm.labelsDropped() guards that class: the overflow guard only saw text that was too wide, never a label truncated to nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
145 lines
4.0 KiB
TypeScript
145 lines
4.0 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 { worldPositionOf } from '../core/graph';
|
||
import { BehaviorSystem, type Trace } from '../runtime/systems/BehaviorSystem';
|
||
import { CollisionSystem } from '../runtime/systems/CollisionSystem';
|
||
import { Input } from '../runtime/systems/Input';
|
||
import { World } from '../runtime/World';
|
||
import { ui } from '../ui';
|
||
import type { GameViewport } from '../viewport/GameViewport';
|
||
import type { GraphEditor } from './graph/GraphEditor';
|
||
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>;
|
||
}[];
|
||
log: string[];
|
||
}
|
||
|
||
export interface PlayHost {
|
||
gameView: GameViewport;
|
||
graphEditor: GraphEditor;
|
||
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();
|
||
|
||
constructor(
|
||
private readonly state: EditorState,
|
||
private readonly host: PlayHost,
|
||
) {}
|
||
|
||
get isPlaying(): boolean {
|
||
return this.world !== null;
|
||
}
|
||
|
||
traceFor(assetId: string): Trace | null {
|
||
return this.behaviors?.traceFor(assetId) ?? null;
|
||
}
|
||
|
||
get time(): number {
|
||
return this.world?.time ?? 0;
|
||
}
|
||
|
||
syncFromModel(): void {
|
||
this.world?.syncFromModel();
|
||
}
|
||
|
||
play(): void {
|
||
const scene = this.state.activeScene;
|
||
if (!scene) return;
|
||
ui().focus.blur();
|
||
const runtime = this.state.enterPlay();
|
||
const world = World.instantiate(runtime, scene.id);
|
||
this.world = world;
|
||
this.input.attach();
|
||
// Collisions are detected first so behaviors see this frame's enter/stay/exit events.
|
||
const collisions = new CollisionSystem();
|
||
this.behaviors = new BehaviorSystem(runtime, this.input, collisions);
|
||
world.addSystem(collisions);
|
||
world.addSystem(this.behaviors);
|
||
this.host.gameView.setWorld(world);
|
||
this.host.showGame();
|
||
this.state.setNotice(
|
||
world.firstOfType('camera')
|
||
? 'Playing · edits are not saved · Esc stops'
|
||
: 'This scene has no camera – nothing is rendered · Esc stops',
|
||
);
|
||
if (world.stopRequested) this.stop();
|
||
}
|
||
|
||
stop(): void {
|
||
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?.log.find((line) => line.startsWith('Error in behavior'));
|
||
this.state.setNotice(lastError ?? '');
|
||
}
|
||
|
||
tick(dt: number): void {
|
||
const world = this.world;
|
||
if (!world) return;
|
||
world.tick(Math.min(dt, 0.1));
|
||
this.input.endFrame();
|
||
if (world.stopRequested) {
|
||
this.stop();
|
||
return;
|
||
}
|
||
if (world.restartRequested) {
|
||
this.stop();
|
||
this.play();
|
||
return;
|
||
}
|
||
// Mirror what the systems did into the runtime copy so the panels show live values.
|
||
if (world.syncToModel()) this.state.preview();
|
||
this.state.runtimeTick();
|
||
if (this.host.graphEditor.visible) this.host.graphEditor.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 },
|
||
};
|
||
}),
|
||
log: [...world.log],
|
||
};
|
||
}
|
||
}
|