A camera that follows smoothly stands between pixels most of the time, and pixel art then wanders between them. The new camera switch draws what the camera shows on the game view's own grid, while positions in the model keep their fractions. - `Pixel perfect` is a camera row, off by default. It works at every fixed game scale and at Auto, but not at Fit and not in the Scene tab, where one works with the fractions. - The grid has one step per device pixel of a world unit. At 100% that is whole units, at 500% fifths. The view carries it (`View.grid`), so no view draws with the grid of another. - The view looks from a point that puts the world's origin on a whole device pixel (`lookFrom`). This also covers odd camera sizes and views that cut the picture to an odd size. - Sprites, texts and tilemaps snap their top left corner when they are unturned, so an odd size lands on whole pixels too. A turned object snaps its pivot (`snapPlacement` in `pixelGrid.ts`). - Under a camera shader the picture is taken the same way, and the objects in it use the picture's grid. With Camera pixels that is one step per camera pixel. - An overlay camera has its own switch and snaps only while the main picture is at a fixed scale. - Particles are drawn where they are. - New test hook `__gm.gameDrawnRect(id)` reads where a sprite's picture is drawn, which `gameRect` (the model's) cannot tell. Found: the e2e fixture at 300% drew the crate at y 205.5 device pixels without moving the camera, because the view cut to an odd height puts its centre on half a pixel. With the switch on it is at 206. Tests: - Gate: 237 files, 3587 tests (+10 in pixel-grid.test.ts) - e2e 2,3: ALL PASSED, 75 checks (+1, 3j) - Counter-checks: without the origin snap 3 unit tests fail, with the centre snapped instead of the corner 2 fail Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
873 lines
33 KiB
TypeScript
873 lines
33 KiB
TypeScript
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, shadedPicture } from './cameraShade';
|
||
import { snapPlacement } from './pixelGrid';
|
||
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;
|
||
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 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 ||
|
||
(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).
|
||
*/
|
||
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,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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();
|
||
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 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);
|
||
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';
|
||
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),
|
||
);
|
||
}
|
||
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.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);
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
|
||
/** 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);
|
||
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,
|
||
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));
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
}
|