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 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(); /** Text content of text items: a sibling of the item's node, kept next to it in z. */ private readonly texts = new Map(); /** Pictures of sprite items: siblings as well, each right under its node. */ private readonly sprites = new Map(); /** The drawn particles of each source, one container – and one draw call – per source. */ private readonly fields = new Map(); /** The meshes of each placed tilemap, one container per layer (#174). */ private readonly tilemaps = new Map(); /** * 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(); /** The cut-off part of a sprite or text, drawn faintly under it while its clip area is open. */ private readonly ghostSprites = new Map(); private readonly ghostTexts = new Map(); 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(); /** 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(); 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(); 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[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(); 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(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(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): 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[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 | 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; } }