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:
1 parent
cb8885359f
commit
c3a533c4e5
14 files changed
+408
-118
No files matched your search
+39
-18
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user