Files
feuerwerk/src/viewport/WorldViewport.ts
T
adminandClaude Opus 5.5 808e1eefc2
game-maker / gate (push) Successful in 3m2s
game-maker / suite (push) Successful in 15m29s
Clean up duplicates and dead code, and fix the + menu check (#85)
Refactoring round 9 covers everything since round 8 (14972d0): 62
commits with about 29 000 new lines in src, api, tools and tests.
Behaviour is unchanged. The one fix is in a check: e2e 04 counted 25
asset types, and the shader function from #303 makes 26, so the run
of a526e1b failed there.

Duplicate code (jscpd over src, 50 tokens, without the two licence
texts: 35 clones / 289 lines before, 29 / 232 after):

- ShaderPicture (new, viewport/shaderPicture.ts) is the picture a
  camera's shader and a particle field's shader are laid over. It
  keeps the render texture while its size holds and frees the
  replaced one once the shader holds the new one. CameraShade and
  ParticleField.shadeField drew it the same way, line for line.
- CameraShade builds its mesh on the first draw. Before, every redraw
  of a view without a camera shader built a mesh and threw it away.
- emptyHost in inspector/host.ts builds the empty host the Inspector,
  the Variables tab and the Tilemap panel start from.
- paddedQuad(null) is the plain unit quad. symbols.ts and
  shaderPasses.ts each wrote it out by hand.
- In graph/nested.ts, calls of shader functions and On behavior
  nodes are followed by one walk, `reaches`, over ids `namedBy` a
  node type.

Dead code:

- ShaderEditor.colorFieldOf and the getter CurveCanvas.snapping had
  no reader.
- 22 values used only in their own file lose their `export`. Types
  and the node catalogues of shaders and particles stay exported.
- Kept on purpose: PhysicsSystem.holdsJoint is read by tests only,
  but it is their one view of whether a joint holds.
- A pass with stricter lint rules over every file changed since
  round 8 found no bug. PixelArtEditor.started is now readonly.

Comments and docs:

- Ten comments told history ("since #190", "used to", "any more").
  They now describe what is. The bundle is md5-identical before and
  after, and the four e2e files minify to the same bytes.
- README: joint and shader function in the asset list, joints on the
  Physics row, what the Shaders page covers, two CI runners.
- e2e 04 keeps the number of asset types once, as ASSET_TYPE_COUNT.
- screenshot.png in the README is a new picture of the editor
  (3840x2160 instead of 1825x1140), taken by the user.

Tests:
- Gate on melektaus-atx: 229 files, 3514 tests, CHECK EXIT 0
  (+3 for emptyHost, +1 for the unit quad).
- e2e 1-4, 31, 63 on melektaus-atx: ALL PASSED, 195 checks. That is
  the + menu of 04, the field shader of 31 and the camera shader of
  63, both through ShaderPicture.
- No new e2e check, so the sum stays 1728.
- Counter-checks, each put back and compared with cmp: reaches that
  never goes on fails 1 test, listensTo reading the wrong parameter
  fails 1, emptyHost dropping what a panel hands in fails 1, folding
  without a relayout fails 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:38:39 +02:00

826 lines
31 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 Mesh, 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 { type Size, ui, Widget } from '../ui';
import { fontLibrary } from './FontLibrary';
import { drawSlots } from './drawOrder';
import type { DrawItem } 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 } from './cameraShade';
import { cameraSees } from '../core/model/renderLayers';
/** 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;
/**
* 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;
}
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;
culled: { node: Graphics; extent: Extent | null }[];
} | null = null;
/** Full redraws and camera-only moves since start (test hook). */
private redraws = 0;
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();
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) {
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).
*/
animate(drawnBefore: number): void {
if (this.visible && this.animated && 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);
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,
};
}
override redraw(): void {
const view = this.currentView();
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.drawn = null;
if (!view) {
this.unshadeCamera();
this.syncOverlays(null, null, null);
return;
}
this.redraws++;
const frame = this.collectFrame();
const { background } = frame;
const camera = this.mainCamera();
const items = camera ? seenBy(camera, frame.items) : frame.items;
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 shaders = shaderLibrary.source(this.state.project);
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);
const extent = footprintExtent(item.type, item.props, item.m, item.frame);
// 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. A source is never culled by its
// own little square: 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.
const worn = wornShader(item.props, shaders);
const unbound = item.type === 'particles' || (worn?.padding ?? 0) > 0 || Boolean(worn?.vertex);
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';
this.syncDecorations(item, onScreen, view.scale, extent);
this.syncText(item, onScreen, view.scale, shaders);
this.syncSprite(item, onScreen, size, shaders);
this.syncParticles(item, onScreen, shaders, view, bounds);
this.syncTilemap(item, 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),
);
}
this.afterItems(items, view);
this.drawOnScreen(items, view);
this.drawn = { scale: view.scale, w: rect.w, h: rect.h, 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.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(this.shadingCamera(camera, items), view, shaders, background);
this.syncOverlays(frame, view, camera);
}
/**
* 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 mesh = camera ? shade.draw(this.shaded, camera, shaders, ppu, renderer, clear) : 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 view = overlayView(overlayCamera, main!, cameraFrame(camera!.props));
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);
return;
}
if (!sprite) {
sprite = newSpriteMesh();
this.sprites.set(item.id, sprite);
this.objects.addChild(sprite);
}
sprite.visible = true;
if (wornShader(item.props, shaders)?.time) this.animated = true;
syncSpriteMesh(sprite, item, size, textureLibrary, shaders);
let ghost = this.ghostSprites.get(item.id);
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, item, size, textureLibrary, shaders);
}
/**
* 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): 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);
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);
}
}
/** 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));
}
/**
* 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;
}
}