A shader ran only inside the rectangle of what wears it: the quad has the size of the sprite, and outside of it no fragment runs. An outline or a glow built with Sample texture was cut off at the border. The issue still spoke of Filter.padding, but since #77 the shader is no filter any more, the limit is the geometry. - The Output node has a number Padding next to Edge: so many pixels of the picture per side the wearer draws beyond it. 0 is the default and draws as before, 256 is the most. - The quad grows and its UVs grow with it, so the picture keeps its place and size. In the margin the UVs run on below 0 and above 1. - Transparent is a fourth Edge mode: outside of the picture there is nothing. It is cut in the fragment (texelAt), so it needs no copy of the picture with an address mode of its own. - Edge holds everywhere alike, also for Texture color, Texture alpha and unconnected inputs. - Padding above 0 with Edge on Clamp puts the hint "Padding with Clamp smears the border" on the Output. The shader still compiles. A chosen Clamp and the default cannot be told apart. - All four wearers draw the margin. A sprite counts in pixels of its frame, a text in its units, a particle source grows every particle about its centre. - A nine-slice sprite gets a ring around its nine fields, as wide as so many border pixels. The fields themselves stay. - Without a picture a pixel is one unit of the wearer's own size, or of the particle size. - Particles now take the Edge mode of their shader. It had no effect on them before. - An object wearing a padded shader is never culled, or its margin would vanish at the edge of the screen. - The preview in the shader tab shows the margin and leaves room for it. - Picking, the selection frame, the size and the collider stay with the rectangle without the margin. Tests: - Gate on melektaus-atx right before this commit: 216 files, 3336 tests (3319 + 17), exit 0. - e2e 28,30,31,37,41 on melektaus-atx: 178 checks, all pass. e2e 1-37,47,48,58 ran before the last change (no culling with padding): 1281 checks, all pass. - 17 new unit tests: the compiler (padding, Transparent, the hint) and the geometry (quad, nine-slice ring). - Seven new e2e checks: four at the sprite in module 28, one each for text, particles and nine-slice. - 17 unit counter-probes all fail their tests. Three browser counter-probes fail their checks (5, 3 and 1 of them). - Three screenshots looked at: the sprite with Clamp and margin in the scene, the preview in the shader tab, the text. - The e2e sum of 1687 is counted (1680 + 7), the full suite and the smokes did not run. - Not seen: the hint at the node in a picture, particles and nine-slice in a picture, the game view while playing, sprites without a picture, vector and pixel art pictures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
630 lines
24 KiB
TypeScript
630 lines
24 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 { rectsIntersect } from '../core/properties/camera';
|
||
import type { ClipRegion } from '../core/properties/clip';
|
||
import type { Project } from '../core/model/Project';
|
||
import { type Size, 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 { type View, worldBounds, worldToScreen } from './view';
|
||
|
||
/** 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();
|
||
/** 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)[];
|
||
|
||
protected constructor(protected readonly state: S) {
|
||
super();
|
||
this.world.addChild(this.masks, this.objects, 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();
|
||
super.destroy(options);
|
||
}
|
||
|
||
override measure(): Size {
|
||
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
|
||
}
|
||
|
||
protected abstract currentView(): View | null;
|
||
|
||
/** 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.container.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) return;
|
||
|
||
this.redraws++;
|
||
const { items, background } = this.collectFrame();
|
||
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 padded shader, which draws past its rectangle (#69).
|
||
const unbound = item.type === 'particles' || (wornShader(item.props, shaders)?.padding ?? 0) > 0;
|
||
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);
|
||
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();
|
||
}
|
||
|
||
/**
|
||
* 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.container, 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): void {
|
||
let field = this.fields.get(item.id);
|
||
if (item.type !== 'particles' || !item.particles || !onScreen) {
|
||
if (field) field.container.visible = false;
|
||
return;
|
||
}
|
||
if (!field) {
|
||
field = new ParticleField();
|
||
this.fields.set(item.id, field);
|
||
this.objects.addChild(field.container);
|
||
}
|
||
field.container.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),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
}
|
||
}
|