A sprite whose Video clock is free played its video on after it was destroyed, heard in a game, until the player was let go 2 to 4 s later. A driven one was already held and quiet after 100 ms. - The library holds and quiets a free player nothing asked for in HUSH_MS, as it did a driven one. That covers Destroy, a scene load, the end of a run and a sprite switched off. - Asked for again before it is let go, it plays on from where it was held, heard only in a run. A hidden page leaves it alone, as the sweep does. - A sprite only out of view keeps its free video playing and heard. Whether a video is heard should not hang on the camera. Before, it ran 2 to 4 s and was then let go. Now it plays on, which opens its player even where it was never in view. Tests: - Unit: video-library 1 new. A free player losing its wearer is held and quiet 200 ms later, stays so through a new run, plays on heard when asked again with the same element, and runs on on a hidden page. - e2e 66 on melektaus-atx: ALL PASSED, 36 checks, 66g-ii new. Heard 31 frames before Destroy, held and muted in every frame read after. - Counter-probe without hushing free players: the unit test and only 66g-ii fail, the player plays on heard (paused false, muted false). - Gate on melektaus-atx: 309 files, 4797 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1088 lines
42 KiB
TypeScript
1088 lines
42 KiB
TypeScript
import {
|
||
Container,
|
||
type FederatedPointerEvent,
|
||
Graphics,
|
||
type Matrix,
|
||
type Mesh,
|
||
type RenderTexture,
|
||
Texture,
|
||
} from 'pixi.js';
|
||
import { isSymbolType } from '../core/model/AssetTypes';
|
||
import { type Extent, scaleOf } from '../core/model/matrix';
|
||
import type { Vec2 } from '../core/model/Properties';
|
||
import { footprintExtent, footprintOf, placementOf } from '../core/properties/bounds';
|
||
import { cameraFrame, rectsIntersect, type WorldRect } from '../core/properties/camera';
|
||
import type { ClipRegion } from '../core/properties/clip';
|
||
import type { Project } from '../core/model/Project';
|
||
import { cancelFrameWork, onFrame, type Size, ui, Widget } from '../ui';
|
||
import { fontLibrary } from './FontLibrary';
|
||
import { drawSlots } from './drawOrder';
|
||
import type { DrawItem, VideoAhead } from './items';
|
||
import { hashId } from '../core/particles/nodes';
|
||
import { ParticleField } from './particles';
|
||
import { particleLibrary } from './ParticleLibrary';
|
||
import { SHEET_EDGE } from '../core/shader/nodes';
|
||
import { shaderLibrary } from './ShaderLibrary';
|
||
import { type ShaderSource, wornShader } from './shaders';
|
||
import { disposeSpriteMesh, drawItem, newSpriteMesh, syncSpriteMesh } from './symbols';
|
||
import { TextPicture } from './textPicture';
|
||
import { cellCentre, type LayerFocus, TilemapField } from './tilemap';
|
||
import { textureLibrary } from './TextureLibrary';
|
||
import { gameClock } from '../core/time';
|
||
import { overlayView, type View, worldBounds, worldToScreen } from './view';
|
||
import type { ActiveCamera } from './activeCamera';
|
||
import { CameraShade, shadedPicture } from './cameraShade';
|
||
import { snapPlacement } from './pixelGrid';
|
||
import { cameraSees } from '../core/model/renderLayers';
|
||
import { drivenVideoKey, isVideoKey } from '../core/model/video';
|
||
import { videoDrivenOf } from '../core/properties/videos';
|
||
import { type VideoHearing, videoLibrary } from './VideoLibrary';
|
||
|
||
/** How strongly the cut-off part of an object shows while the scene view opens its clip area (#260). */
|
||
const CLIP_GHOST_ALPHA = 0.3;
|
||
/** How long a view keeps its last frame at most while a driven video it brings in seeks (#404). */
|
||
const VIDEO_HOLD_MS = 300;
|
||
|
||
let views = 0;
|
||
|
||
/**
|
||
* What a view draws from: the project and word of when it changed. The editor's state is one; a
|
||
* released game (#266), which has no editor, hands over its own.
|
||
*/
|
||
export interface ViewSource {
|
||
readonly project: Project;
|
||
on(event: 'project' | 'selection' | 'mode' | 'runtime', listener: () => void): unknown;
|
||
}
|
||
|
||
export interface Frame {
|
||
items: DrawItem[];
|
||
background: number;
|
||
/** Driven videos (#404) worn without being drawn: those of composition items about to begin (#417). */
|
||
ahead?: VideoAhead[];
|
||
}
|
||
|
||
/** A part of the view by its centre and size, in local CSS pixels. */
|
||
export interface PictureArea {
|
||
x: number;
|
||
y: number;
|
||
w: number;
|
||
h: number;
|
||
}
|
||
|
||
export abstract class WorldViewport<S extends ViewSource = ViewSource> extends Widget {
|
||
protected readonly bg = new Graphics();
|
||
protected readonly content = new Container();
|
||
protected readonly world = new Container();
|
||
/** Masks and objects: what a camera's shader is laid over (#299), drawn into its picture first. */
|
||
private readonly shaded = new Container();
|
||
/** Where `shaded` waits while the shader's picture stands in for it: on no stage at all. */
|
||
private readonly offstage = new Container();
|
||
private cameraShade: CameraShade | null = null;
|
||
/** Ordered by `z`, ties by hierarchy order (see `syncDrawOrder`). */
|
||
protected readonly objects = new Container();
|
||
/** Editor symbols, always above `objects` so no sprite can hide them whatever the z-order. */
|
||
protected readonly overlay = new Container();
|
||
protected readonly contentMask = new Graphics();
|
||
protected readonly nodes = new Map<string, Graphics>();
|
||
/** Text content of text items: a sibling of the item's node, kept next to it in z. */
|
||
private readonly texts = new Map<string, TextPicture>();
|
||
/** Pictures of sprite items: siblings as well, each right under its node. */
|
||
private readonly sprites = new Map<string, Mesh>();
|
||
/** The drawn particles of each source, one container – and one draw call – per source. */
|
||
private readonly fields = new Map<string, ParticleField>();
|
||
/** The meshes of each placed tilemap, one container per layer (#174). */
|
||
private readonly tilemaps = new Map<string, TilemapField>();
|
||
/**
|
||
* What clip areas cut off (#260), as a mask per drawn thing: Pixi lets a mask serve only one, and
|
||
* switches a shared one back on as content once any of them lets go of it.
|
||
*/
|
||
private readonly masks = new Container();
|
||
private readonly maskOf = new Map<Container, Graphics>();
|
||
/** The cut-off part of a sprite or text, drawn faintly under it while its clip area is open. */
|
||
private readonly ghostSprites = new Map<string, Mesh>();
|
||
private readonly ghostTexts = new Map<string, TextPicture>();
|
||
private visibleCount = 0;
|
||
private animated = false;
|
||
protected lastItems: DrawItem[] = [];
|
||
/**
|
||
* What the last full redraw was drawn for: its zoom and view size, and per object node the box
|
||
* it is culled by (`null`: never culled, a particle source). What `moveCamera` needs to tell
|
||
* whether a pan changes anything but where the world sits.
|
||
*/
|
||
private drawn: {
|
||
scale: number;
|
||
w: number;
|
||
h: number;
|
||
grid: number | null;
|
||
culled: { node: Graphics; extent: Extent | null }[];
|
||
} | null = null;
|
||
/** Full redraws and camera-only moves since start (test hook). */
|
||
private redraws = 0;
|
||
private readonly animateKey = `animate:${(views += 1)}`;
|
||
/** What a driven video (#404) knows this view by: each view drives its own players. */
|
||
private readonly wearer = `view${views}`;
|
||
/** The driven player (#404) each sprite showed in the last redraw, by object id. */
|
||
private readonly videosShown = new Map<string, string>();
|
||
/** Since when the view keeps its last frame for a driven video it brings in, see `holdsForVideo`. */
|
||
private heldSince: number | null = null;
|
||
/** The sprites the last redraw did not show, their driven video still without the picture asked for. */
|
||
private readonly videosLeftOut = new Set<string>();
|
||
private cameraMoves = 0;
|
||
private readonly stopFiles: (() => void)[];
|
||
/** One view per overlay camera (#302), by camera id, laid over this one in camera order. */
|
||
private readonly overlays = new Map<string, OverlayCameraView>();
|
||
|
||
protected constructor(protected readonly state: S) {
|
||
super();
|
||
this.shaded.addChild(this.masks, this.objects);
|
||
this.world.addChild(this.shaded, this.overlay);
|
||
this.content.addChild(this.world);
|
||
this.addChild(this.bg, this.content, this.contentMask);
|
||
this.content.mask = this.contentMask;
|
||
this.cursor = 'default';
|
||
|
||
state.on('project', () => this.redraw());
|
||
state.on('selection', () => this.redraw());
|
||
state.on('mode', () => this.redraw());
|
||
// While a Time shader is on screen the ticker redraws every frame anyway (`animate`).
|
||
state.on('runtime', () => {
|
||
if (this.visible && !this.animated) this.redraw();
|
||
});
|
||
// Unconditional: a picture or a typeface arriving fires once per file, not per frame.
|
||
this.stopFiles = [
|
||
textureLibrary.onChange(() => this.redraw()),
|
||
fontLibrary.onChange(() => this.redraw()),
|
||
];
|
||
}
|
||
|
||
/** The libraries outlive a signed-out editor; a stale listener would redraw destroyed graphics. */
|
||
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
|
||
for (const stop of this.stopFiles) stop();
|
||
cancelFrameWork(this.animateKey);
|
||
for (const overlay of this.overlays.values()) overlay.destroy();
|
||
this.unshadeCamera();
|
||
this.offstage.destroy();
|
||
super.destroy(options);
|
||
}
|
||
|
||
override measure(): Size {
|
||
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
|
||
}
|
||
|
||
protected abstract currentView(): View | null;
|
||
|
||
/** The camera the view is through, whose `Sees` decides what is drawn (#302); none in the scene view. */
|
||
protected mainCamera(): ActiveCamera | null {
|
||
return null;
|
||
}
|
||
|
||
/** The overlay cameras laid over the main one (#302), in the order they are laid on. */
|
||
protected overlayCameras(): ActiveCamera[] {
|
||
return [];
|
||
}
|
||
|
||
/** The camera whose shader is laid over this view (#299): the one the view is through. */
|
||
protected shadingCamera(main: ActiveCamera | null, _items: DrawItem[]): ActiveCamera | null {
|
||
return main;
|
||
}
|
||
|
||
/** Whether the world stays drawn around the shaded frame: the scene view shows more than the camera. */
|
||
protected keepsWorldAroundShader(): boolean {
|
||
return false;
|
||
}
|
||
|
||
/** Only called when `currentView()` is non-null. */
|
||
protected abstract collectFrame(): Frame;
|
||
|
||
/** Fills the whole widget, behind and around the view rectangle. */
|
||
protected abstract get baseColor(): number;
|
||
|
||
protected paintsBackground(): boolean {
|
||
return true;
|
||
}
|
||
|
||
protected get itemsInteractive(): boolean {
|
||
return false;
|
||
}
|
||
|
||
protected beforeItems(_view: View | null): void {}
|
||
|
||
protected afterItems(_items: DrawItem[], _view: View): void {}
|
||
|
||
/** `extent` is the world rectangle around the item as drawn, turn and size included. */
|
||
protected syncDecorations(_item: DrawItem, _onScreen: boolean, _scale: number, _extent: Extent): void {}
|
||
|
||
/** An item disappeared: drop whatever `syncDecorations` created for it. */
|
||
protected disposeDecorations(_id: string): void {}
|
||
|
||
/** Whether the item's own drawing is switched off – it is then neither seen nor hit. */
|
||
protected hidesShape(_item: DrawItem): boolean {
|
||
return false;
|
||
}
|
||
|
||
protected onItemPointerDown(_objectId: string, _e: FederatedPointerEvent): void {}
|
||
|
||
/** Whether the part a clip area cuts off the item is shown faintly and can be grabbed (#260). */
|
||
protected opensClip(_item: DrawItem): boolean {
|
||
return false;
|
||
}
|
||
|
||
/** Whether the object itself is selected – a selected collider shows its area filled. */
|
||
protected isSelected(_objectId: string): boolean {
|
||
return false;
|
||
}
|
||
|
||
/** Screen-space drawing that follows the camera, redrawn after a full redraw and after a pan. */
|
||
protected drawOnScreen(_items: DrawItem[], _view: View): void {}
|
||
|
||
debugInfo(): { rendering: boolean; visibleItems: number; redraws: number; cameraMoves: number } {
|
||
return {
|
||
rendering: this.currentView() !== null,
|
||
visibleItems: this.visibleCount,
|
||
redraws: this.redraws,
|
||
cameraMoves: this.cameraMoves,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The camera moved and nothing else did – a pan. Zoom and size are the same, so every node is
|
||
* drawn as it was; only the world's position and the screen-space overlays follow. Anything
|
||
* else, or an object crossing the edge of the view, takes the full redraw, which would collect,
|
||
* redraw and re-upload every shape of a pan frame for nothing.
|
||
*/
|
||
protected moveCamera(): void {
|
||
const view = this.currentView();
|
||
const drawn = this.drawn;
|
||
if (
|
||
!view ||
|
||
!drawn ||
|
||
view.scale !== drawn.scale ||
|
||
view.rect.w !== drawn.w ||
|
||
view.rect.h !== drawn.h ||
|
||
(view.grid ?? null) !== drawn.grid
|
||
) {
|
||
this.redraw();
|
||
return;
|
||
}
|
||
const bounds = worldBounds(view);
|
||
for (const { node, extent } of drawn.culled)
|
||
if ((extent === null || rectsIntersect(extent, bounds)) !== node.visible) {
|
||
this.redraw();
|
||
return;
|
||
}
|
||
this.cameraMoves++;
|
||
this.placeWorld(view);
|
||
this.drawOnScreen(this.lastItems, view);
|
||
}
|
||
|
||
private placeWorld(view: View): void {
|
||
const { rect } = view;
|
||
this.world.position.set(
|
||
rect.x + rect.w / 2 - view.x * view.scale,
|
||
rect.y + rect.h / 2 - view.y * view.scale,
|
||
);
|
||
this.world.scale.set(view.scale);
|
||
}
|
||
|
||
/**
|
||
* The particles of one source as drawn, in world units, oldest first – what a check reads instead
|
||
* of guessing from pixels. `limit` keeps a stream of 500 from becoming 500 lines of JSON.
|
||
*/
|
||
particleInfo(
|
||
objectId: string,
|
||
limit = 8,
|
||
): {
|
||
count: number;
|
||
drawn: { x: number; y: number; scaleX: number; alpha: number; tint: number }[];
|
||
} | null {
|
||
const field = this.fields.get(objectId);
|
||
if (!field || !field.node.visible) return null;
|
||
return {
|
||
count: field.count,
|
||
drawn: field.container.particleChildren.slice(0, limit).map((p) => ({
|
||
x: Math.round(p.x * 100) / 100,
|
||
y: Math.round(p.y * 100) / 100,
|
||
scaleX: Math.round(p.scaleX * 1000) / 1000,
|
||
alpha: Math.round(p.alpha * 1000) / 1000,
|
||
tint: p.tint,
|
||
})),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Starts every source's clock over at `now`. With particle time frozen this is what makes a
|
||
* measurement exact: freeze, restart, move the clock to the second wanted, read.
|
||
*/
|
||
restartParticles(now: number): void {
|
||
for (const field of this.fields.values()) field.restart(now);
|
||
this.redraw();
|
||
}
|
||
|
||
/** True after a redraw that drew a sprite wearing a Time shader: the picture moves by itself. */
|
||
get animates(): boolean {
|
||
return this.animated;
|
||
}
|
||
|
||
/** How often the view was drawn so far; `animate` is told the count its tick began with. */
|
||
get redrawCount(): number {
|
||
return this.redraws;
|
||
}
|
||
|
||
/**
|
||
* Called every tick: redraws while the last redraw found a Time shader on screen. A view drawn
|
||
* since the tick began, at `drawnBefore`, already shows this frame – a run draws its views
|
||
* itself, and drawing them again cost a second collect of the whole world each frame (#284).
|
||
* Asked just before the frame is drawn rather than now: a gesture's preview redraws there too,
|
||
* and painting a tilemap drew the scene twice a frame (#347).
|
||
*/
|
||
animate(drawnBefore: number): void {
|
||
if (!this.visible || !this.animated) return;
|
||
onFrame(this.animateKey, () => {
|
||
if (!this.destroyed && this.redraws === drawnBefore) this.redraw();
|
||
});
|
||
}
|
||
|
||
layerOf(objectId: string): 'objects' | 'overlay' | null {
|
||
const node = this.nodes.get(objectId);
|
||
if (!node) return null;
|
||
return node.parent === this.overlay ? 'overlay' : 'objects';
|
||
}
|
||
|
||
/** Ids in the order they are drawn, back to front. */
|
||
drawOrder(): string[] {
|
||
return this.objects.children.filter((node) => node.visible).map((node) => node.label);
|
||
}
|
||
|
||
/** Where an object is drawn, in global screen coordinates. */
|
||
objectScreen(objectId: string): Vec2 | null {
|
||
const view = this.currentView();
|
||
const item = this.lastItems.find((i) => i.id === objectId);
|
||
if (!item || !view) return null;
|
||
const local = worldToScreen(view, item.x, item.y);
|
||
const origin = this.getGlobalPosition();
|
||
return { x: origin.x + local.x, y: origin.y + local.y };
|
||
}
|
||
|
||
/**
|
||
* The screen rectangle around an object, so tests can sample the pixels inside it: centre plus
|
||
* size, and for a turned object the box around its corners.
|
||
*/
|
||
objectRect(objectId: string): { x: number; y: number; w: number; h: number } | null {
|
||
const view = this.currentView();
|
||
const item = this.lastItems.find((i) => i.id === objectId);
|
||
if (!item || !view) return null;
|
||
const extent = footprintExtent(item.type, item.props, item.m, item.frame, item.font);
|
||
const local = worldToScreen(view, (extent.left + extent.right) / 2, (extent.top + extent.bottom) / 2);
|
||
const origin = this.getGlobalPosition();
|
||
return {
|
||
x: origin.x + local.x,
|
||
y: origin.y + local.y,
|
||
w: (extent.right - extent.left) * view.scale,
|
||
h: (extent.bottom - extent.top) * view.scale,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Where a sprite's picture is drawn, in global device pixels: unlike `objectRect`, which is the
|
||
* model's, this is where a pixel perfect camera snapped it to (test hook).
|
||
*/
|
||
drawnRect(objectId: string): { x: number; y: number; w: number; h: number } | null {
|
||
const sprite = this.sprites.get(objectId);
|
||
if (!sprite?.visible) return null;
|
||
const bounds = sprite.getBounds();
|
||
const ratio = ui().app.renderer.resolution;
|
||
return { x: bounds.x * ratio, y: bounds.y * ratio, w: bounds.width * ratio, h: bounds.height * ratio };
|
||
}
|
||
|
||
override redraw(): void {
|
||
const view = this.currentView();
|
||
const frame = view && this.collectFrame();
|
||
const camera = frame ? this.mainCamera() : null;
|
||
const items = frame ? (camera ? seenBy(camera, frame.items) : frame.items) : [];
|
||
const shaders = shaderLibrary.source(this.state.project);
|
||
if (view && this.holdsForVideo(view, items, shaders)) {
|
||
this.animated = true;
|
||
return;
|
||
}
|
||
this.bg.clear().rect(0, 0, this.w, this.h).fill(this.baseColor);
|
||
this.beforeItems(view);
|
||
this.contentMask.clear();
|
||
this.content.visible = view !== null;
|
||
this.visibleCount = 0;
|
||
this.animated = false;
|
||
this.videosLeftOut.clear();
|
||
this.drawn = null;
|
||
if (!view || !frame) {
|
||
this.unshadeCamera();
|
||
this.syncOverlays(null, null, null);
|
||
return;
|
||
}
|
||
|
||
this.redraws++;
|
||
const { background } = frame;
|
||
this.lastItems = items;
|
||
const { rect } = view;
|
||
if (this.paintsBackground()) 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.placeWorld(view);
|
||
const bounds = worldBounds(view);
|
||
const shading = this.shadingCamera(camera, items);
|
||
const grid = this.objectGrid(view, shading, shaders);
|
||
const culled: { node: Graphics; extent: Extent | null }[] = [];
|
||
|
||
const seen = new Set<string>();
|
||
for (const item of items) {
|
||
seen.add(item.id);
|
||
const size = footprintOf(item.type, item.props, item.frame, item.font);
|
||
const extent = footprintExtent(item.type, item.props, item.m, item.frame, item.font);
|
||
// A switched-off object is not drawn at all, in the scene view as in the game; it is
|
||
// selected and switched back on from the hierarchy panel.
|
||
const unbound = isUnbound(item, shaders);
|
||
const onScreen = item.enabled && (unbound || rectsIntersect(extent, bounds));
|
||
let node = this.nodes.get(item.id);
|
||
if (!node) {
|
||
node = new Graphics();
|
||
node.label = item.id;
|
||
node.cursor = 'move';
|
||
node.on('pointerdown', (e: FederatedPointerEvent) => this.onItemPointerDown(item.id, e));
|
||
this.nodes.set(item.id, node);
|
||
(isSymbolType(item.type) ? this.overlay : this.objects).addChild(node);
|
||
}
|
||
const hidden = this.hidesShape(item);
|
||
node.visible = onScreen && !hidden;
|
||
if (item.enabled && !hidden) culled.push({ node, extent: unbound ? null : extent });
|
||
node.eventMode = this.itemsInteractive && node.visible ? 'static' : 'none';
|
||
const placed = grid ? { ...item, m: snapPlacement(item.m, size.w, size.h, grid) } : item;
|
||
this.syncDecorations(item, onScreen, view.scale, extent);
|
||
this.syncText(item, onScreen, view.scale, shaders, grid);
|
||
this.syncSprite(placed, onScreen, size, shaders);
|
||
this.syncParticles(item, onScreen, shaders, view, bounds);
|
||
this.syncTilemap(placed, onScreen);
|
||
if (!onScreen) continue;
|
||
this.visibleCount++;
|
||
if (hidden) continue;
|
||
drawItem(
|
||
node,
|
||
item,
|
||
size,
|
||
view.scale * scaleOf(placementOf(item.type, item.m)),
|
||
this.isSelected(item.id),
|
||
);
|
||
}
|
||
// Opened before their item begins, so the cut to it has its picture at once.
|
||
for (const ahead of frame.ahead ?? []) if (this.wearVideo(ahead, false) !== ahead) this.animated = true;
|
||
this.afterItems(items, view);
|
||
this.drawOnScreen(items, view);
|
||
this.drawn = { scale: view.scale, w: rect.w, h: rect.h, grid: view.grid ?? null, culled };
|
||
for (const [id, node] of this.nodes) {
|
||
if (seen.has(id)) continue;
|
||
this.texts.get(id)?.destroy();
|
||
this.texts.delete(id);
|
||
const sprite = this.sprites.get(id);
|
||
if (sprite) disposeSpriteMesh(sprite);
|
||
this.sprites.delete(id);
|
||
this.videosShown.delete(id);
|
||
this.fields.get(id)?.destroy();
|
||
this.fields.delete(id);
|
||
this.tilemaps.get(id)?.destroy();
|
||
this.tilemaps.delete(id);
|
||
this.dropGhosts(id);
|
||
node.destroy();
|
||
this.nodes.delete(id);
|
||
this.disposeDecorations(id);
|
||
}
|
||
this.syncDrawOrder(items);
|
||
this.sweepMasks();
|
||
this.shadeCamera(shading, view, shaders, background);
|
||
this.syncOverlays(frame, view, camera);
|
||
}
|
||
|
||
/**
|
||
* Whether the view keeps its last frame for a driven video (#404) it brings in: a sprite switched
|
||
* on, or come into view, at another picture than its player holds. Its texture shows the held one
|
||
* until the seek is done, and a cut into the middle of a clip flashed the picture it was held at.
|
||
* At most `VIDEO_HOLD_MS`; after that such a sprite is left out until its picture is there.
|
||
*/
|
||
private holdsForVideo(view: View, items: DrawItem[], shaders: ShaderSource): boolean {
|
||
const bounds = worldBounds(view);
|
||
const waiting = items.filter((item) => {
|
||
const file = item.texture?.file;
|
||
if (item.type !== 'sprite' || !item.enabled || !file || !isVideoKey(file)) return false;
|
||
if (!videoDrivenOf(item.props)) return false;
|
||
const key = drivenVideoKey(file, this.videoWearer(item));
|
||
if (this.videosShown.get(item.id) === key) return false;
|
||
const extent = footprintExtent(item.type, item.props, item.m, item.frame, item.font);
|
||
if (!isUnbound(item, shaders) && !rectsIntersect(extent, bounds)) return false;
|
||
// Worn here already, so the seek to the asked picture starts with the frame held.
|
||
this.wearVideo(item);
|
||
// One still without a picture is left out instead (`syncSprite`).
|
||
return videoLibrary.hasPicture(key) && !videoLibrary.showsAsked(key);
|
||
});
|
||
const now = performance.now();
|
||
if (waiting.length === 0) this.heldSince = null;
|
||
else this.heldSince ??= now;
|
||
const holds = waiting.length > 0 && now - this.heldSince! < VIDEO_HOLD_MS;
|
||
// The held frame does not show them either, so a composition waits for them meanwhile.
|
||
if (holds) for (const item of waiting) this.videosLeftOut.add(item.id);
|
||
return holds;
|
||
}
|
||
|
||
/**
|
||
* The steps per unit the objects are drawn on, or null to draw them as they are: through a pixel
|
||
* perfect camera the view's grid, or with its shader laid over the grid of the shader's picture.
|
||
*/
|
||
private objectGrid(view: View, shading: ActiveCamera | null, shaders: ShaderSource): number | null {
|
||
if (!view.grid) return null;
|
||
const ppu = view.scale * ui().app.renderer.resolution;
|
||
return (shading && shadedPicture(shading, shaders, ppu, true)?.grid) || view.grid;
|
||
}
|
||
|
||
/**
|
||
* The camera's picture as the view lays it out (#405), in local CSS pixels by its centre: the part
|
||
* on screen, and all of it, which is larger only while a fixed scale cuts it to the view. Null
|
||
* without a camera.
|
||
*/
|
||
picture(): { shown: PictureArea; whole: PictureArea } | null {
|
||
const view = this.currentView();
|
||
const camera = view && this.mainCamera();
|
||
if (!view || !camera) return null;
|
||
const { rect } = view;
|
||
const frame = cameraFrame(camera.props);
|
||
const x = rect.x + rect.w / 2;
|
||
const y = rect.y + rect.h / 2;
|
||
return {
|
||
shown: { x, y, w: rect.w, h: rect.h },
|
||
whole: { x, y, w: frame.w * view.scale, h: frame.h * view.scale },
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Draws the view into `target` mapped by `transform`, without what lies over the picture
|
||
* (`chrome`): a recording reads it from there (#405). Never into the canvas on screen, which shows
|
||
* at once what is drawn into it while its context is low latency. Drawn even while its tab is
|
||
* hidden, so a recording goes on behind another tab.
|
||
*/
|
||
drawPicture(transform: Matrix, target: RenderTexture): void {
|
||
const shown = this.chrome().filter((part) => part.visible);
|
||
for (const part of shown) part.visible = false;
|
||
const visible = this.visible;
|
||
this.visible = true;
|
||
try {
|
||
ui().app.renderer.render({ container: this, target, transform, clear: true, clearColor: [0, 0, 0, 1] });
|
||
} finally {
|
||
this.visible = visible;
|
||
for (const part of shown) part.visible = true;
|
||
}
|
||
}
|
||
|
||
/** What the view lays over the game's picture and a recording leaves out. */
|
||
protected chrome(): Container[] {
|
||
return [];
|
||
}
|
||
|
||
/** Whether the game view is at a fixed scale rather than fit: the only one overlays snap at. */
|
||
protected fixedScale(): boolean {
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Lays the camera's shader over its frame (#299): the masks and objects are drawn into the
|
||
* camera's picture, cleared to the scene's background – see-through on a view that paints none –
|
||
* and the picture stands in for them. Without a shader that compiles they are drawn as they are.
|
||
*/
|
||
private shadeCamera(
|
||
camera: ActiveCamera | null,
|
||
view: View,
|
||
shaders: ShaderSource,
|
||
background: number,
|
||
): void {
|
||
const renderer = ui().app.renderer;
|
||
const shade = this.cameraShade ?? new CameraShade();
|
||
const clear = this.paintsBackground() ? background : null;
|
||
const ppu = view.scale * renderer.resolution;
|
||
const onGrid = Boolean(view.grid);
|
||
const mesh = camera ? shade.draw(this.shaded, camera, shaders, ppu, renderer, clear, onGrid) : null;
|
||
if (!camera || !mesh) {
|
||
if (shade !== this.cameraShade) shade.destroy();
|
||
this.unshadeCamera();
|
||
return;
|
||
}
|
||
this.cameraShade = shade;
|
||
if (!this.keepsWorldAroundShader() && this.shaded.parent !== this.offstage)
|
||
this.offstage.addChild(this.shaded);
|
||
if (mesh.parent !== this.world) this.world.addChildAt(mesh, this.world.getChildIndex(this.overlay));
|
||
if (wornShader(camera.props, shaders)?.time) this.animated = true;
|
||
}
|
||
|
||
/** Back to drawing the objects themselves. */
|
||
private unshadeCamera(): void {
|
||
if (!this.cameraShade) return;
|
||
this.cameraShade.destroy();
|
||
this.cameraShade = null;
|
||
if (this.shaded.parent !== this.world)
|
||
this.world.addChildAt(this.shaded, this.world.getChildIndex(this.overlay));
|
||
}
|
||
|
||
/** The pixels of the camera shader's picture while one is drawn (#299, test hook). */
|
||
cameraShaderPixels(): { w: number; h: number } | null {
|
||
return this.cameraShade?.pixels ?? null;
|
||
}
|
||
|
||
/**
|
||
* Lays one view per overlay camera over this one (#302): each draws what its camera sees of the
|
||
* same frame, see-through, fitted into the main picture. Without a main view there are none.
|
||
*/
|
||
private syncOverlays(frame: Frame | null, main: View | null, camera: ActiveCamera | null): void {
|
||
const cameras = frame && main && camera ? this.overlayCameras() : [];
|
||
const wanted = new Set(cameras.map((c) => c.id));
|
||
for (const [id, overlay] of this.overlays)
|
||
if (!wanted.has(id)) {
|
||
overlay.destroy();
|
||
this.overlays.delete(id);
|
||
}
|
||
let at = this.getChildIndex(this.contentMask) + 1;
|
||
for (const overlayCamera of cameras) {
|
||
let overlay = this.overlays.get(overlayCamera.id);
|
||
if (!overlay) {
|
||
overlay = new OverlayCameraView(() => this.state.project);
|
||
this.overlays.set(overlayCamera.id, overlay);
|
||
this.addChild(overlay);
|
||
}
|
||
this.setChildIndex(overlay, at++);
|
||
const ratio = ui().app.renderer.resolution;
|
||
const view = overlayView(overlayCamera, main!, cameraFrame(camera!.props), ratio, this.fixedScale());
|
||
overlay.show(overlayCamera, view, frame!, this.w, this.h);
|
||
if (overlay.animates) this.animated = true;
|
||
}
|
||
}
|
||
|
||
/** What each overlay camera draws, in the order they are laid on (#302, test hook). */
|
||
overlayInfo(): { camera: string; drawOrder: string[]; rect: View['rect'] }[] {
|
||
return [...this.overlays].map(([camera, overlay]) => ({
|
||
camera,
|
||
drawOrder: overlay.drawOrder(),
|
||
rect: overlay.shownRect(),
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Re-attaches the object nodes in ascending `z`, ties in hierarchy order, each layer of a map
|
||
* sorted in on its own (#174, see `drawSlots`). Explicit rather than Pixi's lazy
|
||
* `sortableChildren` so the order is deterministic and honours hierarchy changes.
|
||
*/
|
||
private syncDrawOrder(items: DrawItem[]): void {
|
||
this.objects.removeChildren();
|
||
const byId = new Map(items.map((item) => [item.id, item]));
|
||
for (const { id, layerId, row } of drawSlots(items)) {
|
||
const clip = byId.get(id)?.clip ?? null;
|
||
if (layerId) {
|
||
const layer = this.tilemaps.get(id)?.layerContainer(layerId, row);
|
||
if (layer) this.objects.addChild(this.clipTo(layer, clip));
|
||
continue;
|
||
}
|
||
// Picture and text are siblings because Graphics may not have children: the picture right
|
||
// under its node, which only holds the hit area, the text right above its box. A ghost goes
|
||
// right under what it is the rest of.
|
||
const ghost = this.ghostSprites.get(id);
|
||
if (ghost) this.objects.addChild(this.clipTo(ghost, clip, true));
|
||
const sprite = this.sprites.get(id);
|
||
if (sprite) this.objects.addChild(this.clipTo(sprite, clip));
|
||
const node = this.nodes.get(id);
|
||
if (node) this.objects.addChild(node);
|
||
const ghostText = this.ghostTexts.get(id);
|
||
if (ghostText) this.objects.addChild(this.clipTo(ghostText.mesh, clip, true));
|
||
const text = this.texts.get(id);
|
||
if (text) this.objects.addChild(this.clipTo(text.mesh, clip));
|
||
const field = this.fields.get(id);
|
||
if (field) this.objects.addChild(this.clipTo(field.node, clip));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Cuts `target` to the region, or with `inverse` to everything outside it. The mask lives in
|
||
* world units like the objects, and stays with the target until the target goes.
|
||
*/
|
||
private clipTo<T extends Container>(target: T, region: ClipRegion | null, inverse = false): T {
|
||
let mask = this.maskOf.get(target);
|
||
if (!region) {
|
||
if (mask) {
|
||
target.mask = null;
|
||
mask.destroy();
|
||
this.maskOf.delete(target);
|
||
}
|
||
return target;
|
||
}
|
||
if (!mask) {
|
||
mask = new Graphics();
|
||
this.masks.addChild(mask);
|
||
this.maskOf.set(target, mask);
|
||
target.setMask({ mask, inverse });
|
||
}
|
||
mask.clear();
|
||
if (region.length >= 3) mask.poly([...region]).fill(0xffffff);
|
||
return target;
|
||
}
|
||
|
||
/** Masks whose target is gone: a tilemap drops the containers of layers it no longer has itself. */
|
||
private sweepMasks(): void {
|
||
for (const [target, mask] of this.maskOf)
|
||
if (target.destroyed) {
|
||
mask.destroy();
|
||
this.maskOf.delete(target);
|
||
}
|
||
}
|
||
|
||
private dropGhosts(id: string): void {
|
||
const sprite = this.ghostSprites.get(id);
|
||
if (sprite) disposeSpriteMesh(sprite);
|
||
this.ghostSprites.delete(id);
|
||
this.ghostTexts.get(id)?.destroy();
|
||
this.ghostTexts.delete(id);
|
||
}
|
||
|
||
/** Whether the item has a cut-off part to show faintly right now. */
|
||
private ghosted(item: DrawItem, onScreen: boolean): boolean {
|
||
return onScreen && item.clip !== null && this.opensClip(item);
|
||
}
|
||
|
||
/**
|
||
* The particles of one source. Their positions are world units, like everything else under
|
||
* `objects`, so the container itself carries no transform: the source's own goes into each
|
||
* particle, which is what lets one stay where it was born while its source flies on.
|
||
*/
|
||
private syncParticles(
|
||
item: DrawItem,
|
||
onScreen: boolean,
|
||
shaders: ShaderSource,
|
||
view: View,
|
||
bounds: WorldRect,
|
||
): void {
|
||
let field = this.fields.get(item.id);
|
||
if (item.type !== 'particles' || !item.particles || !onScreen) {
|
||
if (field) field.node.visible = false;
|
||
return;
|
||
}
|
||
if (!field) {
|
||
field = new ParticleField();
|
||
this.fields.set(item.id, field);
|
||
this.objects.addChild(field.node);
|
||
}
|
||
field.node.visible = true;
|
||
// A source is a moving picture by definition, so the view keeps redrawing while one is on screen.
|
||
this.animated = true;
|
||
const build = particleLibrary.buildFor(item.particles);
|
||
const compiled = build.compiled;
|
||
const params = compiled ? compiled.params.map((row) => Number(item.props[row.key] ?? row.default)) : [];
|
||
const edge = wornShader(item.props, shaders)?.edge ?? SHEET_EDGE;
|
||
const texture = item.texture ? textureLibrary.get(item.texture, edge) : null;
|
||
field.wear(item.props, shaders, texture);
|
||
field.sync(
|
||
compiled,
|
||
item.props,
|
||
params,
|
||
item.m,
|
||
texture ?? Texture.WHITE,
|
||
particleLibrary.now(),
|
||
item.emits,
|
||
hashId(item.id),
|
||
);
|
||
const renderer = ui().app.renderer;
|
||
field.shadeField(item.props, shaders, item.m, view.scale * renderer.resolution, renderer, bounds);
|
||
}
|
||
|
||
/** The device pixels of a source's field-shader picture while one is drawn (#170, test hook). */
|
||
fieldPixels(objectId: string): { w: number; h: number } | null {
|
||
const field = this.fields.get(objectId);
|
||
return field?.node.visible ? field.fieldPixels : null;
|
||
}
|
||
|
||
/**
|
||
* A painted map, drawn from its own meshes. It keeps redrawing the view while an animated
|
||
* tile is on it, the same way a Time shader does.
|
||
*/
|
||
private syncTilemap(item: DrawItem, onScreen: boolean): void {
|
||
let field = this.tilemaps.get(item.id);
|
||
if (item.type !== 'tilemap' || !item.tilemap || !onScreen) {
|
||
if (field) field.visible = false;
|
||
return;
|
||
}
|
||
if (!field) {
|
||
// Its layers are attached by `syncDrawOrder`, which runs after every item is synced.
|
||
field = new TilemapField();
|
||
this.tilemaps.set(item.id, field);
|
||
}
|
||
field.visible = true;
|
||
field.sync(item, textureLibrary, gameClock.now, this.tilemapFocus(item));
|
||
if (field.animated) this.animated = true;
|
||
}
|
||
|
||
/**
|
||
* Which layer of a tilemap is singled out and how (#138). The game view shows every map as the
|
||
* game does; only the scene view, where one paints, has a say.
|
||
*/
|
||
protected tilemapFocus(_item: DrawItem): LayerFocus | null {
|
||
return null;
|
||
}
|
||
|
||
/** Where the centre of one cell of a placed tilemap is on screen, so a check can sample it. */
|
||
cellScreen(objectId: string, x: number, y: number): Vec2 | null {
|
||
const view = this.currentView();
|
||
const item = this.lastItems.find((i) => i.id === objectId);
|
||
if (!view || !item || item.type !== 'tilemap') return null;
|
||
const centre = cellCentre(item, x, y);
|
||
const local = worldToScreen(view, centre.x, centre.y);
|
||
const origin = this.getGlobalPosition();
|
||
return { x: origin.x + local.x, y: origin.y + local.y };
|
||
}
|
||
|
||
/**
|
||
* What a placed tilemap drew last redraw: how many meshes, and how many cells in them; `builds`
|
||
* counts the geometries built since the map came on screen (test hook).
|
||
*/
|
||
tilemapInfo(objectId: string): { drawCalls: number; builds: number; tiles: number; layers: number } | null {
|
||
const field = this.tilemaps.get(objectId);
|
||
const item = this.lastItems.find((i) => i.id === objectId);
|
||
if (!field?.visible || !item?.tilemap) return null;
|
||
return {
|
||
drawCalls: field.drawCalls,
|
||
builds: field.builds,
|
||
tiles: item.tilemap.layers.reduce((sum, layer) => sum + layer.tiles.length, 0),
|
||
layers: item.tilemap.layers.length,
|
||
};
|
||
}
|
||
|
||
private syncSprite(item: DrawItem, onScreen: boolean, size: Size, shaders: ShaderSource): void {
|
||
let sprite = this.sprites.get(item.id);
|
||
if (item.type !== 'sprite' || !onScreen) {
|
||
if (sprite) sprite.visible = false;
|
||
const ghost = this.ghostSprites.get(item.id);
|
||
if (ghost) disposeSpriteMesh(ghost);
|
||
this.ghostSprites.delete(item.id);
|
||
this.videosShown.delete(item.id);
|
||
// A driven video (#404) is kept ready at its Video time while its sprite is switched off or out
|
||
// of view: switched on, it showed its plain colour until the video had loaded. A free one plays
|
||
// on, heard in a run, while its sprite is only out of view; switched off, it is held (#420).
|
||
if (item.type !== 'sprite') return;
|
||
if (this.wearVideo(item, false) !== item) this.animated = true;
|
||
else if (item.enabled && item.texture && isVideoKey(item.texture.file)) {
|
||
videoLibrary.get(item.texture.file);
|
||
this.animated = true;
|
||
}
|
||
return;
|
||
}
|
||
if (!sprite) {
|
||
sprite = newSpriteMesh();
|
||
this.sprites.set(item.id, sprite);
|
||
this.objects.addChild(sprite);
|
||
}
|
||
sprite.visible = true;
|
||
// A video (#402) moves by itself like a Time shader does.
|
||
if (wornShader(item.props, shaders)?.time || (item.texture && isVideoKey(item.texture.file)))
|
||
this.animated = true;
|
||
const drawn = this.wearVideo(item);
|
||
let ghost = this.ghostSprites.get(item.id);
|
||
// A driven video (#404) is left out until it has its first picture: its plain colour flashed
|
||
// white at the start of a run and at a cut. So is one brought in while it still seeks, once the
|
||
// view has held its last frame long enough (`holdsForVideo`).
|
||
const key = drawn !== item ? (drawn.texture?.file ?? '') : null;
|
||
if (
|
||
key !== null &&
|
||
!(
|
||
videoLibrary.hasPicture(key) &&
|
||
(this.videosShown.get(item.id) === key || videoLibrary.showsAsked(key))
|
||
)
|
||
) {
|
||
sprite.visible = false;
|
||
if (ghost) disposeSpriteMesh(ghost);
|
||
this.ghostSprites.delete(item.id);
|
||
this.videosShown.delete(item.id);
|
||
this.videosLeftOut.add(item.id);
|
||
return;
|
||
}
|
||
if (key !== null) this.videosShown.set(item.id, key);
|
||
else this.videosShown.delete(item.id);
|
||
syncSpriteMesh(sprite, drawn, size, textureLibrary, shaders);
|
||
if (!this.ghosted(item, onScreen)) {
|
||
if (ghost) disposeSpriteMesh(ghost);
|
||
this.ghostSprites.delete(item.id);
|
||
return;
|
||
}
|
||
if (!ghost) {
|
||
ghost = newSpriteMesh();
|
||
ghost.alpha = CLIP_GHOST_ALPHA;
|
||
this.ghostSprites.set(item.id, ghost);
|
||
}
|
||
syncSpriteMesh(ghost, drawn, size, textureLibrary, shaders);
|
||
}
|
||
|
||
/**
|
||
* The item with the player of its own a driven video (#404) has, per view and object. One that is
|
||
* not `seen` is never heard.
|
||
*/
|
||
private wearVideo<T extends VideoAhead>(item: T, seen = true): T {
|
||
if (!item.texture) return item;
|
||
const texture = videoLibrary.wear(
|
||
item.texture,
|
||
item.props,
|
||
this.videoWearer(item),
|
||
seen ? this.hearsVideo() : 'none',
|
||
);
|
||
return texture === item.texture ? item : { ...item, texture };
|
||
}
|
||
|
||
private videoWearer(item: Pick<DrawItem, 'id'>): string {
|
||
return `${this.wearer}:${item.id}`;
|
||
}
|
||
|
||
/**
|
||
* What the player of a driven video drawn here for `objectId` is doing (test hook): the one it
|
||
* showed last, else the newest it opened; null for none.
|
||
*/
|
||
videoOf(objectId: string): ReturnType<typeof videoLibrary.debugInfo>[number] | null {
|
||
const mark = `@${this.videoWearer({ id: objectId })}`;
|
||
const players = videoLibrary.debugInfo().filter((player) => player.key.endsWith(mark));
|
||
const shown = this.videosShown.get(objectId);
|
||
return players.find((player) => player.key === shown) ?? players[players.length - 1] ?? null;
|
||
}
|
||
|
||
/** Whether a driven video (#404) this view wears is still without its first picture. */
|
||
videosLoading(): boolean {
|
||
return videoLibrary.loading(this.wearer);
|
||
}
|
||
|
||
/**
|
||
* Whether the last redraw did not show a driven video (#404) it should show: its player has no
|
||
* picture yet, or still seeks to the one asked for, as after loading a clip that starts later.
|
||
* Unlike `videosLoading`, one opened ahead or switched off does not count.
|
||
*/
|
||
missesVideo(): boolean {
|
||
return this.videosLeftOut.size > 0;
|
||
}
|
||
|
||
/** Whether the last redraw left out the sprite `objectId` so, see `missesVideo`. */
|
||
missesVideoOf(objectId: string): boolean {
|
||
return this.videosLeftOut.has(objectId);
|
||
}
|
||
|
||
/** Whether a driven video (#404) drawn here may be heard: in a run, and in the animation preview. */
|
||
protected hearsVideo(): VideoHearing {
|
||
return 'none';
|
||
}
|
||
|
||
/**
|
||
* The typeface a text is drawn in and how wide that makes it. Both are what a font asset is
|
||
* for, and both arrive asynchronously, so a test cannot read them off the model.
|
||
*/
|
||
textInfo(id: string): ReturnType<TextPicture['info']> | null {
|
||
const text = this.texts.get(id);
|
||
return text?.mesh.visible ? text.info() : null;
|
||
}
|
||
|
||
private syncText(
|
||
item: DrawItem,
|
||
onScreen: boolean,
|
||
scale: number,
|
||
shaders: ShaderSource,
|
||
grid: number | null,
|
||
): void {
|
||
let text = this.texts.get(item.id);
|
||
if (item.type !== 'text' || !onScreen) {
|
||
if (text) text.mesh.visible = false;
|
||
this.ghostTexts.get(item.id)?.destroy();
|
||
this.ghostTexts.delete(item.id);
|
||
return;
|
||
}
|
||
if (!text) {
|
||
text = new TextPicture();
|
||
this.texts.set(item.id, text);
|
||
this.objects.addChild(text.mesh);
|
||
}
|
||
text.mesh.visible = true;
|
||
if (wornShader(item.props, shaders)?.time) this.animated = true;
|
||
text.sync(item, scale, shaders, grid);
|
||
let ghost = this.ghostTexts.get(item.id);
|
||
if (!this.ghosted(item, onScreen)) {
|
||
ghost?.destroy();
|
||
this.ghostTexts.delete(item.id);
|
||
return;
|
||
}
|
||
if (!ghost) {
|
||
ghost = new TextPicture();
|
||
ghost.mesh.alpha = CLIP_GHOST_ALPHA;
|
||
this.ghostTexts.set(item.id, ghost);
|
||
}
|
||
ghost.sync(item, scale, shaders, grid);
|
||
}
|
||
}
|
||
|
||
/** Only what a camera sees (#302): the items on the render layers in its `Sees`. */
|
||
function seenBy(camera: ActiveCamera, items: DrawItem[]): DrawItem[] {
|
||
return items.filter((item) => cameraSees(camera.props, item.renderLayer));
|
||
}
|
||
|
||
/**
|
||
* Whether an item is never culled by its own rectangle. A source is not: its particles may be
|
||
* anywhere on screen while it is not. Nor is a wearer of a shader that pads it (#69) or moves its
|
||
* corners (#79): both draw past its rectangle.
|
||
*/
|
||
function isUnbound(item: DrawItem, shaders: ShaderSource): boolean {
|
||
const worn = wornShader(item.props, shaders);
|
||
return item.type === 'particles' || (worn?.padding ?? 0) > 0 || Boolean(worn?.vertex);
|
||
}
|
||
|
||
/**
|
||
* What one overlay camera sees (#302), drawn like any view but handed its view and frame by the view
|
||
* it lies on, which also redraws it. See-through, and never hit: the mouse belongs to the main
|
||
* camera's view below (#304).
|
||
*/
|
||
class OverlayCameraView extends WorldViewport {
|
||
private camera: ActiveCamera | null = null;
|
||
private view: View | null = null;
|
||
private frame: Frame = { items: [], background: 0 };
|
||
|
||
constructor(project: () => Project) {
|
||
super({
|
||
get project() {
|
||
return project();
|
||
},
|
||
on: () => undefined,
|
||
});
|
||
this.eventMode = 'none';
|
||
this.bg.visible = false;
|
||
}
|
||
|
||
show(camera: ActiveCamera, view: View, frame: Frame, w: number, h: number): void {
|
||
this.camera = camera;
|
||
this.view = view;
|
||
this.frame = frame;
|
||
this.w = w;
|
||
this.h = h;
|
||
this.redraw();
|
||
}
|
||
|
||
shownRect(): View['rect'] {
|
||
return this.view?.rect ?? { x: 0, y: 0, w: 0, h: 0 };
|
||
}
|
||
|
||
protected override currentView(): View | null {
|
||
return this.view;
|
||
}
|
||
|
||
protected override mainCamera(): ActiveCamera | null {
|
||
return this.camera;
|
||
}
|
||
|
||
protected override collectFrame(): Frame {
|
||
return this.frame;
|
||
}
|
||
|
||
protected override get baseColor(): number {
|
||
return 0;
|
||
}
|
||
|
||
protected override paintsBackground(): boolean {
|
||
return false;
|
||
}
|
||
}
|