/** * The scene seen through its active camera at the chosen scale, letterboxed: the running world while * playing, the model as a still preview otherwise. */ import { type FederatedPointerEvent, Text } from 'pixi.js'; import { isSymbolType } from '../../core/model/AssetTypes'; import type { EditorState } from '../state'; import type { World } from '../../runtime/World'; import { textStyle } from '../../ui'; import { type ActiveCamera, cameraOfItems, cameraOfWorld } from '../../viewport/activeCamera'; import { collectHolderItems, collectWorldItems } from '../../viewport/items'; import { cameraFactor, cameraView, type GameScale, isInside, screenToWorld, type View, } from '../../viewport/view'; import { settings, updateSettings } from '../settings'; import { type Frame, WorldViewport } from './WorldViewport'; const LETTERBOX_COLOR = 0x000000; export class GameViewport extends WorldViewport { private readonly hint = new Text({ text: 'No active camera', style: textStyle('muted') }); private readonly editorRunCaption = new Text({ text: 'Behaviors run here ยท keys and clicks go to the game', style: textStyle('muted'), }); private playWorld: World | null = null; /** The editor run's world: it takes the mouse, but it is never what this view draws. */ private editorWorld: World | null = null; /** Screen pixels per camera pixel as last drawn, null without a camera. */ private factor: number | null = null; /** Told the factor of every redraw: auto and fit change with the view. */ onScaleShown: ((factor: number | null) => void) | null = null; constructor(state: EditorState) { super(state); this.hint.eventMode = 'none'; this.hint.anchor.set(0.5); this.addChild(this.hint); this.editorRunCaption.eventMode = 'none'; this.addChild(this.editorRunCaption); this.on('pointerdown', (e: FederatedPointerEvent) => { if (e.button !== 0) return; this.reportPointer(e); this.pointerWorld()?.setPointerDown(true); }); this.on('globalpointermove', (e: FederatedPointerEvent) => this.reportPointer(e)); this.on('pointerup', () => this.pointerWorld()?.setPointerDown(false)); this.on('pointerupoutside', () => this.pointerWorld()?.setPointerDown(false)); } /** null = not playing. */ setWorld(world: World | null): void { this.playWorld = world; this.redraw(); } /** null = no behavior is running outside play mode. */ setEditorWorld(world: World | null): void { this.editorWorld = world; this.redraw(); } /** Where the mouse is reported: the running game, or the behaviors running without it. */ private pointerWorld(): World | null { return this.playWorld ?? this.editorWorld; } /** * Skipped while the tab is hidden: the preview costs a collect of the whole scene and would * otherwise run on every selection change behind another tab. Showing the tab redraws it. */ override redraw(): void { if (!this.visible) return; super.redraw(); } get scaleMode(): GameScale { return settings().gameScale; } /** Remembered in this browser, like which scene Play starts. */ setScaleMode(mode: GameScale): void { updateSettings({ gameScale: mode }); this.redraw(); } protected override currentView(): View | null { const camera = this.activeCamera(); this.factor = camera ? cameraFactor(camera.props, this.w, this.h, this.scaleMode) : null; return camera ? cameraView(camera, this.w, this.h, this.scaleMode) : null; } private activeCamera(): ActiveCamera | null { const world = this.playWorld; return world ? cameraOfWorld(world) : cameraOfItems(this.modelFrame().items); } private modelFrame(): Frame { return collectHolderItems(this.state.project, this.state.activeScene, LETTERBOX_COLOR, this.editorWorld); } /** * Screen pixels per camera pixel and per world unit, and the camera's picture as it lies on * screen, in global pixels; null without a camera. */ scaleInfo(): { factor: number; scale: number; frame: { x: number; y: number; w: number; h: number }; } | null { const view = this.currentView(); if (!view || this.factor === null) return null; const origin = this.getGlobalPosition(); const { x, y, w, h } = view.rect; return { factor: this.factor, scale: view.scale, frame: { x: origin.x + x, y: origin.y + y, w, h } }; } /** `camera`: object id of the camera the view is through, empty when none is switched on. */ override debugInfo(): ReturnType & { camera: string } { return { ...super.debugInfo(), camera: this.activeCamera()?.id ?? '' }; } protected override collectFrame(): Frame { if (this.playWorld) return collectWorldItems(this.playWorld); const frame = this.modelFrame(); // Symbols are editor decoration, so the preview drops them: it shows what playing would show. return { ...frame, items: frame.items.filter((item) => !isSymbolType(item.type)) }; } protected override get baseColor(): number { return LETTERBOX_COLOR; } protected override beforeItems(view: View | null): void { this.onScaleShown?.(view ? this.factor : null); this.hint.visible = view === null; this.hint.position.set(this.w / 2, this.h / 2); // Which tab holds the keyboard is otherwise nowhere to be seen. this.editorRunCaption.visible = this.editorWorld !== null && !this.playWorld; this.editorRunCaption.position.set(8, 6); } /** Outside the camera frame counts as "not in view": that region is letterbox, not game. */ private reportPointer(e: FederatedPointerEvent): void { const world = this.pointerWorld(); const view = this.currentView(); if (!world || !view) return; const local = this.toLocal(e.global); const point = screenToWorld(view, local.x, local.y); world.movePointer(point.x, point.y, isInside(view, local.x, local.y)); } }