Images are uploaded to a content-addressed blob endpoint and stored as data/textures/<sha256>.<ext>; the project only keeps the file name. The sprite points at a texture asset through an ordinary property of the new kind `asset`, so instance overrides, "Apply to container", "Reset to asset", undo and `Set property` work without any extra code. The new kind also replaces the ad-hoc asset picker the graph nodes faked as an enum. Import goes through + -> Texture, drag & drop onto the canvas or Ctrl+V. Images wider than 2048px are downscaled client-side and re-encoded to WebP if needed, because nginx caps uploads at 1MB; an image that already fits is uploaded byte for byte so pixel art is never resampled. The sprite colour default changes from red to white, since colour tints the texture multiplicatively and a tint of white shows an image as it is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
283 lines
9.4 KiB
TypeScript
283 lines
9.4 KiB
TypeScript
import { CanvasTextMetrics, Graphics, Rectangle, Text } from 'pixi.js';
|
||
import {
|
||
asString,
|
||
edgesInto,
|
||
type Graph,
|
||
type GraphNode,
|
||
inlineInputs,
|
||
inlineValue,
|
||
type NodeCategory,
|
||
type NodeDef,
|
||
type PortDef,
|
||
type PortType,
|
||
type Value,
|
||
} from '../../core/graph';
|
||
import type { BehaviorAsset } from '../../core/model/Asset';
|
||
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 { keyLabel } from '../inspector/fieldFactory';
|
||
|
||
/**
|
||
* Node geometry in graph units – what the node lays itself out, draws and hit-tests with.
|
||
* Exported as the single source of these numbers: edge anchors have to land exactly on the
|
||
* ports drawn here.
|
||
*/
|
||
export const NODE_WIDTH = 176;
|
||
export const TITLE_HEIGHT = 24;
|
||
export const ROW_HEIGHT = 20;
|
||
export const PORT_RADIUS = 5;
|
||
/** Distance (graph units) within which a press counts as hitting a port. */
|
||
export const PORT_HIT_RADIUS = 9;
|
||
const PAD = 6;
|
||
|
||
export function categoryColor(category: NodeCategory): number {
|
||
const c = Theme.colors;
|
||
switch (category) {
|
||
case 'event':
|
||
return c.categoryEvent;
|
||
case 'flow':
|
||
return c.categoryFlow;
|
||
case 'data':
|
||
return c.categoryData;
|
||
case 'variable':
|
||
return c.categoryVariable;
|
||
case 'action':
|
||
return c.categoryAction;
|
||
}
|
||
}
|
||
|
||
export function portColor(type: PortType): number {
|
||
const c = Theme.colors;
|
||
switch (type) {
|
||
case 'exec':
|
||
return c.portExec;
|
||
case 'number':
|
||
return c.portNumber;
|
||
case 'boolean':
|
||
return c.portBoolean;
|
||
case 'string':
|
||
return c.portString;
|
||
case 'object':
|
||
return c.portObject;
|
||
case 'any':
|
||
return c.portAny;
|
||
}
|
||
}
|
||
|
||
export interface PortHit {
|
||
port: PortDef;
|
||
isOutput: boolean;
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
export class NodeView extends Widget {
|
||
private readonly box = new Graphics();
|
||
private readonly ports = new Graphics();
|
||
private readonly texts: Text[] = [];
|
||
private paramLines: string[] = [];
|
||
selected = false;
|
||
hot = false;
|
||
/** Data inputs that currently have a connection (their inline value is hidden). */
|
||
private connected = new Set<string>();
|
||
|
||
constructor(
|
||
readonly node: GraphNode,
|
||
readonly def: NodeDef,
|
||
) {
|
||
super();
|
||
this.cursor = 'grab';
|
||
this.addChild(this.box, this.ports);
|
||
this.sync(node, { nodes: [node], edges: [] }, null, null);
|
||
}
|
||
|
||
/** Ports sit on the left/right edges; widen the hit area so they can be grabbed. */
|
||
override resize(w: number, h: number): void {
|
||
super.resize(w, h);
|
||
this.hitArea = new Rectangle(-PORT_HIT_RADIUS, 0, w + PORT_HIT_RADIUS * 2, h);
|
||
}
|
||
|
||
/** Re-reads params and connections; rebuilds the text lines. `project` resolves asset references (Spawn, Is asset). */
|
||
sync(node: GraphNode, graph: Graph, asset: BehaviorAsset | null, project: Project | null): void {
|
||
this.paramLines = this.def.params.map(
|
||
(p) => `${p.label}: ${formatParam(p, node.params[p.key] ?? p.default, asset, project)}`,
|
||
);
|
||
this.connected = new Set(
|
||
inlineInputs(this.def)
|
||
.filter((p) => edgesInto(graph, node.id, p.key).length > 0)
|
||
.map((p) => p.key),
|
||
);
|
||
const height =
|
||
TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + this.rowCount() * ROW_HEIGHT + PAD;
|
||
this.rebuildTexts();
|
||
this.resize(NODE_WIDTH, height);
|
||
this.position.set(node.x, node.y);
|
||
}
|
||
|
||
private rowCount(): number {
|
||
return Math.max(this.def.inputs.length, this.def.outputs.length);
|
||
}
|
||
|
||
/** Local position of a port's centre. */
|
||
portPosition(key: string, isOutput: boolean): { x: number; y: number } | null {
|
||
const list = isOutput ? this.def.outputs : this.def.inputs;
|
||
const index = list.findIndex((p) => p.key === key);
|
||
if (index < 0) return null;
|
||
return { x: isOutput ? this.w : 0, y: this.rowY(index) };
|
||
}
|
||
|
||
private rowY(index: number): number {
|
||
return TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + index * ROW_HEIGHT + ROW_HEIGHT / 2;
|
||
}
|
||
|
||
/** Every port with its local centre – inputs on the left edge, outputs on the right. */
|
||
private *portCentres(): Iterable<{ port: PortDef; isOutput: boolean; x: number; y: number }> {
|
||
for (const [list, isOutput] of [
|
||
[this.def.inputs, false],
|
||
[this.def.outputs, true],
|
||
] as const) {
|
||
for (let i = 0; i < list.length; i++)
|
||
yield { port: list[i]!, isOutput, x: isOutput ? this.w : 0, y: this.rowY(i) };
|
||
}
|
||
}
|
||
|
||
/** Which port (if any) sits under a local point. */
|
||
portAt(localX: number, localY: number): PortHit | null {
|
||
for (const { port, isOutput, x, y } of this.portCentres()) {
|
||
if (Math.hypot(localX - x, localY - y) <= PORT_HIT_RADIUS) return { port, isOutput };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private rebuildTexts(): void {
|
||
for (const t of this.texts) t.destroy();
|
||
this.texts.length = 0;
|
||
const add = (
|
||
text: string,
|
||
style: TextStyleToken,
|
||
x: number,
|
||
y: number,
|
||
align: 'left' | 'right' = 'left',
|
||
maxWidth = NODE_WIDTH - PAD * 2,
|
||
) => {
|
||
const node = new Text({ text: fit(text, style, maxWidth), style: textStyle(style) });
|
||
node.position.set(align === 'left' ? x : x - node.width, y);
|
||
this.addChild(node);
|
||
this.texts.push(node);
|
||
};
|
||
add(this.def.label, 'accent', PAD + 2, (TITLE_HEIGHT - 16) / 2);
|
||
let y = TITLE_HEIGHT + PAD;
|
||
for (const line of this.paramLines) {
|
||
add(line, 'small', PAD + 2, y + 2);
|
||
y += ROW_HEIGHT;
|
||
}
|
||
for (let i = 0; i < this.rowCount(); i++) {
|
||
const input = this.def.inputs[i];
|
||
const output = this.def.outputs[i];
|
||
const rowTop = y + i * ROW_HEIGHT + 2;
|
||
if (input) {
|
||
let label = input.label;
|
||
if (input.type !== 'exec' && input.type !== 'object' && !this.connected.has(input.key)) {
|
||
const value = inlineValue(this.node, input);
|
||
if (value !== null && value !== '') label += ` ${formatValue(value)}`;
|
||
}
|
||
add(
|
||
label,
|
||
input.type === 'exec' ? 'body' : 'muted',
|
||
PAD + PORT_RADIUS + 4,
|
||
rowTop,
|
||
'left',
|
||
NODE_WIDTH / 2 + 20,
|
||
);
|
||
}
|
||
if (output)
|
||
add(
|
||
output.label,
|
||
output.type === 'exec' ? 'body' : 'muted',
|
||
this.w - PAD - PORT_RADIUS - 4,
|
||
rowTop,
|
||
'right',
|
||
NODE_WIDTH / 2 - 10,
|
||
);
|
||
}
|
||
}
|
||
|
||
override redraw(): void {
|
||
const c = Theme.colors;
|
||
this.box.clear();
|
||
this.box.roundRect(2, 3, this.w, this.h, Theme.radius).fill({ color: 0x000000, alpha: 0.35 });
|
||
this.box.roundRect(0, 0, this.w, this.h, Theme.radius).fill(c.node);
|
||
this.box.roundRect(0, 0, this.w, TITLE_HEIGHT, Theme.radius).fill(categoryColor(this.def.category));
|
||
this.box
|
||
.rect(0, TITLE_HEIGHT - Theme.radius, this.w, Theme.radius)
|
||
.fill(categoryColor(this.def.category));
|
||
const border = this.hot ? c.hot : this.selected ? c.nodeSelected : c.nodeBorder;
|
||
this.box
|
||
.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius)
|
||
.stroke({ width: this.selected || this.hot ? 2 : 1, color: border });
|
||
|
||
this.ports.clear();
|
||
const r = PORT_RADIUS;
|
||
for (const { port, x, y } of this.portCentres()) {
|
||
if (port.type === 'exec') {
|
||
// A pentagon pointing right: the Blueprint-style exec pin.
|
||
this.ports
|
||
.moveTo(x - r, y - r)
|
||
.lineTo(x + 1, y - r)
|
||
.lineTo(x + r + 1, y)
|
||
.lineTo(x + 1, y + r)
|
||
.lineTo(x - r, y + r)
|
||
.closePath()
|
||
.fill(portColor('exec'));
|
||
} else {
|
||
this.ports.circle(x, y, r).fill(portColor(port.type));
|
||
this.ports.circle(x, y, r).stroke({ width: 1, color: c.node });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/** The value of a setting as shown on the node; references that no longer resolve get a "?". */
|
||
function formatParam(
|
||
param: PropertyDef,
|
||
value: PropertyValue | undefined,
|
||
asset: BehaviorAsset | null,
|
||
project: Project | null,
|
||
): string {
|
||
if (value === undefined || value === '') return '—';
|
||
if (param.kind === 'key') return keyLabel(String(value));
|
||
if (param.kind === 'asset') return assetRefLabel(project, String(value));
|
||
if (
|
||
param.kind === 'enum' &&
|
||
param.key === 'variable' &&
|
||
asset &&
|
||
!asset.variables.some((v) => v.key === value)
|
||
)
|
||
return `${value}?`;
|
||
return formatValue(value);
|
||
}
|
||
|
||
function formatValue(value: Value): string {
|
||
// Whole numbers stay bare to keep nodes narrow; fractions get the same precision as the
|
||
// field that edits them, so a node never rounds harder than the inspector.
|
||
if (typeof value === 'number') return Number.isInteger(value) ? String(value) : formatNumber(value);
|
||
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 + '…';
|
||
}
|