Editing while playing: play runs on a runtime copy, new Game tab (Scene | Game | Graph)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-07 16:34:26 +02:00
1 parent cb8885359f
commit c3a533c4e5
14 files changed
+408 -118

No files matched your search

+39 -18
View File
@@ -60,16 +60,28 @@ interface View {
const GRID_STEP = 100;
const LETTERBOX_COLOR = 0x000000;
export interface ViewportOptions {
/**
* Game view: draws the running world through the scene's camera, no editing. The default
* (scene view) draws the model – in play mode that is the runtime copy, so it shows the
* live positions and stays fully editable.
*/
game?: boolean;
}
/**
* Draws the active scene (edit mode) or the running world (play mode) as coloured boxes.
* Edit mode adds a grid, selection outline, drag-to-move, middle-drag panning and wheel
* zoom. In play mode the scene's camera defines what is visible: only its frame is drawn,
* everything outside it is masked and not rendered at all. Without a camera play mode
* renders nothing.
* Draws a scene as coloured boxes. The **scene view** shows the model with a grid, selection
* outline, drag-to-move, middle-drag panning and wheel zoom – in edit and play mode alike.
* The **game view** shows the running world: the scene's camera defines what is visible,
* only its frame is drawn (letterboxed), everything outside it is masked and not rendered
* at all. Without a camera the game view renders nothing.
*/
export class Viewport extends Widget {
private readonly game: boolean;
private readonly bg = new Graphics();
private readonly grid = new Graphics();
/** Game view only: shown while no world is running. */
private readonly hint = new Text({ text: 'Press ▶ Play to run the scene here', style: textStyle('muted') });
/** Everything that may extend past the viewport (world and gizmos) is clipped through `content`. */
private readonly content = new Container();
private readonly world = new Container();
@@ -93,23 +105,27 @@ export class Viewport extends Widget {
private dragging: { objectId: string; sceneId: string; start: Position; pointer: Vec2; moved: boolean } | null = null;
private visibleCount = 0;
constructor(private readonly state: EditorState) {
constructor(private readonly state: EditorState, options: ViewportOptions = {}) {
super();
this.game = options.game ?? false;
this.world.addChild(this.objects, this.links, this.overlay);
// Decorations must not swallow hits meant for the objects beneath them.
this.links.eventMode = 'none';
this.hint.eventMode = 'none';
this.hint.anchor.set(0.5);
this.content.addChild(this.world, this.gizmos);
this.addChild(this.bg, this.grid, this.content, this.contentMask);
this.addChild(this.bg, this.grid, this.content, this.contentMask, this.hint);
this.content.mask = this.contentMask;
this.cursor = 'default';
this.on('pointerdown', (e: FederatedPointerEvent) => {
if (this.game) return;
if (e.button === 1 || (e.button === 0 && e.target === this && e.shiftKey)) {
this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y };
e.stopPropagation();
return;
}
if (e.button === 0 && e.target === this && this.state.mode === 'edit') this.state.select(null);
if (e.button === 0 && e.target === this) this.state.select(null);
});
this.on('globalpointermove', (e: FederatedPointerEvent) => {
if (this.panning) {
@@ -143,7 +159,7 @@ export class Viewport extends Widget {
this.on('pointerup', finish);
this.on('pointerupoutside', finish);
this.on('wheel', (e: FederatedWheelEvent) => {
if (this.state.mode !== 'edit') return;
if (this.game) return;
const local = this.toLocal(e.global);
const before = this.screenToWorld(local.x, local.y);
const factor = Math.exp(-e.deltaY * 0.0015);
@@ -159,9 +175,11 @@ export class Viewport extends Widget {
state.on('load', () => { this.view = { x: 0, y: 0, zoom: 1 }; this.redraw(); });
state.on('selection', () => this.redraw());
state.on('mode', () => this.redraw());
// Every tick moves things: redraw the live view when it is on screen.
state.on('runtime', () => { if (this.visible) this.redraw(); });
}
/** Switch between drawing the model (null) and a running world. */
/** Game view: the world to draw (null = not playing). Ignored by the scene view. */
setWorld(world: World | null): void {
this.playWorld = world;
this.redraw();
@@ -199,10 +217,10 @@ export class Viewport extends Widget {
return { x: this.view.x, y: this.view.y, scale: this.view.zoom, rect: { x: 0, y: 0, w: this.w, h: this.h } };
}
/** The active mapping, or null when playing a scene that has no camera (nothing to show). */
/** The active mapping, or null when the game view has no world or no camera (nothing to show). */
private currentView(): View | null {
if (!this.playWorld) return this.editView();
const camera = this.playWorld.firstOfType('camera');
if (!this.game) return this.editView();
const camera = this.playWorld?.firstOfType('camera');
if (!camera) return null;
const frame = cameraFrame(camera.props);
const scale = Math.min(this.w / frame.w, this.h / frame.h);
@@ -231,9 +249,10 @@ export class Viewport extends Widget {
// --- drawing ---------------------------------------------------------------------------
private collectItems(): { items: DrawItem[]; background: number } {
if (this.playWorld) {
if (this.game) {
// Symbols (camera, behaviors) are editor-only; the running game shows scene content.
const world = this.playWorld;
if (!world) return { items: [], background: LETTERBOX_COLOR };
return {
background: world.backgroundColor,
items: world.entities.filter((e) => !isSymbolType(e.type)).map((e) => ({ id: e.objectId, type: e.type, name: e.name, ...world.worldPosition(e), props: e.props, parentId: e.parent?.objectId ?? '' })),
@@ -260,21 +279,23 @@ export class Viewport extends Widget {
}
override redraw(): void {
const editing = !this.playWorld;
const editing = !this.game;
const view = this.currentView();
// Base fill: letterbox black in play mode, neutral in edit mode without a scene.
// Base fill: letterbox black in the game view, neutral in the scene view without a scene.
this.bg.clear().rect(0, 0, this.w, this.h).fill(editing ? Theme.colors.viewport : LETTERBOX_COLOR);
this.drawGrid(editing);
this.gizmos.clear();
this.contentMask.clear();
this.content.visible = view !== null;
this.hint.visible = this.game && !this.playWorld;
this.hint.position.set(this.w / 2, this.h / 2);
this.visibleCount = 0;
if (!view) return;
const { items, background } = this.collectItems();
const { rect } = view;
if (this.state.activeScene || this.playWorld) this.bg.rect(rect.x, rect.y, rect.w, rect.h).fill(background);
if (this.game || this.state.activeScene) this.bg.rect(rect.x, rect.y, rect.w, rect.h).fill(background);
this.contentMask.rect(rect.x, rect.y, rect.w, rect.h).fill(0xffffff);
this.world.position.set(rect.x + rect.w / 2 - view.x * view.scale, rect.y + rect.h / 2 - view.y * view.scale);
@@ -432,7 +453,7 @@ export class Viewport extends Widget {
}
private onItemPointerDown(objectId: string, e: FederatedPointerEvent): void {
if (this.state.mode !== 'edit' || e.button !== 0) return;
if (this.game || e.button !== 0) return;
const scene = this.state.activeScene;
const object = scene?.objects.find((o) => o.id === objectId);
if (!scene || !object) return;