Add a decimals option, fix dropdown overflow and the texture filter
- NumberField takes a decimals option (default 3); frame rects and the slice dialog use 0, matching their integer model. - Button truncates its label to the drawn width; Dropdown reserves the chevron slot. NodeView reuses the shared truncate(). - Setting scaleMode leaves the style's shared resource id in place, so the old GPU sampler stayed in use until a reload. Call style.update(). - Graph gets a header bar like the Texture tab: GraphEditor is now the frame, the former editor is GraphCanvas. A selection the tab does not concern no longer unloads the behavior. - New e2e step 14 guards text overflow across all widgets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
35a1a23af8
commit
f16801aef3
22 files changed
+874
-493
No files matched your search
@@ -89,8 +89,14 @@ shown on the node and editable in the inspector.
|
||||
(types are checked; a data input holds one link and is replaced), drag nodes, right-click a
|
||||
node or port to delete/disconnect, `Delete` removes the selected node, wheel zooms, dragging
|
||||
the empty canvas pans. Everything is undoable.
|
||||
- **Selection-bound**: the Graph tab shows the behavior the current selection refers to
|
||||
(asset, placed object or node); with nothing or a non-behavior selected it is empty.
|
||||
- **Header bar** (the same one the Texture tab has): the behavior picker, **Add node…** (the
|
||||
palette, dropping the node in the middle of the view – the right-click nobody finds), **Fit**,
|
||||
and the size of the graph as "N nodes · M links".
|
||||
- **Selection-following, not selection-bound**: selecting a behavior asset, a placed behavior
|
||||
or one of its nodes shows that graph; a selection about anything else (a sprite, nothing at
|
||||
all) leaves the tab on the behavior you were editing, because checking a sprite's position
|
||||
must not empty your work. Only a behavior that no longer exists is dropped, and then the tab
|
||||
falls back to another one – the picker is how you switch on purpose.
|
||||
- **Live view**: Play always switches to the Game tab; switch to Graph while playing and
|
||||
the exec edges and nodes that just ran light up – the same canvas draws editor and game, so
|
||||
this costs nothing extra.
|
||||
@@ -187,7 +193,10 @@ picture becomes ten differently coloured coins, per instance, for nothing. Witho
|
||||
`color` is simply the fill of the box, as before.
|
||||
|
||||
**`filter`** is the texture's only property: *Smooth* (default) or *Pixel art*, which is the
|
||||
difference between crisp and mush when a small image is scaled up.
|
||||
difference between crisp and mush when a small image is scaled up. Switching it needs a
|
||||
`style.update()` on the texture source, not just the assignment: Pixi's `scaleMode` setter
|
||||
writes the filters but leaves the style's cached resource id in place, and that id is the key
|
||||
the GPU sampler is looked up under — so without it the picture only changes on a reload.
|
||||
|
||||
### Frames — cutting a sheet up
|
||||
|
||||
@@ -256,6 +265,12 @@ selected and switched back on.
|
||||
object of the scene – is opened with `{ searchable: true }`, which puts a filter box above
|
||||
the entries (Enter takes the first match). A field that opens a menu is a `Dropdown`: value
|
||||
flush left, chevron drawn flush right rather than written into the label.
|
||||
Every label that can be wider than its control is cut with an ellipsis (`truncate` in
|
||||
`Label.ts`, shared by `Label`, `ListRow` and `Button`). A `Button` keeps the full text and
|
||||
measures from it – only what is drawn is shortened – and a subclass that puts something else
|
||||
inside the control takes its room off `labelSlot()`, so a long value is cut rather than run
|
||||
underneath the chevron. `__gm.textOverflows()` guards the whole class of bug: it reports any
|
||||
visible text drawn wider than the widget owning it (e2e step 14).
|
||||
- **Vite + TypeScript**, **Vitest** for the pure core.
|
||||
- **PHP 8** JSON API (`api/`) storing one JSON file per project in `data/projects/`, with
|
||||
atomic writes and an integer revision that rejects stale saves (HTTP 409).
|
||||
@@ -278,7 +293,7 @@ src/editor/ App → Editor (layout + ticker), PlaySession (the run),
|
||||
src/editor/inspector/ Inspector panel, field factory, previewHandlers (scrub → preview → command),
|
||||
one section per selection kind (asset/object/node)
|
||||
src/editor/panels/ AssetPanel, HierarchyPanel, Toolbar
|
||||
src/editor/graph/ GraphEditor (canvas), NodeView, NodePalette
|
||||
src/editor/graph/ GraphEditor (header + picker), GraphCanvas, NodeView, NodePalette
|
||||
api/ PHP backend data/projects/ persisted projects
|
||||
tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/), smoke-jumprun, lib/
|
||||
```
|
||||
@@ -323,7 +338,8 @@ rewritten by every e2e run and the **jump-run** project by its seeder. The **pla
|
||||
project is the author's own and is maintained in the editor – every tool refuses to touch it.
|
||||
|
||||
The e2e run is a list of steps in `tools/e2e/` (hierarchy, inspector, play, assets, drawing,
|
||||
graph, parenting, colliders, containers, text/enabled, menus) that build on each other's scene state
|
||||
graph, parenting, colliders, containers, text/enabled, menus, textures, frames, overflow) that build
|
||||
on each other's scene state
|
||||
and share the CDP driver in `tools/lib/harness.mjs` – `press`, `undo`, `play`, `object(id)`,
|
||||
`world()`, `mutate(js)` and friends, so a check reads like the interaction it exercises.
|
||||
|
||||
@@ -365,9 +381,10 @@ There is no authentication yet; `data/` is world-readable. Both are phase-2 topi
|
||||
container from the selection · `Ctrl+Enter` play · `Esc` stop · `Ctrl+V` import an image from
|
||||
the clipboard as a texture (ignored while a text field has focus) · `Tab` cycles fields · number
|
||||
fields: drag to scrub (`Shift` ×10, `Alt` ×0.1, snapping to the grid of that increment, so a
|
||||
step-1 field lands on whole numbers), `↑`/`↓` step, always shown with three
|
||||
decimals (`ui/math.ts`, `NUMBER_DECIMALS`) so a fractional value never reads as a whole
|
||||
number · viewport: wheel zooms,
|
||||
step-1 field lands on whole numbers), `↑`/`↓` step, shown with three decimals (`ui/math.ts`,
|
||||
`NUMBER_DECIMALS`) so a fractional value never reads as a whole number — except where the
|
||||
value is integral by nature (frame rectangles, the slice dialog), which set
|
||||
`NumberField`'s `decimals: 0` and show plain whole numbers · viewport: wheel zooms,
|
||||
middle-drag or `Shift`+drag pans, drag on empty space rubber-bands, `Ctrl`-click toggles an
|
||||
object in the selection (hierarchy rows too).
|
||||
|
||||
|
||||
@@ -7,7 +7,22 @@ import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||
* these entries, so adding a property to a type is a single line in a schema below.
|
||||
*/
|
||||
export type PropertyDef =
|
||||
| { key: string; label: string; kind: 'number'; default: number; min?: number; max?: number; step?: number }
|
||||
/**
|
||||
* `decimals` is the number of places the field displays; it belongs only on properties that
|
||||
* are integral by their meaning (counts, indices). Geometry does not qualify: dragging in the
|
||||
* viewport puts fractions into a position or a size, and a field that hid them would commit
|
||||
* the rounded number back into the project.
|
||||
*/
|
||||
| {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: 'number';
|
||||
default: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
decimals?: number;
|
||||
}
|
||||
| { key: string; label: string; kind: 'text'; default: string }
|
||||
| { key: string; label: string; kind: 'color'; default: number }
|
||||
| { key: string; label: string; kind: 'boolean'; default: boolean }
|
||||
|
||||
@@ -78,8 +78,8 @@ export class Editor implements PlayHost {
|
||||
if (this.play.isPlaying) this.play.stop();
|
||||
});
|
||||
|
||||
this.graphEditor.traceProvider = (assetId) => this.play.traceFor(assetId);
|
||||
this.graphEditor.timeProvider = () => this.play.time;
|
||||
this.graphEditor.canvas.traceProvider = (assetId) => this.play.traceFor(assetId);
|
||||
this.graphEditor.canvas.timeProvider = () => this.play.time;
|
||||
|
||||
this.toolbar = new Toolbar(state, {
|
||||
openProjectMenu: (anchor) => this.projects.openProjectMenu(anchor),
|
||||
|
||||
@@ -132,7 +132,7 @@ export class PlaySession {
|
||||
// A destroyed entity removes its object – that is a structural change the lists must see.
|
||||
if (world.syncToModel()) this.state.preview();
|
||||
this.state.runtimeTick();
|
||||
if (this.host.graphEditor.visible) this.host.graphEditor.redraw();
|
||||
if (this.host.graphEditor.visible) this.host.graphEditor.canvas.redraw();
|
||||
}
|
||||
|
||||
/** Read-only view of the running world for the check scripts. */
|
||||
|
||||
+42
-3
@@ -50,6 +50,42 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
walk(ui().overlay);
|
||||
return out;
|
||||
};
|
||||
/**
|
||||
* Visible texts drawn wider than the widget that owns them – the shape of bug where a label
|
||||
* runs past its own control instead of being shortened to fit.
|
||||
*
|
||||
* The owner's box is its declared `w`/`h` at its global position, NOT `getBounds()`: a Pixi
|
||||
* container has no size of its own, so its bounds are the union of its children – including
|
||||
* the very text that overflows, which makes every overflow measure as zero. Texts inside a
|
||||
* scroll container are skipped, where sticking out is the point and the mask hides it.
|
||||
*/
|
||||
const textOverflows = (all = false): { text: string; owner: string; over: number }[] => {
|
||||
const out: { text: string; owner: string; over: number }[] = [];
|
||||
const walk = (node: Container, owner: Widget | null, clipped: boolean): void => {
|
||||
if (!node.visible) return;
|
||||
const isWidget = node instanceof Widget;
|
||||
const box = isWidget ? node : owner;
|
||||
// A mask further up – a panel clipping its contents – does not excuse a label spilling
|
||||
// out of its own control, so the question restarts at every widget. Only a mask at or
|
||||
// below the owner (a text field's scroll window, a scroll container) makes it intended.
|
||||
// `Boolean`, not `!== null`: Pixi's mask getter returns `undefined` when nothing masks,
|
||||
// so comparing against null marks every container as clipped and hides every finding.
|
||||
const masked = (isWidget ? false : clipped) || Boolean(node.mask);
|
||||
if (node instanceof Text) {
|
||||
if (!box || !node.text || (masked && !all)) return;
|
||||
const text = node.getBounds();
|
||||
const origin = box.getGlobalPosition();
|
||||
const over = Math.max(text.x + text.width - (origin.x + box.w), origin.x - text.x);
|
||||
if (over > 1 || all) {
|
||||
out.push({ text: node.text, owner: box.constructor.name, over: Math.round(over) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const child of node.children) walk(child, box, masked);
|
||||
};
|
||||
walk(app.stage, null, false);
|
||||
return out;
|
||||
};
|
||||
/** Screen rectangle of the open popup (the backdrop is not a widget), or null when none is. */
|
||||
const overlayRect = (): { x: number; y: number; w: number; h: number } | null => {
|
||||
const popups = ui().overlay.children.filter((node): node is Widget => node instanceof Widget);
|
||||
@@ -63,6 +99,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
locate,
|
||||
overlayTexts,
|
||||
overlayRect,
|
||||
textOverflows,
|
||||
viewportCenter: () => centreOf(viewport),
|
||||
viewportInfo: () => viewport.debugInfo(),
|
||||
gameInfo: () => gameView.debugInfo(),
|
||||
@@ -77,9 +114,11 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
graph: {
|
||||
current: () => graphEditor.currentAssetId,
|
||||
portAt: (nodeId: string, port: string, isOutput: boolean) =>
|
||||
graphEditor.portGlobal(nodeId, port, isOutput),
|
||||
nodeAt: (nodeId: string) => graphEditor.nodeGlobal(nodeId),
|
||||
center: () => centreOf(graphEditor),
|
||||
graphEditor.canvas.portGlobal(nodeId, port, isOutput),
|
||||
nodeAt: (nodeId: string) => graphEditor.canvas.nodeGlobal(nodeId),
|
||||
// The canvas, not the whole tab: a point measured through the header would sit above
|
||||
// the drawing area a right-click test aims at.
|
||||
center: () => centreOf(graphEditor.canvas),
|
||||
},
|
||||
texture: {
|
||||
current: () => textureEditor.currentAssetId,
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
|
||||
import {
|
||||
AddNodeCommand,
|
||||
ConnectCommand,
|
||||
DisconnectCommand,
|
||||
MoveNodeCommand,
|
||||
RemoveNodeCommand,
|
||||
} from '../../core/commands/graphCommands';
|
||||
import {
|
||||
edgesInto,
|
||||
edgesOutOf,
|
||||
type GraphEdge,
|
||||
type NodeDef,
|
||||
nodeDef,
|
||||
portsCompatible,
|
||||
} from '../../core/graph';
|
||||
import type { BehaviorAsset } from '../../core/model/Asset';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
|
||||
import { clamp, drawGrid, Menu, type Size, Theme, textStyle, Widget, zoomFactor } from '../../ui';
|
||||
import type { EditorState } from '../state';
|
||||
import { NodePalette } from './NodePalette';
|
||||
import { NODE_WIDTH, NodeView, portColor, TITLE_HEIGHT } from './NodeView';
|
||||
|
||||
const GRID_STEP = 24;
|
||||
/** Below this on-screen spacing the grid is noise rather than orientation, so it is dropped. */
|
||||
const MIN_GRID_STEP = 8;
|
||||
const MIN_ZOOM = 0.25;
|
||||
const MAX_ZOOM = 2.5;
|
||||
/** Stand-in node height for centring the view – real nodes vary with their port count. */
|
||||
const NODE_HEIGHT_ESTIMATE = 80;
|
||||
|
||||
interface PortAnchor {
|
||||
nodeId: string;
|
||||
port: string;
|
||||
isOutput: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Graph tab's canvas: 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.
|
||||
*
|
||||
* Interactions: drag nodes, drag from port to port to connect, right-click the canvas for
|
||||
* the node palette, right-click a node or port for delete/disconnect, wheel to zoom, drag
|
||||
* the empty canvas to pan. All edits go through commands (undoable).
|
||||
*/
|
||||
export class GraphCanvas extends Widget {
|
||||
private readonly bg = new Graphics();
|
||||
private readonly grid = new Graphics();
|
||||
private readonly clip = new Graphics();
|
||||
private readonly canvas = new Container();
|
||||
private readonly edges = new Graphics();
|
||||
private readonly nodeLayer = new Container();
|
||||
private readonly tempEdge = new Graphics();
|
||||
private readonly hint = new Text({ text: '', style: textStyle('muted') });
|
||||
private readonly views = new Map<string, NodeView>();
|
||||
private assetId: string | null = null;
|
||||
private pan = { x: 0, y: 0 };
|
||||
private zoom = 1;
|
||||
private panning: { startX: number; startY: number; panX: number; panY: number } | null = null;
|
||||
private dragging: {
|
||||
nodeId: string;
|
||||
start: { x: number; y: number };
|
||||
pointer: { x: number; y: number };
|
||||
moved: boolean;
|
||||
} | null = null;
|
||||
private connecting: { from: PortAnchor; pointer: { x: number; y: number } } | null = null;
|
||||
/** Fit the view once the editor has a real size (it may be hidden behind the Scene tab). */
|
||||
private needsFit = true;
|
||||
/** Supplies live execution data while playing (null otherwise). */
|
||||
traceProvider: (assetId: string) => Trace | null = () => null;
|
||||
timeProvider: () => number = () => 0;
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
super();
|
||||
this.canvas.addChild(this.edges, this.nodeLayer, this.tempEdge);
|
||||
this.addChild(this.bg, this.grid, this.canvas, this.hint, this.clip);
|
||||
this.canvas.mask = this.clip;
|
||||
this.cursor = 'default';
|
||||
|
||||
this.on('pointerdown', (e: FederatedPointerEvent) => this.onCanvasPointerDown(e));
|
||||
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
|
||||
this.on('pointerup', (e: FederatedPointerEvent) => this.onPointerUp(e));
|
||||
this.on('pointerupoutside', (e: FederatedPointerEvent) => this.onPointerUp(e));
|
||||
this.on('wheel', (e: FederatedWheelEvent) => {
|
||||
const local = this.toLocal(e.global);
|
||||
const before = this.screenToGraph(local.x, local.y);
|
||||
this.zoom = clamp(this.zoom * zoomFactor(e.deltaY), MIN_ZOOM, MAX_ZOOM);
|
||||
const after = this.screenToGraph(local.x, local.y);
|
||||
this.pan.x += (after.x - before.x) * this.zoom;
|
||||
this.pan.y += (after.y - before.y) * this.zoom;
|
||||
this.applyView();
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
// Only the highlight of the selected node depends on the selection; which graph is shown
|
||||
// is the editor's decision.
|
||||
state.on('selection', () => this.redraw());
|
||||
state.on('project', () => this.sync());
|
||||
state.on('mode', () => this.redraw());
|
||||
}
|
||||
|
||||
/** The behavior whose graph is shown, if any. */
|
||||
get currentAssetId(): string | null {
|
||||
return this.assetId;
|
||||
}
|
||||
|
||||
/** Shows a behavior's graph. Switching refits the view, since the next graph is a new subject. */
|
||||
show(assetId: string | null): void {
|
||||
if (this.assetId === assetId) return;
|
||||
this.assetId = assetId;
|
||||
this.sync();
|
||||
this.needsFit = true;
|
||||
this.fitView();
|
||||
}
|
||||
|
||||
/** Recentres the graph in the view – the "reset view" the header offers. */
|
||||
fit(): void {
|
||||
this.needsFit = true;
|
||||
this.fitView();
|
||||
}
|
||||
|
||||
/**
|
||||
* The header's "Add node": the same palette the right-click opens, dropped in the middle of
|
||||
* the view, since a button press says nothing about where the node should go.
|
||||
*/
|
||||
addNodeFrom(anchor: Widget): void {
|
||||
if (!this.assetId || this.state.mode !== 'edit') return;
|
||||
const centre = this.screenToGraph(this.w / 2, this.h / 2);
|
||||
new NodePalette((def: NodeDef) =>
|
||||
this.addNode(def, { x: centre.x - NODE_WIDTH / 2, y: centre.y - NODE_HEIGHT_ESTIMATE / 2 }),
|
||||
).openBelow(anchor);
|
||||
}
|
||||
|
||||
override measure(): Size {
|
||||
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
|
||||
}
|
||||
|
||||
// --- model binding ----------------------------------------------------------------------
|
||||
|
||||
private asset(): BehaviorAsset | null {
|
||||
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
|
||||
return asset && asset.type === 'behavior' ? asset : null;
|
||||
}
|
||||
|
||||
/** Rebuilds node views from the model (cheap: views are reused by node id). */
|
||||
private sync(): void {
|
||||
const asset = this.asset();
|
||||
if (!asset) {
|
||||
// A behavior that was deleted (or undone away) must not linger as a graph of a thing
|
||||
// that no longer exists, so the canvas drops it rather than waiting to be told.
|
||||
this.assetId = null;
|
||||
for (const view of this.views.values()) view.destroy({ children: true });
|
||||
this.views.clear();
|
||||
this.redraw();
|
||||
return;
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
for (const node of asset.graph.nodes) {
|
||||
const def = nodeDef(node.type);
|
||||
if (!def) continue;
|
||||
seen.add(node.id);
|
||||
let view = this.views.get(node.id);
|
||||
if (!view || view.node !== node) {
|
||||
view?.destroy({ children: true });
|
||||
view = new NodeView(node, def);
|
||||
view.on('pointerdown', (e: FederatedPointerEvent) => this.onNodePointerDown(view!, e));
|
||||
this.views.set(node.id, view);
|
||||
this.nodeLayer.addChild(view);
|
||||
}
|
||||
view.sync(node, asset.graph, asset, this.state.project);
|
||||
}
|
||||
for (const [id, view] of this.views) {
|
||||
if (!seen.has(id)) {
|
||||
view.destroy({ children: true });
|
||||
this.views.delete(id);
|
||||
}
|
||||
}
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** Centres the graph's nodes (or the origin) in the view. Waits for a real size. */
|
||||
private fitView(): void {
|
||||
if (this.w === 0 || this.h === 0) return;
|
||||
this.needsFit = false;
|
||||
const asset = this.asset();
|
||||
if (!asset || asset.graph.nodes.length === 0) {
|
||||
this.pan = { x: this.w / 2 - NODE_WIDTH / 2, y: this.h / 3 };
|
||||
this.zoom = 1;
|
||||
} else {
|
||||
const xs = asset.graph.nodes.map((n) => n.x);
|
||||
const ys = asset.graph.nodes.map((n) => n.y);
|
||||
const cx = (Math.min(...xs) + Math.max(...xs) + NODE_WIDTH) / 2;
|
||||
const cy = (Math.min(...ys) + Math.max(...ys) + NODE_HEIGHT_ESTIMATE) / 2;
|
||||
this.zoom = 1;
|
||||
this.pan = { x: this.w / 2 - cx, y: this.h / 2 - cy };
|
||||
}
|
||||
this.applyView();
|
||||
}
|
||||
|
||||
private applyView(): void {
|
||||
this.canvas.position.set(Math.round(this.pan.x), Math.round(this.pan.y));
|
||||
this.canvas.scale.set(this.zoom);
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
private screenToGraph(sx: number, sy: number): { x: number; y: number } {
|
||||
return { x: (sx - this.pan.x) / this.zoom, y: (sy - this.pan.y) / this.zoom };
|
||||
}
|
||||
|
||||
// --- pointer -----------------------------------------------------------------------------
|
||||
|
||||
private onCanvasPointerDown(e: FederatedPointerEvent): void {
|
||||
if (e.target !== this) return;
|
||||
const local = this.toLocal(e.global);
|
||||
if (e.button === 2) {
|
||||
if (this.assetId && this.state.mode === 'edit')
|
||||
this.openPalette(e.global.x, e.global.y, this.screenToGraph(local.x, local.y));
|
||||
return;
|
||||
}
|
||||
if (e.button === 0 || e.button === 1) {
|
||||
if (e.button === 0 && this.assetId && this.state.mode === 'edit')
|
||||
this.state.select({ kind: 'asset', assetId: this.assetId });
|
||||
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
|
||||
}
|
||||
}
|
||||
|
||||
private onNodePointerDown(view: NodeView, e: FederatedPointerEvent): void {
|
||||
if (!this.assetId || this.state.mode !== 'edit') return;
|
||||
e.stopPropagation();
|
||||
const local = view.toLocal(e.global);
|
||||
const hit = view.portAt(local.x, local.y);
|
||||
if (e.button === 2) {
|
||||
if (hit) this.openPortMenu(view, hit.port.key, hit.isOutput, e.global.x, e.global.y);
|
||||
else this.openNodeMenu(view, e.global.x, e.global.y);
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) return;
|
||||
if (hit) {
|
||||
const p = this.toLocal(e.global);
|
||||
this.connecting = {
|
||||
from: { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput },
|
||||
pointer: this.screenToGraph(p.x, p.y),
|
||||
};
|
||||
this.redraw();
|
||||
return;
|
||||
}
|
||||
this.state.select({ kind: 'node', assetId: this.assetId, nodeId: view.node.id });
|
||||
const p = this.toLocal(e.global);
|
||||
this.dragging = {
|
||||
nodeId: view.node.id,
|
||||
start: { x: view.node.x, y: view.node.y },
|
||||
pointer: this.screenToGraph(p.x, p.y),
|
||||
moved: false,
|
||||
};
|
||||
this.nodeLayer.addChild(view); // raise
|
||||
}
|
||||
|
||||
private onPointerMove(e: FederatedPointerEvent): void {
|
||||
if (this.panning) {
|
||||
this.pan.x = this.panning.panX + (e.global.x - this.panning.startX);
|
||||
this.pan.y = this.panning.panY + (e.global.y - this.panning.startY);
|
||||
this.applyView();
|
||||
return;
|
||||
}
|
||||
const local = this.toLocal(e.global);
|
||||
const g = this.screenToGraph(local.x, local.y);
|
||||
if (this.dragging) {
|
||||
const dx = g.x - this.dragging.pointer.x;
|
||||
const dy = g.y - this.dragging.pointer.y;
|
||||
if (!this.dragging.moved && Math.hypot(dx, dy) * this.zoom < 3) return;
|
||||
this.dragging.moved = true;
|
||||
const node = this.asset()?.graph.nodes.find((n) => n.id === this.dragging?.nodeId);
|
||||
if (!node) return;
|
||||
node.x = Math.round(this.dragging.start.x + dx);
|
||||
node.y = Math.round(this.dragging.start.y + dy);
|
||||
this.state.preview();
|
||||
} else if (this.connecting) {
|
||||
this.connecting.pointer = g;
|
||||
this.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
private onPointerUp(e: FederatedPointerEvent): void {
|
||||
this.panning = null;
|
||||
const drag = this.dragging;
|
||||
this.dragging = null;
|
||||
if (drag && this.assetId) {
|
||||
const node = this.asset()?.graph.nodes.find((n) => n.id === drag.nodeId);
|
||||
if (node && drag.moved) {
|
||||
// The preview moved the node already; put it back so the command records the start.
|
||||
const next = { x: node.x, y: node.y };
|
||||
node.x = drag.start.x;
|
||||
node.y = drag.start.y;
|
||||
this.state.execute(new MoveNodeCommand(this.assetId, drag.nodeId, next.x, next.y));
|
||||
}
|
||||
}
|
||||
const connecting = this.connecting;
|
||||
this.connecting = null;
|
||||
if (connecting && this.assetId) {
|
||||
const target = this.portUnder(e.global.x, e.global.y);
|
||||
if (target && target.isOutput !== connecting.from.isOutput) {
|
||||
const out = connecting.from.isOutput ? connecting.from : target;
|
||||
const inp = connecting.from.isOutput ? target : connecting.from;
|
||||
this.state.execute(
|
||||
new ConnectCommand(
|
||||
this.assetId,
|
||||
{ node: out.nodeId, port: out.port },
|
||||
{ node: inp.nodeId, port: inp.port },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
private portUnder(globalX: number, globalY: number): PortAnchor | null {
|
||||
for (const view of this.views.values()) {
|
||||
const local = view.toLocal({ x: globalX, y: globalY });
|
||||
const hit = view.portAt(local.x, local.y);
|
||||
if (hit) return { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- menus -------------------------------------------------------------------------------
|
||||
|
||||
private openPalette(globalX: number, globalY: number, at: { x: number; y: number }): void {
|
||||
new NodePalette((def: NodeDef) => this.addNode(def, at)).open(globalX, globalY);
|
||||
}
|
||||
|
||||
private addNode(def: NodeDef, at: { x: number; y: number }): void {
|
||||
if (!this.assetId) return;
|
||||
const command = new AddNodeCommand(this.assetId, def.type, Math.round(at.x), Math.round(at.y));
|
||||
if (this.state.execute(command))
|
||||
this.state.select({ kind: 'node', assetId: this.assetId, nodeId: command.node.id });
|
||||
}
|
||||
|
||||
private openNodeMenu(view: NodeView, globalX: number, globalY: number): void {
|
||||
const assetId = this.assetId;
|
||||
if (!assetId) return;
|
||||
this.state.select({ kind: 'node', assetId, nodeId: view.node.id });
|
||||
new Menu([
|
||||
{
|
||||
label: 'Delete node',
|
||||
danger: true,
|
||||
onSelect: () => this.state.execute(new RemoveNodeCommand(assetId, view.node.id)),
|
||||
},
|
||||
]).open(globalX, globalY);
|
||||
}
|
||||
|
||||
private openPortMenu(
|
||||
view: NodeView,
|
||||
port: string,
|
||||
isOutput: boolean,
|
||||
globalX: number,
|
||||
globalY: number,
|
||||
): void {
|
||||
const asset = this.asset();
|
||||
if (!asset) return;
|
||||
const edges = isOutput
|
||||
? edgesOutOf(asset.graph, view.node.id, port)
|
||||
: edgesInto(asset.graph, view.node.id, port);
|
||||
if (edges.length === 0) return;
|
||||
new Menu([
|
||||
{
|
||||
label: edges.length === 1 ? 'Disconnect' : `Disconnect ${edges.length} links`,
|
||||
danger: true,
|
||||
onSelect: () => {
|
||||
for (const edge of edges) this.state.execute(new DisconnectCommand(asset.id, edge.id));
|
||||
},
|
||||
},
|
||||
]).open(globalX, globalY);
|
||||
}
|
||||
|
||||
// --- drawing -----------------------------------------------------------------------------
|
||||
|
||||
override layout(): void {
|
||||
this.hint.position.set(Theme.padding, this.h - Theme.padding - 16);
|
||||
if (this.needsFit) this.fitView();
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
const c = Theme.colors;
|
||||
this.bg.clear().rect(0, 0, this.w, this.h).fill(c.graphBackground);
|
||||
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
|
||||
this.syncGrid();
|
||||
const asset = this.asset();
|
||||
this.canvas.visible = asset !== null;
|
||||
if (!asset) {
|
||||
this.hint.text = 'No behavior selected – pick one above, or add a behavior asset.';
|
||||
return;
|
||||
}
|
||||
const playing = this.state.mode === 'play';
|
||||
// The header names the behavior; the hint only says what the canvas itself is up to.
|
||||
this.hint.text = asset.graph.nodes.length === 0 ? 'Right-click to add a node' : playing ? 'live' : '';
|
||||
|
||||
const trace = playing ? this.traceProvider(asset.id) : null;
|
||||
const now = this.timeProvider();
|
||||
const isHot = (map: Map<string, number> | undefined, id: string) => {
|
||||
const t = map?.get(id);
|
||||
return t !== undefined && now - t <= TRACE_FADE;
|
||||
};
|
||||
const selectedNode = this.state.selection?.kind === 'node' ? this.state.selection.nodeId : null;
|
||||
for (const view of this.views.values()) {
|
||||
view.selected = view.node.id === selectedNode;
|
||||
view.hot = isHot(trace?.nodes, view.node.id);
|
||||
view.redraw();
|
||||
}
|
||||
|
||||
this.edges.clear();
|
||||
for (const edge of asset.graph.edges) this.drawEdge(edge, isHot(trace?.edges, edge.id));
|
||||
|
||||
this.tempEdge.clear();
|
||||
if (this.connecting) {
|
||||
const view = this.views.get(this.connecting.from.nodeId);
|
||||
const p = view?.portPosition(this.connecting.from.port, this.connecting.from.isOutput);
|
||||
if (view && p) {
|
||||
const a = { x: view.x + p.x, y: view.y + p.y };
|
||||
const b = this.connecting.pointer;
|
||||
const [from, to] = this.connecting.from.isOutput ? [a, b] : [b, a];
|
||||
bezier(this.tempEdge, from.x, from.y, to.x, to.y);
|
||||
this.tempEdge.stroke({ width: 2, color: c.textMuted, alpha: 0.9 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private drawEdge(edge: GraphEdge, hot: boolean): void {
|
||||
const from = this.views.get(edge.from.node);
|
||||
const to = this.views.get(edge.to.node);
|
||||
const a = from?.portPosition(edge.from.port, true);
|
||||
const b = to?.portPosition(edge.to.port, false);
|
||||
if (!from || !to || !a || !b) return;
|
||||
const type = from.def.outputs.find((p) => p.key === edge.from.port)?.type ?? 'any';
|
||||
const inType = to.def.inputs.find((p) => p.key === edge.to.port)?.type ?? 'any';
|
||||
const valid = portsCompatible(type, inType);
|
||||
bezier(this.edges, from.x + a.x, from.y + a.y, to.x + b.x, to.y + b.y);
|
||||
this.edges.stroke({
|
||||
width: hot ? 4 : type === 'exec' ? 2.5 : 2,
|
||||
color: hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger,
|
||||
alpha: type === 'exec' || hot ? 1 : 0.85,
|
||||
});
|
||||
}
|
||||
|
||||
private syncGrid(): void {
|
||||
this.grid.clear();
|
||||
const step = GRID_STEP * this.zoom;
|
||||
if (step < MIN_GRID_STEP) return;
|
||||
// `pan` is where the graph origin sits on screen – but the graph has no origin worth
|
||||
// marking, so no axis lines.
|
||||
drawGrid(this.grid, {
|
||||
originX: this.pan.x,
|
||||
originY: this.pan.y,
|
||||
step,
|
||||
w: this.w,
|
||||
h: this.h,
|
||||
alpha: 0.04,
|
||||
});
|
||||
}
|
||||
|
||||
// --- 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);
|
||||
const p = view?.portPosition(port, isOutput);
|
||||
if (!view || !p) return null;
|
||||
const g = view.toGlobal({ x: p.x, y: p.y });
|
||||
return { x: g.x, y: g.y };
|
||||
}
|
||||
|
||||
/** Global screen position of a node's title bar centre. */
|
||||
nodeGlobal(nodeId: string): { x: number; y: number } | null {
|
||||
const view = this.views.get(nodeId);
|
||||
if (!view) return null;
|
||||
const g = view.toGlobal({ x: view.w / 2, y: TITLE_HEIGHT / 2 });
|
||||
return { x: g.x, y: g.y };
|
||||
}
|
||||
}
|
||||
|
||||
/** Horizontal S-curve between two points, as used by every node editor. */
|
||||
function bezier(g: Graphics, x1: number, y1: number, x2: number, y2: number): void {
|
||||
const dx = Math.max(40, Math.abs(x2 - x1) * 0.5);
|
||||
g.moveTo(x1, y1).bezierCurveTo(x1 + dx, y1, x2 - dx, y2, x2, y2);
|
||||
}
|
||||
+85
-443
@@ -1,474 +1,116 @@
|
||||
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
|
||||
import {
|
||||
AddNodeCommand,
|
||||
ConnectCommand,
|
||||
DisconnectCommand,
|
||||
MoveNodeCommand,
|
||||
RemoveNodeCommand,
|
||||
} from '../../core/commands/graphCommands';
|
||||
import {
|
||||
edgesInto,
|
||||
edgesOutOf,
|
||||
type GraphEdge,
|
||||
type NodeDef,
|
||||
nodeDef,
|
||||
portsCompatible,
|
||||
} from '../../core/graph';
|
||||
import { Graphics } from 'pixi.js';
|
||||
import type { BehaviorAsset } from '../../core/model/Asset';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
|
||||
import { clamp, drawGrid, Menu, type Size, Theme, textStyle, Widget, zoomFactor } from '../../ui';
|
||||
import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Spacer, Theme, VStack } from '../../ui';
|
||||
import type { EditorState } from '../state';
|
||||
import { NodePalette } from './NodePalette';
|
||||
import { NODE_WIDTH, NodeView, portColor, TITLE_HEIGHT } from './NodeView';
|
||||
import { GraphCanvas } from './GraphCanvas';
|
||||
|
||||
const GRID_STEP = 24;
|
||||
/** Below this on-screen spacing the grid is noise rather than orientation, so it is dropped. */
|
||||
const MIN_GRID_STEP = 8;
|
||||
const MIN_ZOOM = 0.25;
|
||||
const MAX_ZOOM = 2.5;
|
||||
/** Stand-in node height for centring the view – real nodes vary with their port count. */
|
||||
const NODE_HEIGHT_ESTIMATE = 80;
|
||||
|
||||
interface PortAnchor {
|
||||
nodeId: string;
|
||||
port: string;
|
||||
isOutput: boolean;
|
||||
}
|
||||
/** Room kept for the "N nodes · M links" readout, wide enough for a three-digit graph. */
|
||||
const INFO_WIDTH = 150;
|
||||
|
||||
/**
|
||||
* The node graph editor for one behavior asset. Shows the graph of whatever behavior the
|
||||
* selection refers to (asset, placed object or node). When nothing or a non-behavior is
|
||||
* selected the editor is empty – the graph strictly mirrors the selection.
|
||||
* The Graph tab: a picker for which behavior is being worked on, the actions that act on the
|
||||
* whole graph, and the canvas the nodes are drawn on.
|
||||
*
|
||||
* Interactions: drag nodes, drag from port to port to connect, right-click the canvas for
|
||||
* the node palette, right-click a node or port for delete/disconnect, wheel to zoom, drag
|
||||
* the empty canvas to pan. All edits go through commands (undoable).
|
||||
* It follows the selection – selecting a behavior asset, a placed behavior or one of its nodes
|
||||
* shows that graph – but it keeps showing the last behavior when the selection moves elsewhere.
|
||||
* Otherwise clicking a sprite to check its position would empty the graph you are editing.
|
||||
*/
|
||||
export class GraphEditor extends Widget {
|
||||
private readonly bg = new Graphics();
|
||||
private readonly grid = new Graphics();
|
||||
private readonly clip = new Graphics();
|
||||
private readonly canvas = new Container();
|
||||
private readonly edges = new Graphics();
|
||||
private readonly nodeLayer = new Container();
|
||||
private readonly tempEdge = new Graphics();
|
||||
private readonly hint = new Text({ text: '', style: textStyle('muted') });
|
||||
private readonly views = new Map<string, NodeView>();
|
||||
private assetId: string | null = null;
|
||||
private pan = { x: 0, y: 0 };
|
||||
private zoom = 1;
|
||||
private panning: { startX: number; startY: number; panX: number; panY: number } | null = null;
|
||||
private dragging: {
|
||||
nodeId: string;
|
||||
start: { x: number; y: number };
|
||||
pointer: { x: number; y: number };
|
||||
moved: boolean;
|
||||
} | null = null;
|
||||
private connecting: { from: PortAnchor; pointer: { x: number; y: number } } | null = null;
|
||||
/** Fit the view once the editor has a real size (it may be hidden behind the Scene tab). */
|
||||
private needsFit = true;
|
||||
/** Supplies live execution data while playing (null otherwise). */
|
||||
traceProvider: (assetId: string) => Trace | null = () => null;
|
||||
timeProvider: () => number = () => 0;
|
||||
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;
|
||||
private readonly addButton: Button;
|
||||
private readonly headerBg = new Graphics();
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
super();
|
||||
this.canvas.addChild(this.edges, this.nodeLayer, this.tempEdge);
|
||||
this.addChild(this.bg, this.grid, this.canvas, this.hint, this.clip);
|
||||
this.canvas.mask = this.clip;
|
||||
this.cursor = 'default';
|
||||
const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
|
||||
header.minHeight = Theme.controlHeight + Theme.padding * 2;
|
||||
header.addChildAt(this.headerBg, 0);
|
||||
|
||||
this.on('pointerdown', (e: FederatedPointerEvent) => this.onCanvasPointerDown(e));
|
||||
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
|
||||
this.on('pointerup', (e: FederatedPointerEvent) => this.onPointerUp(e));
|
||||
this.on('pointerupoutside', (e: FederatedPointerEvent) => this.onPointerUp(e));
|
||||
this.on('wheel', (e: FederatedWheelEvent) => {
|
||||
const local = this.toLocal(e.global);
|
||||
const before = this.screenToGraph(local.x, local.y);
|
||||
this.zoom = clamp(this.zoom * zoomFactor(e.deltaY), MIN_ZOOM, MAX_ZOOM);
|
||||
const after = this.screenToGraph(local.x, local.y);
|
||||
this.pan.x += (after.x - before.x) * this.zoom;
|
||||
this.pan.y += (after.y - before.y) * this.zoom;
|
||||
this.applyView();
|
||||
e.preventDefault();
|
||||
});
|
||||
header.add(new Label('Behavior', { style: 'muted' }));
|
||||
this.picker = new Dropdown('—', { width: 200, onClick: () => this.openPicker() });
|
||||
header.add(this.picker);
|
||||
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 rather than measured: the label is built empty, and a stack sizes it once from
|
||||
// the text it had at the time – so without a floor the readout is clipped from then on.
|
||||
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());
|
||||
// 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.assetId = null;
|
||||
this.canvas.show(null);
|
||||
this.followSelection();
|
||||
});
|
||||
state.on('project', () => this.sync());
|
||||
state.on('mode', () => this.redraw());
|
||||
this.followSelection();
|
||||
}
|
||||
|
||||
/** The behavior whose graph is shown, if any. */
|
||||
/** The behavior currently shown – the check scripts read this. */
|
||||
get currentAssetId(): string | null {
|
||||
return this.assetId;
|
||||
}
|
||||
|
||||
override measure(): Size {
|
||||
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
|
||||
}
|
||||
|
||||
// --- model binding ----------------------------------------------------------------------
|
||||
|
||||
private asset(): BehaviorAsset | null {
|
||||
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
|
||||
return asset && asset.type === 'behavior' ? asset : null;
|
||||
return this.canvas.currentAssetId;
|
||||
}
|
||||
|
||||
private followSelection(): void {
|
||||
const wanted = this.state.selectedBehaviorId;
|
||||
if (wanted && wanted !== this.assetId) {
|
||||
this.assetId = wanted;
|
||||
this.sync();
|
||||
this.needsFit = true;
|
||||
this.fitView();
|
||||
} else if (!wanted || !this.asset()) {
|
||||
this.assetId = null;
|
||||
this.sync();
|
||||
} else {
|
||||
this.redraw();
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
/** Rebuilds node views from the model (cheap: views are reused by node id). */
|
||||
private sync(): void {
|
||||
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; rather than showing nothing at all the tab
|
||||
// falls back to whatever behavior is left, the way it starts out on the first one.
|
||||
if (!this.asset()) this.canvas.show(this.behaviors()[0]?.id ?? null);
|
||||
const asset = this.asset();
|
||||
if (!asset) {
|
||||
this.assetId = null;
|
||||
for (const view of this.views.values()) view.destroy({ children: true });
|
||||
this.views.clear();
|
||||
this.redraw();
|
||||
return;
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
for (const node of asset.graph.nodes) {
|
||||
const def = nodeDef(node.type);
|
||||
if (!def) continue;
|
||||
seen.add(node.id);
|
||||
let view = this.views.get(node.id);
|
||||
if (!view || view.node !== node) {
|
||||
view?.destroy({ children: true });
|
||||
view = new NodeView(node, def);
|
||||
view.on('pointerdown', (e: FederatedPointerEvent) => this.onNodePointerDown(view!, e));
|
||||
this.views.set(node.id, view);
|
||||
this.nodeLayer.addChild(view);
|
||||
}
|
||||
view.sync(node, asset.graph, asset, this.state.project);
|
||||
}
|
||||
for (const [id, view] of this.views) {
|
||||
if (!seen.has(id)) {
|
||||
view.destroy({ children: true });
|
||||
this.views.delete(id);
|
||||
}
|
||||
}
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** Centres the graph's nodes (or the origin) in the view. Waits for a real size. */
|
||||
private fitView(): void {
|
||||
if (this.w === 0 || this.h === 0) return;
|
||||
this.needsFit = false;
|
||||
const asset = this.asset();
|
||||
if (!asset || asset.graph.nodes.length === 0) {
|
||||
this.pan = { x: this.w / 2 - NODE_WIDTH / 2, y: this.h / 3 };
|
||||
this.zoom = 1;
|
||||
} else {
|
||||
const xs = asset.graph.nodes.map((n) => n.x);
|
||||
const ys = asset.graph.nodes.map((n) => n.y);
|
||||
const cx = (Math.min(...xs) + Math.max(...xs) + NODE_WIDTH) / 2;
|
||||
const cy = (Math.min(...ys) + Math.max(...ys) + NODE_HEIGHT_ESTIMATE) / 2;
|
||||
this.zoom = 1;
|
||||
this.pan = { x: this.w / 2 - cx, y: this.h / 2 - cy };
|
||||
}
|
||||
this.applyView();
|
||||
}
|
||||
|
||||
private applyView(): void {
|
||||
this.canvas.position.set(Math.round(this.pan.x), Math.round(this.pan.y));
|
||||
this.canvas.scale.set(this.zoom);
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
private screenToGraph(sx: number, sy: number): { x: number; y: number } {
|
||||
return { x: (sx - this.pan.x) / this.zoom, y: (sy - this.pan.y) / this.zoom };
|
||||
}
|
||||
|
||||
// --- pointer -----------------------------------------------------------------------------
|
||||
|
||||
private onCanvasPointerDown(e: FederatedPointerEvent): void {
|
||||
if (e.target !== this) return;
|
||||
const local = this.toLocal(e.global);
|
||||
if (e.button === 2) {
|
||||
if (this.assetId && this.state.mode === 'edit')
|
||||
this.openPalette(e.global.x, e.global.y, this.screenToGraph(local.x, local.y));
|
||||
return;
|
||||
}
|
||||
if (e.button === 0 || e.button === 1) {
|
||||
if (e.button === 0 && this.assetId && this.state.mode === 'edit')
|
||||
this.state.select({ kind: 'asset', assetId: this.assetId });
|
||||
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
|
||||
}
|
||||
}
|
||||
|
||||
private onNodePointerDown(view: NodeView, e: FederatedPointerEvent): void {
|
||||
if (!this.assetId || this.state.mode !== 'edit') return;
|
||||
e.stopPropagation();
|
||||
const local = view.toLocal(e.global);
|
||||
const hit = view.portAt(local.x, local.y);
|
||||
if (e.button === 2) {
|
||||
if (hit) this.openPortMenu(view, hit.port.key, hit.isOutput, e.global.x, e.global.y);
|
||||
else this.openNodeMenu(view, e.global.x, e.global.y);
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) return;
|
||||
if (hit) {
|
||||
const p = this.toLocal(e.global);
|
||||
this.connecting = {
|
||||
from: { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput },
|
||||
pointer: this.screenToGraph(p.x, p.y),
|
||||
};
|
||||
this.redraw();
|
||||
return;
|
||||
}
|
||||
this.state.select({ kind: 'node', assetId: this.assetId, nodeId: view.node.id });
|
||||
const p = this.toLocal(e.global);
|
||||
this.dragging = {
|
||||
nodeId: view.node.id,
|
||||
start: { x: view.node.x, y: view.node.y },
|
||||
pointer: this.screenToGraph(p.x, p.y),
|
||||
moved: false,
|
||||
};
|
||||
this.nodeLayer.addChild(view); // raise
|
||||
}
|
||||
|
||||
private onPointerMove(e: FederatedPointerEvent): void {
|
||||
if (this.panning) {
|
||||
this.pan.x = this.panning.panX + (e.global.x - this.panning.startX);
|
||||
this.pan.y = this.panning.panY + (e.global.y - this.panning.startY);
|
||||
this.applyView();
|
||||
return;
|
||||
}
|
||||
const local = this.toLocal(e.global);
|
||||
const g = this.screenToGraph(local.x, local.y);
|
||||
if (this.dragging) {
|
||||
const dx = g.x - this.dragging.pointer.x;
|
||||
const dy = g.y - this.dragging.pointer.y;
|
||||
if (!this.dragging.moved && Math.hypot(dx, dy) * this.zoom < 3) return;
|
||||
this.dragging.moved = true;
|
||||
const node = this.asset()?.graph.nodes.find((n) => n.id === this.dragging?.nodeId);
|
||||
if (!node) return;
|
||||
node.x = Math.round(this.dragging.start.x + dx);
|
||||
node.y = Math.round(this.dragging.start.y + dy);
|
||||
this.state.preview();
|
||||
} else if (this.connecting) {
|
||||
this.connecting.pointer = g;
|
||||
this.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
private onPointerUp(e: FederatedPointerEvent): void {
|
||||
this.panning = null;
|
||||
const drag = this.dragging;
|
||||
this.dragging = null;
|
||||
if (drag && this.assetId) {
|
||||
const node = this.asset()?.graph.nodes.find((n) => n.id === drag.nodeId);
|
||||
if (node && drag.moved) {
|
||||
// The preview moved the node already; put it back so the command records the start.
|
||||
const next = { x: node.x, y: node.y };
|
||||
node.x = drag.start.x;
|
||||
node.y = drag.start.y;
|
||||
this.state.execute(new MoveNodeCommand(this.assetId, drag.nodeId, next.x, next.y));
|
||||
}
|
||||
}
|
||||
const connecting = this.connecting;
|
||||
this.connecting = null;
|
||||
if (connecting && this.assetId) {
|
||||
const target = this.portUnder(e.global.x, e.global.y);
|
||||
if (target && target.isOutput !== connecting.from.isOutput) {
|
||||
const out = connecting.from.isOutput ? connecting.from : target;
|
||||
const inp = connecting.from.isOutput ? target : connecting.from;
|
||||
this.state.execute(
|
||||
new ConnectCommand(
|
||||
this.assetId,
|
||||
{ node: out.nodeId, port: out.port },
|
||||
{ node: inp.nodeId, port: inp.port },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
private portUnder(globalX: number, globalY: number): PortAnchor | null {
|
||||
for (const view of this.views.values()) {
|
||||
const local = view.toLocal({ x: globalX, y: globalY });
|
||||
const hit = view.portAt(local.x, local.y);
|
||||
if (hit) return { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- menus -------------------------------------------------------------------------------
|
||||
|
||||
private openPalette(globalX: number, globalY: number, at: { x: number; y: number }): void {
|
||||
new NodePalette((def: NodeDef) => {
|
||||
if (!this.assetId) return;
|
||||
const command = new AddNodeCommand(this.assetId, def.type, at.x, at.y);
|
||||
if (this.state.execute(command))
|
||||
this.state.select({ kind: 'node', assetId: this.assetId, nodeId: command.node.id });
|
||||
}).open(globalX, globalY);
|
||||
}
|
||||
|
||||
private openNodeMenu(view: NodeView, globalX: number, globalY: number): void {
|
||||
const assetId = this.assetId;
|
||||
if (!assetId) return;
|
||||
this.state.select({ kind: 'node', assetId, nodeId: view.node.id });
|
||||
new Menu([
|
||||
{
|
||||
label: 'Delete node',
|
||||
danger: true,
|
||||
onSelect: () => this.state.execute(new RemoveNodeCommand(assetId, view.node.id)),
|
||||
},
|
||||
]).open(globalX, globalY);
|
||||
}
|
||||
|
||||
private openPortMenu(
|
||||
view: NodeView,
|
||||
port: string,
|
||||
isOutput: boolean,
|
||||
globalX: number,
|
||||
globalY: number,
|
||||
): void {
|
||||
const asset = this.asset();
|
||||
if (!asset) return;
|
||||
const edges = isOutput
|
||||
? edgesOutOf(asset.graph, view.node.id, port)
|
||||
: edgesInto(asset.graph, view.node.id, port);
|
||||
if (edges.length === 0) return;
|
||||
new Menu([
|
||||
{
|
||||
label: edges.length === 1 ? 'Disconnect' : `Disconnect ${edges.length} links`,
|
||||
danger: true,
|
||||
onSelect: () => {
|
||||
for (const edge of edges) this.state.execute(new DisconnectCommand(asset.id, edge.id));
|
||||
},
|
||||
},
|
||||
]).open(globalX, globalY);
|
||||
}
|
||||
|
||||
// --- drawing -----------------------------------------------------------------------------
|
||||
|
||||
override layout(): void {
|
||||
this.hint.position.set(Theme.padding, this.h - Theme.padding - 16);
|
||||
if (this.needsFit) this.fitView();
|
||||
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'}` : '',
|
||||
);
|
||||
this.addButton.setEnabled(Boolean(asset) && this.state.mode === 'edit');
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
const c = Theme.colors;
|
||||
this.bg.clear().rect(0, 0, this.w, this.h).fill(c.graphBackground);
|
||||
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
|
||||
this.syncGrid();
|
||||
const asset = this.asset();
|
||||
this.canvas.visible = asset !== null;
|
||||
if (!asset) {
|
||||
this.hint.text = 'Select a behavior asset (or a placed behavior) to edit its graph.';
|
||||
return;
|
||||
}
|
||||
const playing = this.state.mode === 'play';
|
||||
this.hint.text =
|
||||
asset.graph.nodes.length === 0
|
||||
? `${asset.name} · right-click to add a node`
|
||||
: `${asset.name}${playing ? ' · live' : ''}`;
|
||||
|
||||
const trace = playing ? this.traceProvider(asset.id) : null;
|
||||
const now = this.timeProvider();
|
||||
const isHot = (map: Map<string, number> | undefined, id: string) => {
|
||||
const t = map?.get(id);
|
||||
return t !== undefined && now - t <= TRACE_FADE;
|
||||
};
|
||||
const selectedNode = this.state.selection?.kind === 'node' ? this.state.selection.nodeId : null;
|
||||
for (const view of this.views.values()) {
|
||||
view.selected = view.node.id === selectedNode;
|
||||
view.hot = isHot(trace?.nodes, view.node.id);
|
||||
view.redraw();
|
||||
}
|
||||
|
||||
this.edges.clear();
|
||||
for (const edge of asset.graph.edges) this.drawEdge(edge, isHot(trace?.edges, edge.id));
|
||||
|
||||
this.tempEdge.clear();
|
||||
if (this.connecting) {
|
||||
const view = this.views.get(this.connecting.from.nodeId);
|
||||
const p = view?.portPosition(this.connecting.from.port, this.connecting.from.isOutput);
|
||||
if (view && p) {
|
||||
const a = { x: view.x + p.x, y: view.y + p.y };
|
||||
const b = this.connecting.pointer;
|
||||
const [from, to] = this.connecting.from.isOutput ? [a, b] : [b, a];
|
||||
bezier(this.tempEdge, from.x, from.y, to.x, to.y);
|
||||
this.tempEdge.stroke({ width: 2, color: c.textMuted, alpha: 0.9 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private drawEdge(edge: GraphEdge, hot: boolean): void {
|
||||
const from = this.views.get(edge.from.node);
|
||||
const to = this.views.get(edge.to.node);
|
||||
const a = from?.portPosition(edge.from.port, true);
|
||||
const b = to?.portPosition(edge.to.port, false);
|
||||
if (!from || !to || !a || !b) return;
|
||||
const type = from.def.outputs.find((p) => p.key === edge.from.port)?.type ?? 'any';
|
||||
const inType = to.def.inputs.find((p) => p.key === edge.to.port)?.type ?? 'any';
|
||||
const valid = portsCompatible(type, inType);
|
||||
bezier(this.edges, from.x + a.x, from.y + a.y, to.x + b.x, to.y + b.y);
|
||||
this.edges.stroke({
|
||||
width: hot ? 4 : type === 'exec' ? 2.5 : 2,
|
||||
color: hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger,
|
||||
alpha: type === 'exec' || hot ? 1 : 0.85,
|
||||
});
|
||||
}
|
||||
|
||||
private syncGrid(): void {
|
||||
this.grid.clear();
|
||||
const step = GRID_STEP * this.zoom;
|
||||
if (step < MIN_GRID_STEP) return;
|
||||
// `pan` is where the graph origin sits on screen – but the graph has no origin worth
|
||||
// marking, so no axis lines.
|
||||
drawGrid(this.grid, {
|
||||
originX: this.pan.x,
|
||||
originY: this.pan.y,
|
||||
step,
|
||||
w: this.w,
|
||||
h: this.h,
|
||||
alpha: 0.04,
|
||||
});
|
||||
}
|
||||
|
||||
// --- 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);
|
||||
const p = view?.portPosition(port, isOutput);
|
||||
if (!view || !p) return null;
|
||||
const g = view.toGlobal({ x: p.x, y: p.y });
|
||||
return { x: g.x, y: g.y };
|
||||
}
|
||||
|
||||
/** Global screen position of a node's title bar centre. */
|
||||
nodeGlobal(nodeId: string): { x: number; y: number } | null {
|
||||
const view = this.views.get(nodeId);
|
||||
if (!view) return null;
|
||||
const g = view.toGlobal({ x: view.w / 2, y: TITLE_HEIGHT / 2 });
|
||||
return { x: g.x, y: g.y };
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/** Horizontal S-curve between two points, as used by every node editor. */
|
||||
function bezier(g: Graphics, x1: number, y1: number, x2: number, y2: number): void {
|
||||
const dx = Math.max(40, Math.abs(x2 - x1) * 0.5);
|
||||
g.moveTo(x1, y1).bezierCurveTo(x1 + dx, y1, x2 - dx, y2, x2, y2);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CanvasTextMetrics, Graphics, Rectangle, Text } from 'pixi.js';
|
||||
import { Graphics, Rectangle, Text } from 'pixi.js';
|
||||
import {
|
||||
asString,
|
||||
edgesInto,
|
||||
@@ -17,7 +17,7 @@ import type { Project } from '../../core/model/Project';
|
||||
import type { PropertyValue } from '../../core/model/Properties';
|
||||
import { assetRefLabel } from '../../core/properties/assetRef';
|
||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import { formatNumber, Theme, textStyle, type TextStyleToken, Widget } from '../../ui';
|
||||
import { formatNumber, Theme, textStyle, type TextStyleToken, truncate, Widget } from '../../ui';
|
||||
import { keyLabel } from '../inspector/fieldFactory';
|
||||
|
||||
/**
|
||||
@@ -75,7 +75,7 @@ export interface PortHit {
|
||||
/**
|
||||
* Draws one graph node: a title bar coloured by category, a line per setting and a row per
|
||||
* port pair (inputs left, outputs right). Everything is derived from the NodeDef; the view
|
||||
* never interprets what a node does. Positioned in graph coordinates by the GraphEditor.
|
||||
* never interprets what a node does. Positioned in graph coordinates by the GraphCanvas.
|
||||
*/
|
||||
export class NodeView extends Widget {
|
||||
private readonly box = new Graphics();
|
||||
@@ -166,7 +166,7 @@ export class NodeView extends Widget {
|
||||
align: 'left' | 'right' = 'left',
|
||||
maxWidth = NODE_WIDTH - PAD * 2,
|
||||
) => {
|
||||
const node = new Text({ text: fit(text, style, maxWidth), style: textStyle(style) });
|
||||
const node = new Text({ text: truncate(text, textStyle(style), maxWidth), style: textStyle(style) });
|
||||
node.position.set(align === 'left' ? x : x - node.width, y);
|
||||
this.addChild(node);
|
||||
this.texts.push(node);
|
||||
@@ -270,13 +270,3 @@ function formatValue(value: Value): string {
|
||||
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
||||
return asString(value);
|
||||
}
|
||||
|
||||
/** Truncates with an ellipsis so the text fits `maxWidth`. */
|
||||
function fit(text: string, style: TextStyleToken, maxWidth: number): string {
|
||||
const s = textStyle(style);
|
||||
if (CanvasTextMetrics.measureText(text, s).width <= maxWidth) return text;
|
||||
let cut = text;
|
||||
while (cut.length > 1 && CanvasTextMetrics.measureText(cut + '…', s).width > maxWidth)
|
||||
cut = cut.slice(0, -1);
|
||||
return cut + '…';
|
||||
}
|
||||
@@ -168,6 +168,7 @@ export function createField(
|
||||
min: def.min,
|
||||
max: def.max,
|
||||
step: def.step ?? 1,
|
||||
decimals: def.decimals,
|
||||
onInput: handlers.onInput,
|
||||
onChange: handlers.onChange,
|
||||
});
|
||||
|
||||
@@ -81,7 +81,9 @@ function rectRow(
|
||||
): void {
|
||||
const row = createFieldRow(
|
||||
host,
|
||||
{ key, label, kind: 'number', default: 0, step: 1 },
|
||||
// `decimals: 0` is honest here and only here in the inspector: `normalizeFrameRect` rounds
|
||||
// every value that reaches the model, so the field never shortens a number it is holding.
|
||||
{ key, label, kind: 'number', default: 0, step: 1, decimals: 0 },
|
||||
value,
|
||||
rectHandlers(host, assetId, frameId, key),
|
||||
);
|
||||
|
||||
@@ -90,6 +90,8 @@ export class SliceDialog extends Popup {
|
||||
const field = new NumberField(this.options[key], {
|
||||
min,
|
||||
step: 1,
|
||||
// Counts of cells and pixel offsets; the value is rounded on change, so 0 hides nothing.
|
||||
decimals: 0,
|
||||
onChange: (value) => {
|
||||
this.options[key] = Math.round(Number(value));
|
||||
this.refreshSummary();
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ export { Theme, textStyle, type TextStyleToken } from './Theme';
|
||||
export { initUI, ui, type UIContext } from './UIContext';
|
||||
export { drawControlBox, drawGrid, type ControlBoxOptions, type GridOptions } from './draw';
|
||||
export { clamp, centreY, formatNumber, NUMBER_DECIMALS, snapToStep, zoomFactor } from './math';
|
||||
export { Label } from './widgets/Label';
|
||||
export { Label, truncate } from './widgets/Label';
|
||||
export { Button, type ButtonOptions } from './widgets/Button';
|
||||
export { Dropdown } from './widgets/Dropdown';
|
||||
export { Checkbox } from './widgets/Checkbox';
|
||||
|
||||
+3
-2
@@ -8,13 +8,14 @@ export function centreY(containerHeight: number, itemHeight: number): number {
|
||||
return Math.round((containerHeight - itemHeight) / 2);
|
||||
}
|
||||
|
||||
/** Decimal places every number field shows. */
|
||||
/** Decimal places a number field shows unless it asks for its own (`NumberFieldOptions.decimals`). */
|
||||
export const NUMBER_DECIMALS = 3;
|
||||
|
||||
/**
|
||||
* Display text for a number. Always shows `decimals` places, so a value can never *look* like
|
||||
* a whole number when it is not: precision used to follow the field's step, which hid the
|
||||
* decimals that dragging in the viewport or a graph node had put into the value.
|
||||
* decimals that dragging in the viewport or a graph node had put into the value. Fields whose
|
||||
* value is integral by nature (a pixel index, a count) pass 0 and are exact all the same.
|
||||
* A value that rounds to zero never carries a minus sign.
|
||||
*/
|
||||
export function formatNumber(value: number, decimals = NUMBER_DECIMALS): string {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { drawControlBox } from '../draw';
|
||||
import { centreY } from '../math';
|
||||
import { Theme, textStyle } from '../Theme';
|
||||
import { type Size, Widget } from '../Widget';
|
||||
import { truncate } from './Label';
|
||||
|
||||
export interface ButtonOptions {
|
||||
/** Visual weight: 'primary' uses the accent color, 'ghost' has no background until hovered. */
|
||||
@@ -21,6 +22,8 @@ export interface ButtonOptions {
|
||||
export class Button extends Widget {
|
||||
private readonly bg = new Graphics();
|
||||
private readonly labelText: Text;
|
||||
/** The label as set. `labelText` holds the possibly shortened version that is on screen. */
|
||||
private fullText: string;
|
||||
private pressed = false;
|
||||
/** Caller-owned toggle state: renders like a primary button but is not flipped by clicks. */
|
||||
toggled = false;
|
||||
@@ -37,6 +40,7 @@ export class Button extends Widget {
|
||||
this.cursor = 'pointer';
|
||||
this.minHeight = Theme.controlHeight;
|
||||
if (options.width) this.minWidth = options.width;
|
||||
this.fullText = text;
|
||||
this.labelText = new Text({ text, style: textStyle(this.variant === 'primary' ? 'accent' : 'body') });
|
||||
this.addChild(this.bg, this.labelText);
|
||||
|
||||
@@ -61,13 +65,15 @@ export class Button extends Widget {
|
||||
});
|
||||
}
|
||||
|
||||
/** The full label, not the truncated version on screen. */
|
||||
get text(): string {
|
||||
return this.labelText.text;
|
||||
return this.fullText;
|
||||
}
|
||||
|
||||
/** Repaints only; the parent must relayout when the measured width matters. */
|
||||
setText(text: string): void {
|
||||
this.labelText.text = text;
|
||||
if (text === this.fullText) return;
|
||||
this.fullText = text;
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
@@ -83,13 +89,23 @@ export class Button extends Widget {
|
||||
}
|
||||
|
||||
override measure(): Size {
|
||||
const metrics = CanvasTextMetrics.measureText(this.labelText.text, this.labelText.style);
|
||||
// Measured from the full label, never from what is currently on screen: measuring the
|
||||
// truncated text would let a button that was once too narrow stay too narrow for good.
|
||||
const metrics = CanvasTextMetrics.measureText(this.fullText, this.labelText.style);
|
||||
return {
|
||||
w: Math.max(this.minWidth, Math.ceil(metrics.width) + Theme.padding * 2),
|
||||
h: Math.max(this.minHeight, Theme.controlHeight),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Width the label may occupy. Subclasses that draw something else inside the button take
|
||||
* their room off here, so the text is shortened instead of running underneath it.
|
||||
*/
|
||||
protected labelSlot(): number {
|
||||
return this.w - Theme.padding * 2;
|
||||
}
|
||||
|
||||
override onKeyDown(event: KeyboardEvent): boolean {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
this.onClick?.();
|
||||
@@ -131,8 +147,10 @@ export class Button extends Widget {
|
||||
} else if (stroke !== null) {
|
||||
this.bg.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: 1, color: stroke });
|
||||
}
|
||||
// Style first: it decides the glyph widths that the truncation then measures against.
|
||||
this.labelText.style = textStyle(this.onAccent ? 'accent' : 'body');
|
||||
const label = this.labelText;
|
||||
label.text = truncate(this.fullText, label.style, this.labelSlot());
|
||||
const x = this.align === 'left' ? Theme.padding : (this.w - label.width) / 2;
|
||||
label.position.set(Math.round(x), centreY(this.h, label.height));
|
||||
}
|
||||
|
||||
@@ -24,6 +24,11 @@ export class Dropdown extends Button {
|
||||
return { w: size.w + CHEVRON_SLOT, h: size.h };
|
||||
}
|
||||
|
||||
/** The chevron's room is not the label's: a long value is shortened, not drawn under it. */
|
||||
protected override labelSlot(): number {
|
||||
return super.labelSlot() - CHEVRON_SLOT;
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
super.redraw();
|
||||
const x = this.w - CHEVRON_SLOT / 2 - Theme.padding / 2;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FederatedPointerEvent } from 'pixi.js';
|
||||
import { clamp, formatNumber, snapToStep } from '../math';
|
||||
import { clamp, formatNumber, NUMBER_DECIMALS, snapToStep } from '../math';
|
||||
import { ui } from '../UIContext';
|
||||
import { TextField, type TextFieldOptions } from './TextField';
|
||||
|
||||
@@ -8,9 +8,16 @@ export interface NumberFieldOptions extends Omit<TextFieldOptions, 'onInput' | '
|
||||
max?: number;
|
||||
/**
|
||||
* Increment per scrubbed pixel and per arrow key press, and the grid a scrub snaps to.
|
||||
* Does not affect the display, which always shows `NUMBER_DECIMALS` decimals.
|
||||
* Does not affect the display, which is governed by `decimals` alone.
|
||||
*/
|
||||
step?: number;
|
||||
/**
|
||||
* Decimal places the text shows (default `NUMBER_DECIMALS`). Only set this to a lower
|
||||
* precision where the *stored* value is of that precision too – a pixel index, a count of
|
||||
* tiles. On anything the viewport can drag to a fractional value it would hide the fraction,
|
||||
* and the text would commit the rounded number back into the project.
|
||||
*/
|
||||
decimals?: number;
|
||||
/** Fires continuously while scrubbing or typing a valid number. */
|
||||
onInput?: (value: number) => void;
|
||||
/** Fires once when a scrub ends or an edit is committed. */
|
||||
@@ -24,14 +31,15 @@ const SCRUB_THRESHOLD = 3;
|
||||
* Numeric input: click to type, drag horizontally to scrub (Shift = ×10, Alt = ×0.1),
|
||||
* arrow keys step. A scrub snaps to the grid of its increment – a step-1 field therefore
|
||||
* always ends on a whole number, even when it started fractional. Values are clamped to
|
||||
* min/max and always shown with `NUMBER_DECIMALS` decimals, so the text never hides that
|
||||
* the value is fractional.
|
||||
* min/max and shown with `NUMBER_DECIMALS` decimals unless the field declares otherwise,
|
||||
* so the text never hides that the value is fractional.
|
||||
*/
|
||||
export class NumberField extends TextField {
|
||||
private numeric: number;
|
||||
private readonly min: number;
|
||||
private readonly max: number;
|
||||
private readonly step: number;
|
||||
private readonly decimals: number;
|
||||
private scrub: { startX: number; startValue: number; moved: boolean } | null = null;
|
||||
onNumberInput?: (value: number) => void;
|
||||
onNumberChange?: (value: number) => void;
|
||||
@@ -41,10 +49,11 @@ export class NumberField extends TextField {
|
||||
this.min = options.min ?? -Infinity;
|
||||
this.max = options.max ?? Infinity;
|
||||
this.step = options.step ?? 1;
|
||||
this.decimals = options.decimals ?? NUMBER_DECIMALS;
|
||||
this.onNumberInput = options.onInput;
|
||||
this.onNumberChange = options.onChange;
|
||||
this.numeric = this.clampValue(value);
|
||||
this.valueText = formatNumber(this.numeric);
|
||||
this.valueText = this.display;
|
||||
this.cursor = 'ew-resize';
|
||||
|
||||
this.onInput = (text) => {
|
||||
@@ -57,7 +66,7 @@ export class NumberField extends TextField {
|
||||
this.onChange = (text) => {
|
||||
const n = Number(text.trim());
|
||||
this.numeric = this.clampValue(Number.isFinite(n) && text.trim() !== '' ? n : this.numeric);
|
||||
this.valueText = formatNumber(this.numeric);
|
||||
this.valueText = this.display;
|
||||
this.invalidate();
|
||||
this.onNumberChange?.(this.numeric);
|
||||
};
|
||||
@@ -90,11 +99,16 @@ export class NumberField extends TextField {
|
||||
return this.numeric;
|
||||
}
|
||||
|
||||
/** The one place the value becomes text, so every path through the field honours `decimals`. */
|
||||
private get display(): string {
|
||||
return formatNumber(this.numeric, this.decimals);
|
||||
}
|
||||
|
||||
/** Model → view update: clamps, and reformats the text unless the user is editing it. */
|
||||
setNumber(value: number): void {
|
||||
this.numeric = this.clampValue(value);
|
||||
if (!this.isEditing) {
|
||||
this.valueText = formatNumber(this.numeric);
|
||||
this.valueText = this.display;
|
||||
this.invalidate();
|
||||
}
|
||||
}
|
||||
@@ -114,7 +128,7 @@ export class NumberField extends TextField {
|
||||
const dir = event.key === 'ArrowUp' ? 1 : -1;
|
||||
const factor = event.shiftKey ? 10 : event.altKey ? 0.1 : 1;
|
||||
this.numeric = this.clampValue(this.numeric + dir * this.step * factor);
|
||||
this.valueText = formatNumber(this.numeric);
|
||||
this.valueText = this.display;
|
||||
ui().keyboard.setValue(this.valueText, 0, this.valueText.length);
|
||||
this.selectAll();
|
||||
this.onNumberInput?.(this.numeric);
|
||||
|
||||
@@ -42,7 +42,14 @@ export class TextureLibrary {
|
||||
return null;
|
||||
}
|
||||
const mode = ref.smooth ? 'linear' : 'nearest';
|
||||
if (texture.source.scaleMode !== mode) texture.source.scaleMode = mode;
|
||||
if (texture.source.scaleMode !== mode) {
|
||||
texture.source.scaleMode = mode;
|
||||
// Assigning the mode alone is not enough, and the difference is invisible until a reload:
|
||||
// Pixi's `scaleMode` setter only writes mag/min/mipmapFilter and leaves the style's cached
|
||||
// `_sharedResourceId` in place – but that id is the key the GPU sampler is looked up under,
|
||||
// so the old sampler keeps being used. `update()` drops the id and announces the change.
|
||||
texture.source.style.update();
|
||||
}
|
||||
return texture;
|
||||
}
|
||||
|
||||
|
||||
+10
-1
@@ -22,7 +22,7 @@ describe('centreY', () => {
|
||||
});
|
||||
|
||||
describe('formatNumber', () => {
|
||||
it('always shows three decimals', () => {
|
||||
it('shows three decimals by default', () => {
|
||||
expect(NUMBER_DECIMALS).toBe(3);
|
||||
expect(formatNumber(100)).toBe('100.000');
|
||||
expect(formatNumber(0.5)).toBe('0.500');
|
||||
@@ -50,6 +50,15 @@ describe('formatNumber', () => {
|
||||
expect(formatNumber(1.25, 1)).toBe('1.3');
|
||||
expect(formatNumber(1.25, 0)).toBe('1');
|
||||
});
|
||||
|
||||
it('shows a field of integral values without a decimal point at all', () => {
|
||||
// What `NumberFieldOptions.decimals: 0` buys: a pixel index or a count of tiles reads
|
||||
// "33" and "4" instead of "33.000" and "4.000".
|
||||
expect(formatNumber(33, 0)).toBe('33');
|
||||
expect(formatNumber(-4, 0)).toBe('-4');
|
||||
// The minus-zero rule holds at every precision, not just the default one.
|
||||
expect(formatNumber(-0.4, 0)).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('snapToStep', () => {
|
||||
|
||||
+29
-4
@@ -186,15 +186,40 @@ export async function graph(t) {
|
||||
await undo();
|
||||
check('undo removes the variable', (await variableKeys()) === 'speed', await variableKeys());
|
||||
|
||||
// 13h. The graph strictly mirrors the selection: nothing / a non-behavior → empty editor.
|
||||
// 13h. The tab keeps its subject. A selection about something else (nothing, a camera) leaves
|
||||
// the graph alone – clicking a sprite to look at it must not empty the editor you are
|
||||
// working in – and the header picker switches behaviors without any selection at all.
|
||||
const size = await graphCounts();
|
||||
const readout = `${size.nodes} nodes · ${size.edges} links`;
|
||||
check(
|
||||
'the header reports the size of the shown graph',
|
||||
Boolean(await evaluate(`__gm.locate(${JSON.stringify(readout)})`)),
|
||||
readout,
|
||||
);
|
||||
await select(null);
|
||||
await sleep(100);
|
||||
check('graph editor empties when nothing is selected', (await evaluate(`__gm.graph.current()`)) === null);
|
||||
check(
|
||||
'graph editor keeps its behavior when nothing is selected',
|
||||
(await evaluate(`__gm.graph.current()`)) === 'mover',
|
||||
);
|
||||
await select({ kind: 'asset', assetId: 'cam' });
|
||||
await sleep(100);
|
||||
check(
|
||||
'graph editor stays empty for a non-behavior asset',
|
||||
(await evaluate(`__gm.graph.current()`)) === null,
|
||||
'graph editor keeps its behavior for a non-behavior asset',
|
||||
(await evaluate(`__gm.graph.current()`)) === 'mover',
|
||||
);
|
||||
// The picker is the *last* "Mover" on screen: the assets panel shows one too, and the
|
||||
// inspector is on the camera.
|
||||
const pickerAt = await locate('Mover', true);
|
||||
await click(pickerAt.x, pickerAt.y);
|
||||
await sleep(150);
|
||||
const spawnerRow = await locate('Spawner', true);
|
||||
await click(spawnerRow.x, spawnerRow.y);
|
||||
await sleep(200);
|
||||
check(
|
||||
'the header picker switches the graph to another behavior',
|
||||
(await evaluate(`__gm.graph.current()`)) === 'spawner',
|
||||
String(await evaluate(`__gm.graph.current()`)),
|
||||
);
|
||||
await select({ kind: 'asset', assetId: 'mover' });
|
||||
await sleep(100);
|
||||
|
||||
@@ -138,6 +138,56 @@ export async function textures(t) {
|
||||
);
|
||||
await shot('32-textured-sprite');
|
||||
|
||||
// 19b-i. Switching the Filter property takes effect at once, without a reload.
|
||||
//
|
||||
// Read off the screen rather than off `scaleMode`, because the property was always assigned
|
||||
// correctly – what used to survive the change was the GPU sampler behind it. The measurement
|
||||
// is what "smooth" means: across the red/green border, nearest sampling shows only the two
|
||||
// pure colours, linear sampling blends them into something that is neither.
|
||||
const enlarge = (w, h) => `
|
||||
const sprite = __gm.state.project.assets.find((a) => a.id === 'tex-sprite');
|
||||
sprite.props.width = ${w};
|
||||
sprite.props.height = ${h}`;
|
||||
await mutate(enlarge(320, 240));
|
||||
await sleep(600);
|
||||
const wide = await json("__gm.objectRect('otex')");
|
||||
// Sampled in the upper half so the line crosses only the red/green border, not the corner
|
||||
// where all four quadrants meet.
|
||||
const acrossBorder = async () => {
|
||||
const y = Math.round(wide.y - wide.h * 0.25);
|
||||
const points = [];
|
||||
for (let dx = -2; dx <= 2; dx++) {
|
||||
points.push(await json(`__gm.pixelAt(${Math.round(wide.x + dx)}, ${y})`));
|
||||
}
|
||||
return points;
|
||||
};
|
||||
const setFilter = (value) =>
|
||||
mutate(
|
||||
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)}).props.filter = ${JSON.stringify(value)}`,
|
||||
);
|
||||
|
||||
await setFilter('nearest');
|
||||
await sleep(600);
|
||||
const hard = await acrossBorder();
|
||||
const pure = (c) => colourDistance(c, '#e63c3c') < 60 || colourDistance(c, '#3cc85a') < 60;
|
||||
check(
|
||||
'pixel-art filtering keeps the border between two quadrants hard',
|
||||
hard.every(pure),
|
||||
JSON.stringify(hard),
|
||||
);
|
||||
|
||||
await setFilter('linear');
|
||||
await sleep(600);
|
||||
const soft = await acrossBorder();
|
||||
check(
|
||||
'switching the filter back to smooth blends the border without a reload',
|
||||
soft.some((c) => !pure(c)),
|
||||
JSON.stringify({ nearest: hard, linear: soft }),
|
||||
);
|
||||
await shot('33-texture-filter-smooth');
|
||||
await mutate(enlarge(160, 120));
|
||||
await sleep(400);
|
||||
|
||||
// 19c. The colour multiplies the picture, so a tinted white quadrant stops being white.
|
||||
await mutate("__gm.state.project.assets.find((a) => a.id === 'tex-sprite').props.color = 0x3366ff");
|
||||
await sleep(600);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Labels must be shortened to fit their control, not written past it. Runs last because it
|
||||
* renames assets to absurd lengths to provoke the case.
|
||||
*
|
||||
* `__gm.textOverflows()` is a general guard rather than a check on one widget: it walks every
|
||||
* visible text and reports the ones drawn wider than the widget that owns them. It was verified
|
||||
* to actually catch this class of bug – against a build without the fix it reported the graph
|
||||
* picker at 106 px over, and nothing else.
|
||||
*/
|
||||
|
||||
/** A name far wider than any 200 px picker, so a control that does not truncate must spill. */
|
||||
const LONG_NAME = 'Player movement with jumping and coyote time';
|
||||
|
||||
export async function overflow(t) {
|
||||
const { check, sleep, json, evaluate, shot, mutate } = t;
|
||||
|
||||
await mutate(`
|
||||
for (const asset of __gm.state.project.assets) {
|
||||
if (asset.type === 'behavior' || asset.type === 'texture') asset.name = ${JSON.stringify(LONG_NAME)};
|
||||
}
|
||||
__gm.state.select(null)`);
|
||||
await sleep(600);
|
||||
|
||||
// 21. Every tab, because each one owns a different set of controls: the two header bars with
|
||||
// their pickers, the inspector's dropdowns, and the toolbar.
|
||||
for (const tab of ['scene', 'graph', 'texture', 'game']) {
|
||||
await evaluate(`__gm.showTab(${JSON.stringify(tab)})`);
|
||||
await sleep(400);
|
||||
const spills = await json('__gm.textOverflows()');
|
||||
check(
|
||||
`no label is drawn past its control in the ${tab} tab`,
|
||||
spills.length === 0,
|
||||
JSON.stringify(spills),
|
||||
);
|
||||
}
|
||||
|
||||
// 21a. Shortened, not simply dropped: the value must still be readable and marked as cut.
|
||||
await evaluate("__gm.showTab('graph')");
|
||||
await sleep(400);
|
||||
const shown = (await json('__gm.textOverflows(true)')).find((e) => e.text.startsWith('Player movement'));
|
||||
check(
|
||||
'a value too wide for its dropdown is cut with an ellipsis',
|
||||
Boolean(shown) && shown.text.endsWith('…') && shown.text.length > 8,
|
||||
JSON.stringify(shown),
|
||||
);
|
||||
await shot('36-long-labels');
|
||||
|
||||
// 21b. An inspector full of dropdowns – the parent row and the enum fields – with a selection
|
||||
// whose asset name is now overlong too.
|
||||
await evaluate("__gm.showTab('scene')");
|
||||
await sleep(300);
|
||||
await evaluate("__gm.state.select({ kind: 'object', objectId: 'o1' })");
|
||||
await sleep(500);
|
||||
const inspector = await json('__gm.textOverflows()');
|
||||
check('no label spills in the inspector', inspector.length === 0, JSON.stringify(inspector));
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { textAndEnabled } from './10-text-and-enabled.mjs';
|
||||
import { menus } from './11-menus.mjs';
|
||||
import { textures } from './12-textures.mjs';
|
||||
import { frames } from './13-frames.mjs';
|
||||
import { overflow } from './14-overflow.mjs';
|
||||
|
||||
export const STEPS = [
|
||||
hierarchy,
|
||||
@@ -31,4 +32,5 @@ export const STEPS = [
|
||||
menus,
|
||||
textures,
|
||||
frames,
|
||||
overflow,
|
||||
];
|
||||
Reference in new issue
Block a user