Files
feuerwerk/src/editor/graph/NodeView.ts
T
rootandClaude Opus 5 5a6af74e44 Add textures for sprites
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>
2026-09-08 13:43:08 +02:00

283 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 + '…';
}