Files
feuerwerk/src/editor/viewport/WorldViewport.ts
T
root 2cf788eedd Let a value be a bundle of named fields
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.
2026-09-10 08:14:18 +02:00

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;
}
}