Files
feuerwerk/src/viewport/WorldViewport.ts
T
adminandClaude Opus 5.5 875c76026a Silence a free video once its sprite is destroyed (#420)
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>
2026-10-14 09:49:12 +02:00

1088 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}