PropertyValue was number | string | boolean | list. A struct joins it, which costs two things the typechecker found rather than I did. An object on a data edge used to mean a live entity, so asEntity would have taken a struct for one and asString would have read .name off it. EntityHandle now carries a symbol brand – a symbol so no field name can impersonate it, and it never serializes, which suits a thing that exists only while the game runs. It is built in exactly one place. String(value) would have written a struct as [object Object] at 39 call sites. They go through textOf(), which is identical for primitives. normalizeGraph dropped everything but primitives from node params, so the List node lost its value on every save ([3,1,4] round-tripped to undefined). Variables were spared: they have a schema, params have none.
226 lines
8.6 KiB
TypeScript
226 lines
8.6 KiB
TypeScript
import { Container, type FederatedPointerEvent, Graphics, Text, TextStyle } from 'pixi.js';
|
|
import { isSymbolType } from '../../core/model/AssetTypes';
|
|
import { sortByZ, type Vec2, textOf } from '../../core/model/Properties';
|
|
import { footprintOf, textAnchor } from '../../core/properties/bounds';
|
|
import { rectAround, rectsIntersect } from '../../core/properties/camera';
|
|
import type { EditorState } from '../state';
|
|
import { type Size, Theme, Widget } from '../../ui';
|
|
import type { DrawItem } from '../../viewport/items';
|
|
import { drawItem } from '../../viewport/symbols';
|
|
import { textureLibrary } from '../../viewport/TextureLibrary';
|
|
import { type View, worldBounds, worldToScreen } from '../../viewport/view';
|
|
|
|
export interface Frame {
|
|
items: DrawItem[];
|
|
background: number;
|
|
}
|
|
|
|
export abstract class WorldViewport 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, Text>();
|
|
private visibleCount = 0;
|
|
protected lastItems: DrawItem[] = [];
|
|
|
|
protected constructor(protected readonly state: EditorState) {
|
|
super();
|
|
this.world.addChild(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());
|
|
state.on('runtime', () => {
|
|
if (this.visible) this.redraw();
|
|
});
|
|
// Unconditional: a picture arriving fires once per image, not per frame.
|
|
textureLibrary.onChange(() => this.redraw());
|
|
}
|
|
|
|
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 {}
|
|
|
|
protected syncDecorations(_item: DrawItem, _onScreen: boolean, _scale: number, _size: Size): void {}
|
|
|
|
/** An item disappeared: drop whatever `syncDecorations` created for it. */
|
|
protected disposeDecorations(_id: string): void {}
|
|
|
|
protected onItemPointerDown(_objectId: string, _e: FederatedPointerEvent): void {}
|
|
|
|
debugInfo(): { rendering: boolean; visibleItems: number } {
|
|
return { rendering: this.currentView() !== null, visibleItems: this.visibleCount };
|
|
}
|
|
|
|
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 an object occupies, so tests can sample the pixels inside it. */
|
|
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 local = worldToScreen(view, item.x, item.y);
|
|
const origin = this.getGlobalPosition();
|
|
const size = footprintOf(item.type, item.props);
|
|
return {
|
|
x: origin.x + local.x,
|
|
y: origin.y + local.y,
|
|
w: size.w * view.scale,
|
|
h: size.h * 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;
|
|
if (!view) return;
|
|
|
|
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.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);
|
|
const bounds = worldBounds(view);
|
|
|
|
const seen = new Set<string>();
|
|
for (const item of items) {
|
|
seen.add(item.id);
|
|
const size = footprintOf(item.type, item.props);
|
|
// A switched-off object is not drawn at all, in the scene view as in the game; it is
|
|
// selected and switched back on from the hierarchy panel.
|
|
const onScreen = item.enabled && rectsIntersect(rectAround(item.x, item.y, size.w, size.h), 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);
|
|
}
|
|
node.visible = onScreen;
|
|
node.eventMode = this.itemsInteractive && onScreen ? 'static' : 'none';
|
|
this.syncDecorations(item, onScreen, view.scale, size);
|
|
this.syncText(item, onScreen, view.scale);
|
|
if (!onScreen) continue;
|
|
this.visibleCount++;
|
|
node.position.set(item.x, item.y);
|
|
drawItem(node, item, size, view.scale, textureLibrary);
|
|
}
|
|
this.afterItems(items, view);
|
|
for (const [id, node] of this.nodes) {
|
|
if (seen.has(id)) continue;
|
|
this.texts.get(id)?.destroy();
|
|
this.texts.delete(id);
|
|
node.destroy();
|
|
this.nodes.delete(id);
|
|
this.disposeDecorations(id);
|
|
}
|
|
this.syncDrawOrder(items);
|
|
}
|
|
|
|
/**
|
|
* Re-attaches the object nodes in ascending `z`, ties in hierarchy order. Explicit rather than
|
|
* Pixi's lazy `sortableChildren` so the order is deterministic and honours hierarchy changes.
|
|
*/
|
|
private syncDrawOrder(items: DrawItem[]): void {
|
|
const ordered = sortByZ(items.filter((item) => !isSymbolType(item.type)));
|
|
this.objects.removeChildren();
|
|
for (const item of ordered) {
|
|
const node = this.nodes.get(item.id);
|
|
if (node) this.objects.addChild(node);
|
|
// Text is a sibling because Graphics may not have children; keep it right above its box.
|
|
const text = this.texts.get(item.id);
|
|
if (text) this.objects.addChild(text);
|
|
}
|
|
}
|
|
|
|
/** Resolution follows the zoom in coarse steps: re-rendering the glyphs per wheel notch is wasteful. */
|
|
private syncText(item: DrawItem, onScreen: boolean, scale: number): void {
|
|
let text = this.texts.get(item.id);
|
|
if (item.type !== 'text' || !onScreen) {
|
|
if (text) text.visible = false;
|
|
return;
|
|
}
|
|
if (!text) {
|
|
text = new Text({
|
|
text: '',
|
|
style: new TextStyle({ fontFamily: Theme.font, fontSize: 32, fill: 0xffffff }),
|
|
});
|
|
text.eventMode = 'none';
|
|
this.texts.set(item.id, text);
|
|
this.objects.addChild(text);
|
|
}
|
|
text.visible = true;
|
|
text.position.set(item.x, item.y);
|
|
text.text = textOf(item.props.text ?? '');
|
|
const size = Math.max(1, Number(item.props.fontSize ?? 32));
|
|
if (text.style.fontSize !== size) text.style.fontSize = size;
|
|
const color = Number(item.props.color ?? 0xffffff);
|
|
if (text.style.fill !== color) text.style.fill = color;
|
|
text.anchor.set(textAnchor(item.props), 0.5);
|
|
const resolution = Math.min(4, Math.max(1, (Math.round(scale * 2) / 2) * 2));
|
|
if (text.resolution !== resolution) text.resolution = resolution;
|
|
}
|
|
}
|