Give the three asset tabs one base, and move the viewports into the editor
src/viewport/ held three classes only src/editor/ ever used, and framing.ts sat under graph/ although the scene view wants the same bounds sum. The graph, the texture and the container tab repeated the same header, subscription and disposal code with two silent divergences: the graph tab said "nothing shown" with null instead of '', and the texture tab refell back to the first asset on a selection change but not on a project event. These arrive together because GraphCanvas ties them: it reads framing from its new home, hands its fold marks to folding.ts, and its '' change is what GraphEditor must agree with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a2f2f8465c
commit
11981b40d0
15 files changed
+408
-425
No files matched your search
@@ -0,0 +1,156 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
import type { Asset } from '../core/model/Asset';
|
||||
import { findAsset } from '../core/model/Project';
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
HStack,
|
||||
Label,
|
||||
Menu,
|
||||
type MenuItem,
|
||||
Spacer,
|
||||
Theme,
|
||||
VStack,
|
||||
type Widget,
|
||||
} from '../ui';
|
||||
import type { EditorState } from './state';
|
||||
|
||||
/** The canvas each tab wraps: the header's Fit button and the tab's own plumbing need this much. */
|
||||
export interface TabCanvas {
|
||||
readonly currentAssetId: string;
|
||||
show(assetId: string): void;
|
||||
fit(): void;
|
||||
}
|
||||
|
||||
export interface AssetTabOptions {
|
||||
/** Shown left of the picker, and in the empty state as "No <noun>". */
|
||||
noun: string;
|
||||
/** Width reserved for the right-hand readout; a stack measures the label once, while empty. */
|
||||
infoWidth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Graph, Texture and Container tabs. Each follows the selection but keeps showing its last
|
||||
* asset when the selection moves elsewhere – clicking a sprite must not empty the tab being
|
||||
* worked in. "Nothing shown" is `''` throughout.
|
||||
*
|
||||
* A subclass finishes construction by calling `mount()`: the header needs the canvas (its Fit
|
||||
* button) and the canvas needs `state`, so the base cannot build one before the other exists.
|
||||
*/
|
||||
export abstract class AssetTabEditor<
|
||||
A extends Asset,
|
||||
C extends TabCanvas & Widget = TabCanvas & Widget,
|
||||
> extends VStack {
|
||||
protected readonly picker: Dropdown;
|
||||
protected readonly info: Label;
|
||||
/** Assigned by `mount`. Public because the check hooks and the play loop reach for it. */
|
||||
canvas!: C;
|
||||
private readonly headerBg = new Graphics();
|
||||
|
||||
constructor(
|
||||
protected readonly state: EditorState,
|
||||
private readonly options: AssetTabOptions,
|
||||
) {
|
||||
super();
|
||||
this.picker = new Dropdown('—', { width: 200, onClick: () => this.openPicker() });
|
||||
this.info = new Label('', { style: 'small', align: 'right' });
|
||||
this.info.minWidth = options.infoWidth;
|
||||
}
|
||||
|
||||
/** Builds the header around `canvas`, wires the state events and shows the first asset. */
|
||||
protected mount(canvas: C, extras: readonly Widget[] = []): void {
|
||||
this.canvas = canvas;
|
||||
const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
|
||||
header.minHeight = Theme.controlHeight + Theme.padding * 2;
|
||||
header.addChildAt(this.headerBg, 0);
|
||||
header.add(new Label(this.options.noun, { style: 'muted' }));
|
||||
header.add(this.picker);
|
||||
for (const widget of extras) header.add(widget);
|
||||
header.add(new Button('Fit', { onClick: () => this.canvas.fit() }));
|
||||
header.add(new Spacer(), 1);
|
||||
header.add(this.info);
|
||||
|
||||
this.add(header);
|
||||
this.add(canvas, 1);
|
||||
|
||||
this.state.on('selection', () => this.followSelection());
|
||||
this.state.on('project', () => this.refresh());
|
||||
this.state.on('load', () => {
|
||||
this.canvas.show('');
|
||||
this.followSelection();
|
||||
});
|
||||
this.followSelection();
|
||||
}
|
||||
|
||||
/** The asset currently shown, `''` for none – the check scripts read this. */
|
||||
get currentAssetId(): string {
|
||||
return this.canvas.currentAssetId;
|
||||
}
|
||||
|
||||
/** For the check scripts: the picker's width must not follow the name it happens to show. */
|
||||
get pickerWidth(): number {
|
||||
return this.picker.w;
|
||||
}
|
||||
|
||||
show(assetId: string): void {
|
||||
this.canvas.show(assetId);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
/** The selection's id for this kind of asset, `''` when the selection is about something else. */
|
||||
protected abstract selectedId(): string;
|
||||
|
||||
protected abstract isMine(asset: Asset): asset is A;
|
||||
|
||||
/** The right-hand readout; `null` is the empty state. */
|
||||
protected abstract infoText(asset: A | null): string;
|
||||
|
||||
/** Enable/disable whatever `extras` the subclass passed to `mount`. */
|
||||
protected syncExtras(_asset: A | null): void {}
|
||||
|
||||
protected asset(): A | null {
|
||||
const asset = findAsset(this.state.project, this.canvas.currentAssetId);
|
||||
return asset && this.isMine(asset) ? asset : null;
|
||||
}
|
||||
|
||||
protected candidates(): A[] {
|
||||
return this.state.project.assets
|
||||
.filter((a): a is A => this.isMine(a))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
protected followSelection(): void {
|
||||
const id = this.selectedId();
|
||||
// An empty id merely means "the selection is about something else"; the tab keeps its subject.
|
||||
if (id) this.canvas.show(id);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
protected refresh(): void {
|
||||
// A deleted asset leaves the canvas empty; fall back to whatever is left of this kind.
|
||||
if (!this.asset()) this.canvas.show(this.candidates()[0]?.id ?? '');
|
||||
const asset = this.asset();
|
||||
this.picker.setText(asset ? asset.name : `No ${this.options.noun.toLowerCase()}`);
|
||||
this.info.setText(asset ? this.infoText(asset) : '');
|
||||
this.syncExtras(asset);
|
||||
}
|
||||
|
||||
private openPicker(): void {
|
||||
const current = this.canvas.currentAssetId;
|
||||
const items: MenuItem[] = this.candidates().map((asset) => ({
|
||||
label: asset.id === current ? `● ${asset.name}` : asset.name,
|
||||
onSelect: () => this.state.select({ kind: 'asset', assetId: asset.id }),
|
||||
}));
|
||||
if (items.length === 0)
|
||||
items.push({ label: `No ${this.options.noun.toLowerCase()}s yet`, onSelect: () => undefined });
|
||||
new Menu(items, { searchable: true }).openBelow(this.picker);
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
super.redraw();
|
||||
this.headerBg.clear();
|
||||
const h = Theme.controlHeight + Theme.padding * 2;
|
||||
this.headerBg.rect(0, 0, this.w, h).fill(Theme.colors.panelHeader);
|
||||
this.headerBg.rect(0, h - 1, this.w, 1).fill(Theme.colors.border);
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import { findAsset, findObjectHolder } from '../core/model/Project';
|
||||
import type { ProjectStore } from '../core/store/ProjectStore';
|
||||
import type { TextureStore } from '../core/store/TextureStore';
|
||||
import { Button, HStack, Splitter, Theme, ui, VStack } from '../ui';
|
||||
import { GameViewport } from '../viewport/GameViewport';
|
||||
import { SceneViewport } from '../viewport/SceneViewport';
|
||||
import { GameViewport } from './viewport/GameViewport';
|
||||
import { SceneViewport } from './viewport/SceneViewport';
|
||||
import { attachAutosave } from './autosave';
|
||||
import { ContainerEditor } from './container/ContainerEditor';
|
||||
import { EditorRun } from './EditorRun';
|
||||
@@ -207,8 +207,6 @@ export class Editor implements PlayHost {
|
||||
return this.projects.boot();
|
||||
}
|
||||
|
||||
// --- PlayHost --------------------------------------------------------------------------------
|
||||
|
||||
showGame(): void {
|
||||
this.showTab('game');
|
||||
}
|
||||
@@ -217,8 +215,6 @@ export class Editor implements PlayHost {
|
||||
if (this.tab === 'game') this.showTab('scene');
|
||||
}
|
||||
|
||||
// --- frame -----------------------------------------------------------------------------------
|
||||
|
||||
private tick(dt: number): void {
|
||||
this.measureFps(dt);
|
||||
this.play.tick(dt);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { CollisionSystem } from '../runtime/systems/CollisionSystem';
|
||||
import { Input } from '../runtime/systems/Input';
|
||||
import { World } from '../runtime/World';
|
||||
import { ui } from '../ui';
|
||||
import type { GameViewport } from '../viewport/GameViewport';
|
||||
import type { GameViewport } from './viewport/GameViewport';
|
||||
import type { GraphEditor } from './graph/GraphEditor';
|
||||
import type { EditorState } from './state';
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Graphics } from 'pixi.js';
|
||||
import { findAsset, type ObjectHolder } from '../../core/model/Project';
|
||||
import { Theme } from '../../ui';
|
||||
import type { DrawItem } from '../../viewport/items';
|
||||
import { SceneViewport } from '../../viewport/SceneViewport';
|
||||
import { SceneViewport } from '../viewport/SceneViewport';
|
||||
import { type View, worldToScreen } from '../../viewport/view';
|
||||
import type { EditorState } from '../state';
|
||||
|
||||
|
||||
@@ -1,76 +1,16 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
import type { ContainerAsset } from '../../core/model/Asset';
|
||||
import { findAsset, listContainers } from '../../core/model/Project';
|
||||
import {
|
||||
activeDrag,
|
||||
Button,
|
||||
Dropdown,
|
||||
HStack,
|
||||
Label,
|
||||
Menu,
|
||||
type MenuItem,
|
||||
Spacer,
|
||||
Theme,
|
||||
VStack,
|
||||
} from '../../ui';
|
||||
import type { Asset, ContainerAsset } from '../../core/model/Asset';
|
||||
import { activeDrag } from '../../ui';
|
||||
import { AssetTabEditor } from '../AssetTabEditor';
|
||||
import type { EditorState } from '../state';
|
||||
import { ContainerCanvas } from './ContainerCanvas';
|
||||
|
||||
/** Room kept for the "N objects" readout, wide enough for a three-digit template. */
|
||||
const INFO_WIDTH = 110;
|
||||
|
||||
/**
|
||||
* The Container tab. It follows the selection, but keeps showing the last container when the
|
||||
* selection moves elsewhere – clicking a sprite must not empty the tab being worked in.
|
||||
*/
|
||||
export class ContainerEditor extends VStack {
|
||||
private readonly canvas: ContainerCanvas;
|
||||
private readonly picker: Dropdown;
|
||||
private readonly info: Label;
|
||||
private readonly headerBg = new Graphics();
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
super();
|
||||
const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
|
||||
header.minHeight = Theme.controlHeight + Theme.padding * 2;
|
||||
header.addChildAt(this.headerBg, 0);
|
||||
|
||||
header.add(new Label('Container', { style: 'muted' }));
|
||||
this.picker = new Dropdown('—', { width: 200, onClick: () => this.openPicker() });
|
||||
header.add(this.picker);
|
||||
header.add(new Button('Fit', { onClick: () => this.canvas.fit() }));
|
||||
header.add(new Spacer(), 1);
|
||||
this.info = new Label('', { style: 'small', align: 'right' });
|
||||
// Reserved, not measured: a stack sizes the label once, from the empty text it was built with.
|
||||
this.info.minWidth = INFO_WIDTH;
|
||||
header.add(this.info);
|
||||
|
||||
this.canvas = new ContainerCanvas(state);
|
||||
this.add(header);
|
||||
this.add(this.canvas, 1);
|
||||
|
||||
state.on('selection', () => this.followSelection());
|
||||
state.on('project', () => this.refresh());
|
||||
state.on('load', () => {
|
||||
this.canvas.show('');
|
||||
this.followSelection();
|
||||
});
|
||||
this.followSelection();
|
||||
}
|
||||
|
||||
/** The container currently shown – the check scripts read this. */
|
||||
get currentAssetId(): string {
|
||||
return this.canvas.currentAssetId;
|
||||
}
|
||||
|
||||
/** For the check scripts: the picker's width must not follow the name it happens to show. */
|
||||
get pickerWidth(): number {
|
||||
return this.picker.w;
|
||||
}
|
||||
|
||||
show(assetId: string): void {
|
||||
this.canvas.show(assetId);
|
||||
this.refresh();
|
||||
export class ContainerEditor extends AssetTabEditor<ContainerAsset, ContainerCanvas> {
|
||||
constructor(state: EditorState) {
|
||||
super(state, { noun: 'Container', infoWidth: INFO_WIDTH });
|
||||
this.mount(new ContainerCanvas(state));
|
||||
}
|
||||
|
||||
/** The pending drop of an asset dragged over the template (test hook). */
|
||||
@@ -89,48 +29,19 @@ export class ContainerEditor extends VStack {
|
||||
return { x: p.x, y: p.y, w: this.canvas.w, h: this.canvas.h };
|
||||
}
|
||||
|
||||
private followSelection(): void {
|
||||
const id = this.state.selectedContainerId;
|
||||
protected override selectedId(): string {
|
||||
// A dragged row selects on release, and whether that lands before or after the drop is up
|
||||
// to Pixi's event order. Following it in between would swap the tab to the very container
|
||||
// being dragged, which would then nest in itself, so the target stays put while a drag runs.
|
||||
if (id && !activeDrag()) this.canvas.show(id);
|
||||
this.refresh();
|
||||
return activeDrag() ? '' : (this.state.selectedContainerId ?? '');
|
||||
}
|
||||
|
||||
private asset(): ContainerAsset | null {
|
||||
const asset = findAsset(this.state.project, this.canvas.currentAssetId);
|
||||
return asset && asset.type === 'container' ? asset : null;
|
||||
protected override isMine(asset: Asset): asset is ContainerAsset {
|
||||
return asset.type === 'container';
|
||||
}
|
||||
|
||||
private containers(): ContainerAsset[] {
|
||||
return listContainers(this.state.project).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
private openPicker(): void {
|
||||
const current = this.canvas.currentAssetId;
|
||||
const items: MenuItem[] = this.containers().map((asset) => ({
|
||||
label: asset.id === current ? `● ${asset.name}` : asset.name,
|
||||
onSelect: () => this.state.select({ kind: 'asset', assetId: asset.id }),
|
||||
}));
|
||||
if (items.length === 0) items.push({ label: 'No containers yet', onSelect: () => undefined });
|
||||
new Menu(items, { searchable: true }).openBelow(this.picker);
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
// A deleted container leaves the canvas empty; fall back to whatever container is left.
|
||||
if (!this.asset()) this.canvas.show(this.containers()[0]?.id ?? '');
|
||||
const asset = this.asset();
|
||||
this.picker.setText(asset ? asset.name : 'No container');
|
||||
const count = asset?.objects.length ?? 0;
|
||||
this.info.setText(asset ? `${count} ${count === 1 ? 'object' : 'objects'}` : '');
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
super.redraw();
|
||||
this.headerBg.clear();
|
||||
const h = Theme.controlHeight + Theme.padding * 2;
|
||||
this.headerBg.rect(0, 0, this.w, h).fill(Theme.colors.panelHeader);
|
||||
this.headerBg.rect(0, h - 1, this.w, 1).fill(Theme.colors.border);
|
||||
protected override infoText(asset: ContainerAsset): string {
|
||||
const count = asset.objects.length;
|
||||
return `${count} ${count === 1 ? 'object' : 'objects'}`;
|
||||
}
|
||||
}
|
||||
@@ -40,9 +40,38 @@ export function boxesInBand(boxes: readonly Box[], band: Band): string[] {
|
||||
.map((b) => b.id);
|
||||
}
|
||||
|
||||
export interface Bounds {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rectangle enclosing every box, or null for none. `seed` starts the sum off – the scene view
|
||||
* passes the origin, because that is where a container is grabbed when placed and a template drawn
|
||||
* far to one side would otherwise hide it.
|
||||
*/
|
||||
export function boundsOf(boxes: readonly Box[], seed?: Bounds): Bounds | null {
|
||||
let { minX, minY, maxX, maxY } = seed ?? {
|
||||
minX: Infinity,
|
||||
minY: Infinity,
|
||||
maxX: -Infinity,
|
||||
maxY: -Infinity,
|
||||
};
|
||||
for (const b of boxes) {
|
||||
minX = Math.min(minX, b.x);
|
||||
minY = Math.min(minY, b.y);
|
||||
maxX = Math.max(maxX, b.x + b.w);
|
||||
maxY = Math.max(maxY, b.y + b.h);
|
||||
}
|
||||
return Number.isFinite(minX) ? { minX, minY, maxX, maxY } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The view that shows every box at once, centred, with `margin` screen pixels to spare.
|
||||
* Never zooms past 1: a two-node graph blown up to fill the screen helps nobody.
|
||||
* Never zooms past 1: a two-node graph blown up to fill the screen helps nobody. The scene view
|
||||
* deliberately does not share that cap, which is why only the bounds sum is common.
|
||||
*/
|
||||
export function frameBoxes(
|
||||
boxes: readonly Box[],
|
||||
@@ -50,17 +79,9 @@ export function frameBoxes(
|
||||
margin: number,
|
||||
limits: { min: number; max: number },
|
||||
): Framing {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const b of boxes) {
|
||||
minX = Math.min(minX, b.x);
|
||||
minY = Math.min(minY, b.y);
|
||||
maxX = Math.max(maxX, b.x + b.w);
|
||||
maxY = Math.max(maxY, b.y + b.h);
|
||||
}
|
||||
if (!Number.isFinite(minX)) return { zoom: 1, panX: view.w / 2, panY: view.h / 2 };
|
||||
const bounds = boundsOf(boxes);
|
||||
if (!bounds) return { zoom: 1, panX: view.w / 2, panY: view.h / 2 };
|
||||
const { minX, minY, maxX, maxY } = bounds;
|
||||
const width = Math.max(maxX - minX, 1);
|
||||
const height = Math.max(maxY - minY, 1);
|
||||
const fitted = Math.min((view.w - margin * 2) / width, (view.h - margin * 2) / height, 1);
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
Menu,
|
||||
type MenuItem,
|
||||
onDragChange,
|
||||
pressCount,
|
||||
type Size,
|
||||
Theme,
|
||||
textStyle,
|
||||
@@ -47,11 +48,18 @@ import {
|
||||
Widget,
|
||||
zoomFactor,
|
||||
} from '../../ui';
|
||||
import { pressCount } from '../../ui/clicks';
|
||||
import { removeNodes } from '../actions';
|
||||
import { type EditorState, nodeSelection } from '../state';
|
||||
import { foldedEdgeShown, foldMarkKey, foldMarkLabel } from './folding';
|
||||
import { type Box, boxesInBand, frameBoxes } from './framing';
|
||||
import {
|
||||
buildFoldMarks,
|
||||
foldedEdgeShown,
|
||||
type FoldMarkEnd,
|
||||
foldMarkAt,
|
||||
foldMarkKey,
|
||||
foldMarkLabel,
|
||||
foldMarkRadius,
|
||||
} from './folding';
|
||||
import { type Box, boxesInBand, frameBoxes } from '../framing';
|
||||
import { NodePalette } from './NodePalette';
|
||||
import { type PaletteEntry, paletteEntries } from './paletteEntries';
|
||||
import { NODE_WIDTH, NodeView, portColor, TITLE_HEIGHT } from './NodeView';
|
||||
@@ -83,14 +91,6 @@ interface FoldMark {
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** A mark while the edges are still adding themselves to it. */
|
||||
interface PendingMark extends Omit<FoldMark, 'text'> {
|
||||
names: string[];
|
||||
portX: number;
|
||||
color: number;
|
||||
hot: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The node graph of one behavior asset. Which behavior that is comes from the outside (`show`),
|
||||
* so the canvas never has an opinion about the selection – the GraphEditor around it does.
|
||||
@@ -107,7 +107,7 @@ export class GraphCanvas extends Widget {
|
||||
private readonly markLabels: Text[] = [];
|
||||
private markLabelCount = 0;
|
||||
private foldMarks: FoldMark[] = [];
|
||||
private readonly foldPending = new Map<string, PendingMark>();
|
||||
private foldEnds: FoldMarkEnd[] = [];
|
||||
/** Edges the last redraw really stroked as a curve – a hidden one is missing from it. */
|
||||
private readonly strokedEdges = new Set<string>();
|
||||
private readonly nodeLayer = new Container();
|
||||
@@ -116,7 +116,7 @@ export class GraphCanvas extends Widget {
|
||||
private readonly band = new Graphics();
|
||||
private readonly hint = new Text({ text: '', style: textStyle('muted') });
|
||||
private readonly views = new Map<string, NodeView>();
|
||||
private assetId: string | null = null;
|
||||
private assetId = '';
|
||||
private pan = { x: 0, y: 0 };
|
||||
private zoom = 1;
|
||||
private panning: { startX: number; startY: number; panX: number; panY: number } | null = null;
|
||||
@@ -180,12 +180,12 @@ export class GraphCanvas extends Widget {
|
||||
state.on('mode', () => this.redraw());
|
||||
}
|
||||
|
||||
get currentAssetId(): string | null {
|
||||
get currentAssetId(): string {
|
||||
return this.assetId;
|
||||
}
|
||||
|
||||
/** Switching refits the view, since the next graph is a new subject. */
|
||||
show(assetId: string | null): void {
|
||||
show(assetId: string): void {
|
||||
if (this.assetId === assetId) return;
|
||||
this.assetId = assetId;
|
||||
this.sync();
|
||||
@@ -211,8 +211,6 @@ export class GraphCanvas extends Widget {
|
||||
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
|
||||
}
|
||||
|
||||
// --- model binding ----------------------------------------------------------------------
|
||||
|
||||
private behaviors(): BehaviorLookup {
|
||||
return behaviorLookup(this.state.project);
|
||||
}
|
||||
@@ -227,7 +225,7 @@ export class GraphCanvas extends Widget {
|
||||
const asset = this.asset();
|
||||
if (!asset) {
|
||||
// A behavior that was deleted (or undone away) must not linger as a graph.
|
||||
this.assetId = null;
|
||||
this.assetId = '';
|
||||
for (const view of this.views.values()) view.destroy({ children: true });
|
||||
this.views.clear();
|
||||
this.redraw();
|
||||
@@ -306,8 +304,6 @@ export class GraphCanvas extends Widget {
|
||||
return { x: gx * this.zoom + this.pan.x, y: gy * this.zoom + this.pan.y };
|
||||
}
|
||||
|
||||
// --- pointer -----------------------------------------------------------------------------
|
||||
|
||||
private onCanvasPointerDown(e: FederatedPointerEvent): void {
|
||||
// The middle button pans from anywhere, a node under the pointer included – it is the one
|
||||
// gesture that never means "grab this". Nodes let it through for exactly that reason.
|
||||
@@ -613,8 +609,6 @@ export class GraphCanvas extends Widget {
|
||||
}
|
||||
}
|
||||
|
||||
// --- menus -------------------------------------------------------------------------------
|
||||
|
||||
private openPalette(globalX: number, globalY: number, at: { x: number; y: number }): void {
|
||||
new NodePalette(this.entries(), (entry) => this.addNode(entry, at)).open(globalX, globalY);
|
||||
}
|
||||
@@ -750,8 +744,6 @@ export class GraphCanvas extends Widget {
|
||||
]).open(globalX, globalY);
|
||||
}
|
||||
|
||||
// --- drawing -----------------------------------------------------------------------------
|
||||
|
||||
override layout(): void {
|
||||
this.hint.position.set(Theme.padding, this.h - Theme.padding - 16);
|
||||
if (this.needsFit) this.fitView();
|
||||
@@ -792,7 +784,7 @@ export class GraphCanvas extends Widget {
|
||||
this.foldMarks = [];
|
||||
this.markLabelCount = 0;
|
||||
this.strokedEdges.clear();
|
||||
this.foldPending.clear();
|
||||
this.foldEnds = [];
|
||||
for (const edge of asset.graph.edges) this.drawEdge(edge, isHot(trace?.edges, edge.id), selectedNodes);
|
||||
// After the curves, so no line drawn later crosses a mark that stands for a hidden one.
|
||||
this.drawFoldMarks();
|
||||
@@ -871,7 +863,7 @@ export class GraphCanvas extends Widget {
|
||||
this.strokedEdges.add(edge.id);
|
||||
}
|
||||
|
||||
/** Adds one end of a hidden link to its port's mark, creating that mark on first sight. */
|
||||
/** Records one end of a hidden link; `drawFoldMarks` groups them per port afterwards. */
|
||||
private addFoldMark(
|
||||
edge: GraphEdge,
|
||||
port: { x: number; y: number },
|
||||
@@ -881,38 +873,25 @@ export class GraphCanvas extends Widget {
|
||||
hot: boolean,
|
||||
): void {
|
||||
const [side, other] = isOutput ? [edge.from, edge.to.node] : [edge.to, edge.from.node];
|
||||
const key = foldMarkKey(side, isOutput);
|
||||
let mark = this.foldPending.get(key);
|
||||
if (!mark) {
|
||||
mark = {
|
||||
edgeIds: [],
|
||||
others: [],
|
||||
names: [],
|
||||
isOutput,
|
||||
x: port.x + (isOutput ? FOLD_STUB : -FOLD_STUB),
|
||||
y: port.y,
|
||||
portX: port.x,
|
||||
color,
|
||||
hot,
|
||||
};
|
||||
this.foldPending.set(key, mark);
|
||||
}
|
||||
mark.edgeIds.push(edge.id);
|
||||
mark.names.push(otherLabel);
|
||||
if (!mark.others.includes(other)) mark.others.push(other);
|
||||
// A pulse anywhere in the bundle colours the whole mark: it is the one thing worth spotting.
|
||||
if (hot && !mark.hot) {
|
||||
mark.hot = true;
|
||||
mark.color = color;
|
||||
}
|
||||
this.foldEnds.push({
|
||||
edgeId: edge.id,
|
||||
key: foldMarkKey(side, isOutput),
|
||||
other,
|
||||
otherLabel,
|
||||
isOutput,
|
||||
portX: port.x,
|
||||
portY: port.y,
|
||||
color,
|
||||
hot,
|
||||
});
|
||||
}
|
||||
|
||||
/** A stub out of the port, a dot, and what waits at the other end – one per port, not per link. */
|
||||
private drawFoldMarks(): void {
|
||||
for (const mark of this.foldPending.values()) {
|
||||
for (const mark of buildFoldMarks(this.foldEnds, FOLD_STUB)) {
|
||||
const { x, y, color, isOutput } = mark;
|
||||
this.edges.moveTo(mark.portX, y).lineTo(x, y).stroke({ width: 2, color, alpha: 0.9 });
|
||||
const radius = mark.edgeIds.length > 1 ? FOLD_MARK_RADIUS + 2 : FOLD_MARK_RADIUS;
|
||||
const radius = foldMarkRadius(mark.edgeIds.length, FOLD_MARK_RADIUS);
|
||||
this.edges.circle(x, y, radius).fill(color);
|
||||
this.edges.circle(x, y, radius).stroke({ width: 1, color: Theme.colors.node });
|
||||
const label = this.markLabel();
|
||||
@@ -941,11 +920,7 @@ export class GraphCanvas extends Widget {
|
||||
}
|
||||
|
||||
private markAt(g: { x: number; y: number }): FoldMark | null {
|
||||
for (const m of this.foldMarks) {
|
||||
const radius = m.edgeIds.length > 1 ? FOLD_MARK_RADIUS + 2 : FOLD_MARK_RADIUS;
|
||||
if (Math.hypot(g.x - m.x, g.y - m.y) <= radius + 4) return m;
|
||||
}
|
||||
return null;
|
||||
return foldMarkAt(this.foldMarks, g, FOLD_MARK_RADIUS);
|
||||
}
|
||||
|
||||
private syncGrid(): void {
|
||||
@@ -963,8 +938,6 @@ export class GraphCanvas extends Widget {
|
||||
});
|
||||
}
|
||||
|
||||
// --- test hooks -----------------------------------------------------------------------------
|
||||
|
||||
/** Global screen position of a port (tests drive connections with it). */
|
||||
portGlobal(nodeId: string, port: string, isOutput: boolean): { x: number; y: number } | null {
|
||||
const view = this.views.get(nodeId);
|
||||
|
||||
@@ -1,115 +1,38 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
import type { BehaviorAsset } from '../../core/model/Asset';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Spacer, Theme, VStack } from '../../ui';
|
||||
import type { Asset, BehaviorAsset } from '../../core/model/Asset';
|
||||
import { Button } from '../../ui';
|
||||
import { AssetTabEditor } from '../AssetTabEditor';
|
||||
import type { EditorState } from '../state';
|
||||
import { GraphCanvas } from './GraphCanvas';
|
||||
|
||||
/** Room kept for the "N nodes · M links" readout, wide enough for a three-digit graph. */
|
||||
const INFO_WIDTH = 150;
|
||||
|
||||
/**
|
||||
* The Graph tab. It follows the selection, but keeps showing the last behavior when the
|
||||
* selection moves elsewhere – clicking a sprite must not empty the graph being edited.
|
||||
*/
|
||||
export class GraphEditor extends VStack {
|
||||
/** The canvas itself: the live redraw while playing and the check scripts reach for it. */
|
||||
readonly canvas: GraphCanvas;
|
||||
private readonly picker: Dropdown;
|
||||
private readonly info: Label;
|
||||
export class GraphEditor extends AssetTabEditor<BehaviorAsset, GraphCanvas> {
|
||||
private readonly addButton: Button;
|
||||
private readonly headerBg = new Graphics();
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
super();
|
||||
const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
|
||||
header.minHeight = Theme.controlHeight + Theme.padding * 2;
|
||||
header.addChildAt(this.headerBg, 0);
|
||||
|
||||
header.add(new Label('Behavior', { style: 'muted' }));
|
||||
this.picker = new Dropdown('—', { width: 200, onClick: () => this.openPicker() });
|
||||
header.add(this.picker);
|
||||
constructor(state: EditorState) {
|
||||
super(state, { noun: 'Behavior', infoWidth: INFO_WIDTH });
|
||||
this.addButton = new Button('Add node…', { onClick: () => this.canvas.addNodeFrom(this.addButton) });
|
||||
header.add(this.addButton);
|
||||
header.add(new Button('Fit', { onClick: () => this.canvas.fit() }));
|
||||
header.add(new Spacer(), 1);
|
||||
this.info = new Label('', { style: 'small', align: 'right' });
|
||||
// Reserved, not measured: a stack sizes the label once, from the empty text it was built with.
|
||||
this.info.minWidth = INFO_WIDTH;
|
||||
header.add(this.info);
|
||||
|
||||
this.canvas = new GraphCanvas(state);
|
||||
this.add(header);
|
||||
this.add(this.canvas, 1);
|
||||
|
||||
state.on('selection', () => this.followSelection());
|
||||
state.on('project', () => this.refresh());
|
||||
this.mount(new GraphCanvas(state), [this.addButton]);
|
||||
// Adding nodes is an edit, and edits made while playing would be thrown away with the run.
|
||||
state.on('mode', () => this.refresh());
|
||||
state.on('load', () => {
|
||||
this.canvas.show(null);
|
||||
this.followSelection();
|
||||
});
|
||||
this.followSelection();
|
||||
}
|
||||
|
||||
/** The behavior currently shown – the check scripts read this. */
|
||||
get currentAssetId(): string | null {
|
||||
return this.canvas.currentAssetId;
|
||||
protected override selectedId(): string {
|
||||
return this.state.selectedBehaviorId ?? '';
|
||||
}
|
||||
|
||||
/** For the check scripts: the picker's width must not follow the name it happens to show. */
|
||||
get pickerWidth(): number {
|
||||
return this.picker.w;
|
||||
protected override isMine(asset: Asset): asset is BehaviorAsset {
|
||||
return asset.type === 'behavior';
|
||||
}
|
||||
|
||||
private followSelection(): void {
|
||||
const id = this.state.selectedBehaviorId;
|
||||
// Null merely means "the selection is about something else"; the tab keeps its subject.
|
||||
if (id) this.canvas.show(id);
|
||||
this.refresh();
|
||||
protected override infoText(asset: BehaviorAsset): string {
|
||||
const nodes = asset.graph.nodes.length;
|
||||
const links = asset.graph.edges.length;
|
||||
return `${nodes} ${nodes === 1 ? 'node' : 'nodes'} · ${links} ${links === 1 ? 'link' : 'links'}`;
|
||||
}
|
||||
|
||||
private asset(): BehaviorAsset | null {
|
||||
const id = this.canvas.currentAssetId;
|
||||
const asset = id ? findAsset(this.state.project, id) : undefined;
|
||||
return asset && asset.type === 'behavior' ? asset : null;
|
||||
}
|
||||
|
||||
private behaviors(): BehaviorAsset[] {
|
||||
return this.state.project.assets
|
||||
.filter((a): a is BehaviorAsset => a.type === 'behavior')
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
private openPicker(): void {
|
||||
const current = this.canvas.currentAssetId;
|
||||
const items: MenuItem[] = this.behaviors().map((asset) => ({
|
||||
label: asset.id === current ? `● ${asset.name}` : asset.name,
|
||||
onSelect: () => this.state.select({ kind: 'asset', assetId: asset.id }),
|
||||
}));
|
||||
if (items.length === 0) items.push({ label: 'No behaviors yet', onSelect: () => undefined });
|
||||
new Menu(items, { searchable: true }).openBelow(this.picker);
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
// A deleted behavior leaves the canvas empty; fall back to whatever behavior is left.
|
||||
if (!this.asset()) this.canvas.show(this.behaviors()[0]?.id ?? null);
|
||||
const asset = this.asset();
|
||||
this.picker.setText(asset ? asset.name : 'No behavior');
|
||||
const nodes = asset?.graph.nodes.length ?? 0;
|
||||
const links = asset?.graph.edges.length ?? 0;
|
||||
this.info.setText(
|
||||
asset ? `${nodes} ${nodes === 1 ? 'node' : 'nodes'} · ${links} ${links === 1 ? 'link' : 'links'}` : '',
|
||||
);
|
||||
protected override syncExtras(asset: BehaviorAsset | null): void {
|
||||
this.addButton.setEnabled(Boolean(asset) && this.state.mode === 'edit');
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
super.redraw();
|
||||
this.headerBg.clear();
|
||||
const h = Theme.controlHeight + Theme.padding * 2;
|
||||
this.headerBg.rect(0, 0, this.w, h).fill(Theme.colors.panelHeader);
|
||||
this.headerBg.rect(0, h - 1, this.w, 1).fill(Theme.colors.border);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -1,8 +1,7 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
import { SliceFramesCommand } from '../../core/commands/frameCommands';
|
||||
import type { TextureAsset } from '../../core/model/Asset';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Spacer, Theme, VStack } from '../../ui';
|
||||
import type { Asset, TextureAsset } from '../../core/model/Asset';
|
||||
import { Button } from '../../ui';
|
||||
import { AssetTabEditor } from '../AssetTabEditor';
|
||||
import type { EditorState } from '../state';
|
||||
import { SliceDialog } from './SliceDialog';
|
||||
import { TextureCanvas } from './TextureCanvas';
|
||||
@@ -10,61 +9,13 @@ import { TextureCanvas } from './TextureCanvas';
|
||||
/** Room kept for the "W × H px · N frames" readout, wide enough for a four-digit sheet. */
|
||||
const INFO_WIDTH = 190;
|
||||
|
||||
/**
|
||||
* The Texture tab. It follows the selection, but keeps showing the last texture when the
|
||||
* selection moves elsewhere – clicking a sprite must not empty the tab being worked in.
|
||||
*/
|
||||
export class TextureEditor extends VStack {
|
||||
private readonly canvas: TextureCanvas;
|
||||
private readonly picker: Dropdown;
|
||||
private readonly info: Label;
|
||||
export class TextureEditor extends AssetTabEditor<TextureAsset, TextureCanvas> {
|
||||
private readonly sliceButton: Button;
|
||||
private readonly headerBg = new Graphics();
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
super();
|
||||
const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
|
||||
header.minHeight = Theme.controlHeight + Theme.padding * 2;
|
||||
header.addChildAt(this.headerBg, 0);
|
||||
|
||||
header.add(new Label('Texture', { style: 'muted' }));
|
||||
this.picker = new Dropdown('—', { width: 200, onClick: () => this.openPicker() });
|
||||
header.add(this.picker);
|
||||
constructor(state: EditorState) {
|
||||
super(state, { noun: 'Texture', infoWidth: INFO_WIDTH });
|
||||
this.sliceButton = new Button('Slice…', { onClick: () => this.openSlice() });
|
||||
header.add(this.sliceButton);
|
||||
header.add(new Button('Fit', { onClick: () => this.canvas.fit() }));
|
||||
header.add(new Spacer(), 1);
|
||||
this.info = new Label('', { style: 'small', align: 'right' });
|
||||
// Reserved, not measured: a stack sizes the label once, from the empty text it was built with.
|
||||
this.info.minWidth = INFO_WIDTH;
|
||||
header.add(this.info);
|
||||
|
||||
this.canvas = new TextureCanvas(state);
|
||||
this.add(header);
|
||||
this.add(this.canvas, 1);
|
||||
|
||||
state.on('selection', () => this.followSelection());
|
||||
state.on('project', () => this.refresh());
|
||||
state.on('load', () => {
|
||||
this.canvas.show('');
|
||||
this.followSelection();
|
||||
});
|
||||
this.followSelection();
|
||||
}
|
||||
|
||||
/** The texture currently shown – the check scripts read this. */
|
||||
get currentAssetId(): string {
|
||||
return this.canvas.currentAssetId;
|
||||
}
|
||||
|
||||
/** For the check scripts: the picker's width must not follow the name it happens to show. */
|
||||
get pickerWidth(): number {
|
||||
return this.picker.w;
|
||||
}
|
||||
|
||||
show(assetId: string): void {
|
||||
this.canvas.show(assetId);
|
||||
this.refresh();
|
||||
this.mount(new TextureCanvas(state), [this.sliceButton]);
|
||||
}
|
||||
|
||||
/** Where an image pixel of the shown sheet sits on screen – for the check scripts. */
|
||||
@@ -72,32 +23,22 @@ export class TextureEditor extends VStack {
|
||||
return this.canvas.imageToScreen(x, y);
|
||||
}
|
||||
|
||||
private followSelection(): void {
|
||||
const id = this.state.selectedTextureId;
|
||||
// Null merely means "the selection is about something else"; the tab keeps its subject.
|
||||
if (id) this.canvas.show(id);
|
||||
else if (!this.asset()) this.canvas.show(this.textures()[0]?.id ?? '');
|
||||
this.refresh();
|
||||
protected override selectedId(): string {
|
||||
return this.state.selectedTextureId ?? '';
|
||||
}
|
||||
|
||||
private asset(): TextureAsset | null {
|
||||
const asset = findAsset(this.state.project, this.canvas.currentAssetId);
|
||||
return asset && asset.type === 'texture' ? asset : null;
|
||||
protected override isMine(asset: Asset): asset is TextureAsset {
|
||||
return asset.type === 'texture';
|
||||
}
|
||||
|
||||
private textures(): TextureAsset[] {
|
||||
return this.state.project.assets
|
||||
.filter((a): a is TextureAsset => a.type === 'texture')
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
protected override infoText(asset: TextureAsset): string {
|
||||
const count = asset.frames.length;
|
||||
return `${asset.width} × ${asset.height} px · ${count} ${count === 1 ? 'frame' : 'frames'}`;
|
||||
}
|
||||
|
||||
private openPicker(): void {
|
||||
const items: MenuItem[] = this.textures().map((asset) => ({
|
||||
label: asset.id === this.canvas.currentAssetId ? `● ${asset.name}` : asset.name,
|
||||
onSelect: () => this.state.select({ kind: 'asset', assetId: asset.id }),
|
||||
}));
|
||||
if (items.length === 0) items.push({ label: 'No textures yet', onSelect: () => undefined });
|
||||
new Menu(items, { searchable: true }).openBelow(this.picker);
|
||||
protected override syncExtras(asset: TextureAsset | null): void {
|
||||
// Slicing needs a known picture size; without one it could only fail with a notice.
|
||||
this.sliceButton.setEnabled(Boolean(asset && asset.width > 0 && asset.height > 0));
|
||||
}
|
||||
|
||||
private openSlice(): void {
|
||||
@@ -107,23 +48,4 @@ export class TextureEditor extends VStack {
|
||||
this.state.execute(new SliceFramesCommand(asset.id, options, replace));
|
||||
}).openCentered();
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
const asset = this.asset();
|
||||
this.picker.setText(asset ? asset.name : 'No texture');
|
||||
const count = asset?.frames.length ?? 0;
|
||||
this.info.setText(
|
||||
asset ? `${asset.width} × ${asset.height} px · ${count} ${count === 1 ? 'frame' : 'frames'}` : '',
|
||||
);
|
||||
// Slicing needs a known picture size; without one it could only fail with a notice.
|
||||
this.sliceButton.setEnabled(Boolean(asset && asset.width > 0 && asset.height > 0));
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
super.redraw();
|
||||
this.headerBg.clear();
|
||||
const h = Theme.controlHeight + Theme.padding * 2;
|
||||
this.headerBg.rect(0, 0, this.w, h).fill(Theme.colors.panelHeader);
|
||||
this.headerBg.rect(0, h - 1, this.w, 1).fill(Theme.colors.border);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* The scene seen through its active camera, letterboxed into the widget: the running world while
|
||||
* playing, the model as a still preview otherwise.
|
||||
*/
|
||||
import { type FederatedPointerEvent, Text } from 'pixi.js';
|
||||
import { isSymbolType } from '../../core/model/AssetTypes';
|
||||
import type { EditorState } from '../state';
|
||||
import type { World } from '../../runtime/World';
|
||||
import { textStyle } from '../../ui';
|
||||
import { type ActiveCamera, cameraOfItems, cameraOfWorld } from '../../viewport/activeCamera';
|
||||
import { collectHolderItems, collectWorldItems } from '../../viewport/items';
|
||||
import { cameraView, isInside, screenToWorld, type View } from '../../viewport/view';
|
||||
import { type Frame, WorldViewport } from './WorldViewport';
|
||||
|
||||
const LETTERBOX_COLOR = 0x000000;
|
||||
|
||||
export class GameViewport extends WorldViewport {
|
||||
private readonly hint = new Text({ text: 'No active camera', style: textStyle('muted') });
|
||||
private readonly editorRunCaption = new Text({
|
||||
text: 'Behaviors run here · keys and clicks go to the game',
|
||||
style: textStyle('muted'),
|
||||
});
|
||||
private playWorld: World | null = null;
|
||||
/** The editor run's world: it takes the mouse, but it is never what this view draws. */
|
||||
private editorWorld: World | null = null;
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state);
|
||||
this.hint.eventMode = 'none';
|
||||
this.hint.anchor.set(0.5);
|
||||
this.addChild(this.hint);
|
||||
this.editorRunCaption.eventMode = 'none';
|
||||
this.addChild(this.editorRunCaption);
|
||||
|
||||
this.on('pointerdown', (e: FederatedPointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
this.reportPointer(e);
|
||||
this.pointerWorld()?.setPointerDown(true);
|
||||
});
|
||||
this.on('globalpointermove', (e: FederatedPointerEvent) => this.reportPointer(e));
|
||||
this.on('pointerup', () => this.pointerWorld()?.setPointerDown(false));
|
||||
this.on('pointerupoutside', () => this.pointerWorld()?.setPointerDown(false));
|
||||
}
|
||||
|
||||
/** null = not playing. */
|
||||
setWorld(world: World | null): void {
|
||||
this.playWorld = world;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** null = no behavior is running outside play mode. */
|
||||
setEditorWorld(world: World | null): void {
|
||||
this.editorWorld = world;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** Where the mouse is reported: the running game, or the behaviors running without it. */
|
||||
private pointerWorld(): World | null {
|
||||
return this.playWorld ?? this.editorWorld;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skipped while the tab is hidden: the preview costs a collect of the whole scene and would
|
||||
* otherwise run on every selection change behind another tab. Showing the tab relayouts the
|
||||
* stack, which resizes this widget and thereby redraws it.
|
||||
*/
|
||||
override redraw(): void {
|
||||
if (!this.visible) return;
|
||||
super.redraw();
|
||||
}
|
||||
|
||||
protected override currentView(): View | null {
|
||||
const camera = this.activeCamera();
|
||||
return camera ? cameraView(camera, this.w, this.h) : null;
|
||||
}
|
||||
|
||||
private activeCamera(): ActiveCamera | null {
|
||||
const world = this.playWorld;
|
||||
return world ? cameraOfWorld(world) : cameraOfItems(this.modelFrame().items);
|
||||
}
|
||||
|
||||
private modelFrame(): Frame {
|
||||
return collectHolderItems(this.state.project, this.state.activeScene, LETTERBOX_COLOR);
|
||||
}
|
||||
|
||||
/** `camera`: object id of the camera the view is through, empty when none is switched on. */
|
||||
override debugInfo(): { rendering: boolean; visibleItems: number; camera: string } {
|
||||
return { ...super.debugInfo(), camera: this.activeCamera()?.id ?? '' };
|
||||
}
|
||||
|
||||
protected override collectFrame(): Frame {
|
||||
if (this.playWorld) return collectWorldItems(this.playWorld);
|
||||
const frame = this.modelFrame();
|
||||
// Symbols are editor decoration, so the preview drops them: it shows what playing would show.
|
||||
return { ...frame, items: frame.items.filter((item) => !isSymbolType(item.type)) };
|
||||
}
|
||||
|
||||
protected override get baseColor(): number {
|
||||
return LETTERBOX_COLOR;
|
||||
}
|
||||
|
||||
protected override beforeItems(view: View | null): void {
|
||||
this.hint.visible = view === null;
|
||||
this.hint.position.set(this.w / 2, this.h / 2);
|
||||
// Which tab holds the keyboard is otherwise nowhere to be seen.
|
||||
this.editorRunCaption.visible = this.editorWorld !== null && !this.playWorld;
|
||||
this.editorRunCaption.position.set(8, 6);
|
||||
}
|
||||
|
||||
/** Outside the camera frame counts as "not in view": that region is letterbox, not game. */
|
||||
private reportPointer(e: FederatedPointerEvent): void {
|
||||
const world = this.pointerWorld();
|
||||
const view = this.currentView();
|
||||
if (!world || !view) return;
|
||||
const local = this.toLocal(e.global);
|
||||
const point = screenToWorld(view, local.x, local.y);
|
||||
world.movePointer(point.x, point.y, isInside(view, local.x, local.y));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* The editable view of a scene. It shows `state.project`, which in play mode is the runtime
|
||||
* copy, so live positions are visible and stay editable. A container's template is the same
|
||||
* work on the same shape of data, so the Container tab subclasses this and swaps `holder()`.
|
||||
*/
|
||||
import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
|
||||
import { CompoundCommand } from '../../core/commands/CompoundCommand';
|
||||
import { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
|
||||
import { isSymbolType } from '../../core/model/AssetTypes';
|
||||
import { findObject, subtreeRoots } from '../../core/model/hierarchy';
|
||||
import { objectRef, resolvedHolder } from '../../core/model/instance';
|
||||
import { placementRejection } from '../../core/model/placement';
|
||||
import type { Position, Vec2 } from '../../core/model/Properties';
|
||||
import { findAsset, type ObjectHolder } from '../../core/model/Project';
|
||||
import { footprintOf } from '../../core/properties/bounds';
|
||||
import { boundsOf } from '../framing';
|
||||
import { type EditorState, objectSelection } from '../state';
|
||||
import {
|
||||
activeDrag,
|
||||
clamp,
|
||||
drawGrid,
|
||||
endDrag,
|
||||
onDragChange,
|
||||
type Size,
|
||||
Theme,
|
||||
textStyle,
|
||||
zoomFactor,
|
||||
} from '../../ui';
|
||||
import { collectHolderItems, type DrawItem } from '../../viewport/items';
|
||||
import { dashedLine } from '../../viewport/symbols';
|
||||
import { freeView, screenToWorld, type View, worldToScreen } from '../../viewport/view';
|
||||
import { type Frame, WorldViewport } from './WorldViewport';
|
||||
|
||||
/** Pixels of travel before a press turns into a drag. */
|
||||
const MARQUEE_THRESHOLD = 3;
|
||||
const DRAG_THRESHOLD = 2;
|
||||
const GRID_STEP = 100;
|
||||
/** Below this on-screen spacing the grid is noise rather than orientation, so it is dropped. */
|
||||
const MIN_GRID_STEP = 12;
|
||||
const MIN_ZOOM = 0.1;
|
||||
const MAX_ZOOM = 8;
|
||||
/** Breathing room left around the content by `fit()`, in screen pixels. */
|
||||
const FIT_MARGIN = 40;
|
||||
|
||||
interface ObjectDrag {
|
||||
holderId: string;
|
||||
items: { objectId: string; start: Position }[];
|
||||
pointer: Vec2;
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
/** An asset hovering over the view on its way in: where it would land, and whether it may. */
|
||||
interface DropPreview {
|
||||
assetId: string;
|
||||
at: Vec2;
|
||||
rejection: string;
|
||||
}
|
||||
|
||||
/** A rubber-band selection in progress, in viewport-local pixels. */
|
||||
interface Marquee {
|
||||
start: Vec2;
|
||||
current: Vec2;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export class SceneViewport extends WorldViewport {
|
||||
private readonly grid = new Graphics();
|
||||
private readonly links = new Graphics();
|
||||
private readonly gizmos = new Graphics();
|
||||
private readonly labels = new Map<string, Text>();
|
||||
/** World point at the viewport centre, and zoom. */
|
||||
private view = { x: 0, y: 0, zoom: 1 };
|
||||
private panning: { startX: number; startY: number; viewX: number; viewY: number } | null = null;
|
||||
private dragging: ObjectDrag | null = null;
|
||||
private marquee: Marquee | null = null;
|
||||
private dropPreview: DropPreview | null = null;
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state);
|
||||
// Grid behind the content, links below the symbols, gizmos above everything clipped.
|
||||
this.addChildAt(this.grid, 1);
|
||||
this.world.addChildAt(this.links, 1);
|
||||
this.content.addChild(this.gizmos);
|
||||
this.links.eventMode = 'none';
|
||||
|
||||
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
|
||||
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
|
||||
this.on('pointerup', () => this.onPointerUp());
|
||||
this.on('pointerupoutside', () => this.onPointerUp());
|
||||
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
|
||||
|
||||
state.on('load', () => {
|
||||
this.view = { x: 0, y: 0, zoom: 1 };
|
||||
this.redraw();
|
||||
});
|
||||
// A drag can end anywhere – over another panel, off the canvas – and this view never hears
|
||||
// about it, so the preview would stay on screen without this.
|
||||
onDragChange(() => {
|
||||
if (!activeDrag()) this.setDropPreview(null);
|
||||
});
|
||||
}
|
||||
|
||||
/** What this view shows and edits; the Container tab returns its container asset instead. */
|
||||
protected holder(): ObjectHolder | undefined {
|
||||
return this.state.activeScene;
|
||||
}
|
||||
|
||||
/** The holder with the content of placed containers resolved in – what the view shows. */
|
||||
private resolved(): ObjectHolder | undefined {
|
||||
const holder = this.holder();
|
||||
return holder && resolvedHolder(this.state.project, holder);
|
||||
}
|
||||
|
||||
/** The pending drop: where a dragged asset would land and whether it may (test hook). */
|
||||
dropInfo(): { assetId: string; x: number; y: number; rejection: string } | null {
|
||||
const p = this.dropPreview;
|
||||
return p ? { assetId: p.assetId, x: p.at.x, y: p.at.y, rejection: p.rejection } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frames everything the holder contains. The origin is always included: it is where a container
|
||||
* is grabbed when placed, so a template drawn far off to one side would otherwise hide it.
|
||||
*/
|
||||
fit(): void {
|
||||
const { items } = this.collectFrame();
|
||||
const boxes = items.map((item) => {
|
||||
const { w, h } = footprintOf(item.type, item.props);
|
||||
return { id: item.id, x: item.x - w / 2, y: item.y - h / 2, w, h };
|
||||
});
|
||||
const { minX, minY, maxX, maxY } = boundsOf(boxes, { minX: 0, minY: 0, maxX: 0, maxY: 0 })!;
|
||||
const width = Math.max(maxX - minX, 1);
|
||||
const height = Math.max(maxY - minY, 1);
|
||||
this.view = {
|
||||
x: (minX + maxX) / 2,
|
||||
y: (minY + maxY) / 2,
|
||||
zoom: clamp(
|
||||
Math.min((this.w - FIT_MARGIN * 2) / width, (this.h - FIT_MARGIN * 2) / height),
|
||||
MIN_ZOOM,
|
||||
MAX_ZOOM,
|
||||
),
|
||||
};
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
protected override currentView(): View {
|
||||
return freeView(this.view.x, this.view.y, this.view.zoom, this.w, this.h);
|
||||
}
|
||||
|
||||
protected override collectFrame(): Frame {
|
||||
return collectHolderItems(this.state.project, this.holder(), Theme.colors.viewport);
|
||||
}
|
||||
|
||||
protected override get baseColor(): number {
|
||||
return Theme.colors.viewport;
|
||||
}
|
||||
|
||||
/** Without a scene there is nothing to frame: the neutral base colour stands alone. */
|
||||
protected override paintsBackground(): boolean {
|
||||
return Boolean(this.holder());
|
||||
}
|
||||
|
||||
protected override get itemsInteractive(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
private onPointerDown(e: FederatedPointerEvent): void {
|
||||
if (e.button === 1 || (e.button === 0 && e.target === this && e.shiftKey)) {
|
||||
this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y };
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (e.button === 0 && e.target === this) {
|
||||
// Empty space: a click deselects, a drag becomes a rubber band – decided on release.
|
||||
const local = this.toLocal(e.global);
|
||||
this.marquee = {
|
||||
start: { x: local.x, y: local.y },
|
||||
current: { x: local.x, y: local.y },
|
||||
active: false,
|
||||
};
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
private onPointerMove(e: FederatedPointerEvent): void {
|
||||
const incoming = activeDrag();
|
||||
if (incoming) {
|
||||
this.trackIncomingAsset(incoming.kind === 'asset' ? incoming.id : '', e);
|
||||
return;
|
||||
}
|
||||
if (this.panning) {
|
||||
this.view.x = this.panning.viewX - (e.global.x - this.panning.startX) / this.view.zoom;
|
||||
this.view.y = this.panning.viewY - (e.global.y - this.panning.startY) / this.view.zoom;
|
||||
this.redraw();
|
||||
} else if (this.dragging) {
|
||||
const local = this.toLocal(e.global);
|
||||
const dx = (local.x - this.dragging.pointer.x) / this.view.zoom;
|
||||
const dy = (local.y - this.dragging.pointer.y) / this.view.zoom;
|
||||
if (!this.dragging.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
||||
this.dragging.moved = true;
|
||||
const holder = this.holder();
|
||||
if (!holder) return;
|
||||
// A world-space delta is a local-space delta as well (no rotation/scale in the hierarchy).
|
||||
for (const item of this.dragging.items) {
|
||||
objectRef(this.state.project, holder, item.objectId)?.setPosition({
|
||||
x: Math.round(item.start.x + dx),
|
||||
y: Math.round(item.start.y + dy),
|
||||
z: item.start.z,
|
||||
});
|
||||
}
|
||||
this.state.preview();
|
||||
} else if (this.marquee) {
|
||||
const local = this.toLocal(e.global);
|
||||
this.marquee.current = { x: local.x, y: local.y };
|
||||
if (
|
||||
!this.marquee.active &&
|
||||
Math.hypot(local.x - this.marquee.start.x, local.y - this.marquee.start.y) < MARQUEE_THRESHOLD
|
||||
)
|
||||
return;
|
||||
this.marquee.active = true;
|
||||
this.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows an asset dragged in from the panel. `assetId` is empty for payloads this view does
|
||||
* not take; the preview is dropped whenever the pointer leaves it, so nothing lands blind.
|
||||
*/
|
||||
private trackIncomingAsset(assetId: string, e: FederatedPointerEvent): void {
|
||||
const local = this.toLocal(e.global);
|
||||
const holder = this.holder();
|
||||
const asset = assetId ? findAsset(this.state.project, assetId) : undefined;
|
||||
const inside = local.x >= 0 && local.x <= this.w && local.y >= 0 && local.y <= this.h;
|
||||
if (!asset || !holder || !inside) {
|
||||
this.setDropPreview(null);
|
||||
return;
|
||||
}
|
||||
const world = screenToWorld(this.currentView(), local.x, local.y);
|
||||
this.setDropPreview({
|
||||
assetId,
|
||||
at: { x: Math.round(world.x), y: Math.round(world.y) },
|
||||
rejection: placementRejection(this.state.project, holder, asset),
|
||||
});
|
||||
}
|
||||
|
||||
private setDropPreview(preview: DropPreview | null): void {
|
||||
if (!preview && !this.dropPreview) return;
|
||||
this.dropPreview = preview;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
private dropAsset(preview: DropPreview): void {
|
||||
const holder = this.holder();
|
||||
if (!holder) return;
|
||||
if (preview.rejection) {
|
||||
this.state.setNotice(preview.rejection);
|
||||
return;
|
||||
}
|
||||
const command = new PlaceAssetCommand(holder.id, preview.assetId, preview.at);
|
||||
if (this.state.execute(command)) this.state.select(objectSelection(holder.id, [command.objectId]));
|
||||
}
|
||||
|
||||
private onPointerUp(): void {
|
||||
const preview = this.dropPreview;
|
||||
if (preview) {
|
||||
this.setDropPreview(null);
|
||||
endDrag();
|
||||
this.dropAsset(preview);
|
||||
return;
|
||||
}
|
||||
this.panning = null;
|
||||
const marquee = this.marquee;
|
||||
this.marquee = null;
|
||||
if (marquee) {
|
||||
if (marquee.active) this.selectInMarquee(marquee);
|
||||
else this.state.select(null);
|
||||
this.redraw();
|
||||
}
|
||||
const drag = this.dragging;
|
||||
this.dragging = null;
|
||||
if (!drag || !drag.moved) return;
|
||||
const holder = this.holder();
|
||||
if (!holder) return;
|
||||
// The preview moved the objects already; put them back so the command records the start.
|
||||
const moves: MoveSceneObjectCommand[] = [];
|
||||
for (const item of drag.items) {
|
||||
const ref = objectRef(this.state.project, holder, item.objectId);
|
||||
if (!ref) continue;
|
||||
const next = { ...ref.object.position };
|
||||
ref.setPosition(item.start);
|
||||
moves.push(new MoveSceneObjectCommand(drag.holderId, item.objectId, next));
|
||||
}
|
||||
if (moves.length === 1) this.state.execute(moves[0]!);
|
||||
else if (moves.length > 1) this.state.execute(new CompoundCommand('Move objects', moves));
|
||||
}
|
||||
|
||||
private onWheel(e: FederatedWheelEvent): void {
|
||||
const local = this.toLocal(e.global);
|
||||
const before = screenToWorld(this.currentView(), local.x, local.y);
|
||||
this.view.zoom = clamp(this.view.zoom * zoomFactor(e.deltaY), MIN_ZOOM, MAX_ZOOM);
|
||||
const after = screenToWorld(this.currentView(), local.x, local.y);
|
||||
this.view.x += before.x - after.x;
|
||||
this.view.y += before.y - after.y;
|
||||
this.redraw();
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
protected override onItemPointerDown(objectId: string, e: FederatedPointerEvent): void {
|
||||
if (e.button !== 0) return;
|
||||
const holder = this.resolved();
|
||||
const object = holder && findObject(holder, objectId);
|
||||
if (!holder || !object) return;
|
||||
e.stopPropagation();
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey) {
|
||||
this.state.toggleObject(holder.id, objectId);
|
||||
return;
|
||||
}
|
||||
// Clicking a selected object keeps the selection so the whole group can be dragged.
|
||||
if (!this.state.selectedObjectIds.includes(objectId))
|
||||
this.state.select(objectSelection(holder.id, [objectId]));
|
||||
// Only the tops of the selected subtrees move; descendants follow through the hierarchy.
|
||||
const items = subtreeRoots(holder, this.state.selectedObjectIds)
|
||||
.map((id) => findObject(holder, id))
|
||||
.filter((o): o is NonNullable<typeof o> => Boolean(o))
|
||||
.map((o) => ({ objectId: o.id, start: { ...o.position } }));
|
||||
const local = this.toLocal(e.global);
|
||||
this.dragging = { holderId: holder.id, items, pointer: { x: local.x, y: local.y }, moved: false };
|
||||
}
|
||||
|
||||
/** Selects every object whose position – not its bounds – lies inside the band. */
|
||||
private selectInMarquee(marquee: Marquee): void {
|
||||
const holder = this.holder();
|
||||
if (!holder) return;
|
||||
const view = this.currentView();
|
||||
const a = screenToWorld(view, marquee.start.x, marquee.start.y);
|
||||
const b = screenToWorld(view, marquee.current.x, marquee.current.y);
|
||||
const minX = Math.min(a.x, b.x);
|
||||
const maxX = Math.max(a.x, b.x);
|
||||
const minY = Math.min(a.y, b.y);
|
||||
const maxY = Math.max(a.y, b.y);
|
||||
const ids = this.lastItems
|
||||
.filter((i) => i.enabled && i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY)
|
||||
.map((i) => i.id);
|
||||
this.state.select(objectSelection(holder.id, ids));
|
||||
}
|
||||
|
||||
protected override beforeItems(): void {
|
||||
this.syncGrid();
|
||||
this.gizmos.clear();
|
||||
}
|
||||
|
||||
protected override afterItems(items: DrawItem[], view: View): void {
|
||||
this.drawParentLinks(items, view.scale);
|
||||
this.drawGizmos(items, view);
|
||||
}
|
||||
|
||||
protected override syncDecorations(item: DrawItem, onScreen: boolean, scale: number, size: Size): void {
|
||||
this.syncLabel(item, onScreen, scale, size);
|
||||
}
|
||||
|
||||
protected override disposeDecorations(id: string): void {
|
||||
this.labels.get(id)?.destroy();
|
||||
this.labels.delete(id);
|
||||
}
|
||||
|
||||
private syncGrid(): void {
|
||||
this.grid.clear();
|
||||
const step = GRID_STEP * this.view.zoom;
|
||||
if (step < MIN_GRID_STEP) return;
|
||||
const origin = worldToScreen(this.currentView(), 0, 0);
|
||||
drawGrid(this.grid, {
|
||||
originX: origin.x,
|
||||
originY: origin.y,
|
||||
step,
|
||||
w: this.w,
|
||||
h: this.h,
|
||||
alpha: 0.05,
|
||||
axisAlpha: 0.15,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashed line from every parented symbol to its parent. Sprites show their nesting in the
|
||||
* hierarchy panel instead, and colliders sit on their body, so neither gets a line.
|
||||
*/
|
||||
private drawParentLinks(items: DrawItem[], scale: number): void {
|
||||
this.links.clear();
|
||||
const byId = new Map(items.map((i) => [i.id, i]));
|
||||
for (const item of items) {
|
||||
if (!isSymbolType(item.type) || !item.parentId || item.type === 'collider' || !item.enabled) continue;
|
||||
const parent = byId.get(item.parentId);
|
||||
if (!parent || !parent.enabled) continue;
|
||||
const color = item.type === 'behavior' ? Theme.colors.behavior : Theme.colors.textMuted;
|
||||
dashedLine(this.links, item.x, item.y, parent.x, parent.y, 8 / scale, 6 / scale);
|
||||
this.links.stroke({ width: 1.5 / scale, color, alpha: 0.8 });
|
||||
this.links.circle(parent.x, parent.y, 3 / scale).fill(color);
|
||||
}
|
||||
}
|
||||
|
||||
private syncLabel(item: DrawItem, show: boolean, scale: number, size: Size): void {
|
||||
let label = this.labels.get(item.id);
|
||||
if (!show || !isSymbolType(item.type)) {
|
||||
if (label) label.visible = false;
|
||||
return;
|
||||
}
|
||||
if (!label) {
|
||||
label = new Text({ text: '', style: textStyle('viewportLabelMuted') });
|
||||
label.eventMode = 'none';
|
||||
this.labels.set(item.id, label);
|
||||
this.overlay.addChild(label);
|
||||
}
|
||||
label.visible = true;
|
||||
label.text = item.name;
|
||||
label.scale.set(1 / scale);
|
||||
if (item.type === 'camera' || item.type === 'collider')
|
||||
label.position.set(item.x - size.w / 2, item.y - size.h / 2 - 16 / scale);
|
||||
else label.position.set(item.x + size.w / 2 + 4 / scale, item.y - 8 / scale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Outline at the size the object will have – the footprint answers "does it fit here", which a
|
||||
* cursor alone does not. Red means the drop would be refused; the crosshair is where it snaps.
|
||||
*/
|
||||
private drawDropPreview(view: View): void {
|
||||
const preview = this.dropPreview;
|
||||
const asset = preview && findAsset(this.state.project, preview.assetId);
|
||||
if (!preview || !asset) return;
|
||||
const color = preview.rejection ? Theme.colors.danger : Theme.colors.accent;
|
||||
const { w, h } = footprintOf(asset.type, asset.props);
|
||||
const centre = worldToScreen(view, preview.at.x, preview.at.y);
|
||||
const sw = Math.max(w * view.scale, 8);
|
||||
const sh = Math.max(h * view.scale, 8);
|
||||
this.gizmos
|
||||
.rect(centre.x - sw / 2, centre.y - sh / 2, sw, sh)
|
||||
.fill({ color, alpha: 0.12 })
|
||||
.stroke({ width: 2, color });
|
||||
this.gizmos
|
||||
.moveTo(centre.x - 6, centre.y)
|
||||
.lineTo(centre.x + 6, centre.y)
|
||||
.moveTo(centre.x, centre.y - 6)
|
||||
.lineTo(centre.x, centre.y + 6)
|
||||
.stroke({ width: 1, color });
|
||||
}
|
||||
|
||||
/** Selection outlines – only the primary object gets corner handles – and the rubber band. */
|
||||
private drawGizmos(items: DrawItem[], view: View): void {
|
||||
const s = this.state.selection;
|
||||
if (s?.kind === 'object') {
|
||||
for (const id of s.objectIds) {
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (!item || !item.enabled) continue;
|
||||
const { w, h } = footprintOf(item.type, item.props);
|
||||
const tl = worldToScreen(view, item.x - w / 2, item.y - h / 2);
|
||||
const sw = w * view.scale;
|
||||
const sh = h * view.scale;
|
||||
this.gizmos.rect(tl.x - 2, tl.y - 2, sw + 4, sh + 4).stroke({ width: 2, color: Theme.colors.gizmo });
|
||||
if (id !== s.objectId) continue;
|
||||
for (const [cx, cy] of [
|
||||
[tl.x, tl.y],
|
||||
[tl.x + sw, tl.y],
|
||||
[tl.x, tl.y + sh],
|
||||
[tl.x + sw, tl.y + sh],
|
||||
] as const) {
|
||||
this.gizmos.rect(cx - 3, cy - 3, 6, 6).fill(Theme.colors.gizmo);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.drawDropPreview(view);
|
||||
const m = this.marquee;
|
||||
if (m?.active) {
|
||||
const x = Math.min(m.start.x, m.current.x);
|
||||
const y = Math.min(m.start.y, m.current.y);
|
||||
const w = Math.abs(m.current.x - m.start.x);
|
||||
const h = Math.abs(m.current.y - m.start.y);
|
||||
this.gizmos
|
||||
.rect(x, y, w, h)
|
||||
.fill({ color: Theme.colors.marquee, alpha: 0.12 })
|
||||
.stroke({ width: 1, color: Theme.colors.marquee });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { Container, type FederatedPointerEvent, Graphics, Text, TextStyle } from 'pixi.js';
|
||||
import { isSymbolType } from '../../core/model/AssetTypes';
|
||||
import { sortByZ, type Vec2 } 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 = String(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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user