Two compilers that walk a graph the same way walk it through one emitter (#85)
game-maker / suite (push) Failing after 2m22s
game-maker / gate (push) Successful in 2m53s

Refactoring round two: the particle and shader compilers share emitGraph.ts (cycle detection,
duplicate names, emit order) and keep only their rules. FontLibrary and TextureLibrary sit on
FileLibrary (base URL, loading/failed bookkeeping, listeners); SoundLibrary keeps its own
contract. The three Create*Command share ImportFileCommand, the three renames share
RenameCommand - class names stay. bandBounds() in framing.ts and portNameField() in
portSection.ts replace in-file clones, and three comments that told history now state the rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-16 19:28:28 +02:00
1 parent bf0be66c9d
commit d3563897d8
16 files changed
+376 -379

No files matched your search

+19
View File
@@ -11,6 +11,25 @@ export interface Command {
undo(project: Project): void;
}
/** A rename that is undone by renaming back. */
export abstract class RenameCommand implements Command {
constructor(
readonly label: string,
private readonly from: string,
private readonly to: string,
) {}
execute(project: Project): void {
this.rename(project, this.from, this.to);
}
undo(project: Project): void {
this.rename(project, this.to, this.from);
}
protected abstract rename(project: Project, from: string, to: string): void;
}
/** A rejected edit with a message fit for the user; the editor shows it as a notice. */
export class CommandError extends Error {
constructor(message: string) {
+20 -42
View File
@@ -110,22 +110,24 @@ export function buildTextureAsset(name: string, image: TextureImage, id = newId(
}
/**
* The image is uploaded before the command runs; this only records the result, so undo and redo
* The file is uploaded before the command runs; this only records the result, so undo and redo
* never touch the network.
*/
export class CreateTextureCommand implements Command {
abstract class ImportFileCommand<A extends Asset & { file: string }> implements Command {
readonly label: string;
readonly asset: TextureAsset;
constructor(name: string, image: TextureImage) {
this.asset = buildTextureAsset(name, image);
this.label = `Import texture "${name}"`;
constructor(
readonly asset: A,
private readonly fileNoun: string,
private readonly isStored: (file: string) => boolean,
) {
this.label = `Import ${asset.type} "${asset.name}"`;
}
execute(project: Project): void {
// The name is the hash the server derived from the bytes; anything else was never uploaded.
if (!isTextureFile(this.asset.file))
throw new CommandError(`"${this.asset.file}" is not a stored image file`);
if (!this.isStored(this.asset.file))
throw new CommandError(`"${this.asset.file}" is not a stored ${this.fileNoun} file`);
project.assets.push(this.asset);
}
@@ -134,28 +136,19 @@ export class CreateTextureCommand implements Command {
}
}
export class CreateTextureCommand extends ImportFileCommand<TextureAsset> {
constructor(name: string, image: TextureImage) {
super(buildTextureAsset(name, image), 'image', isTextureFile);
}
}
export function buildFontAsset(name: string, file: string, id = newId()): FontAsset {
return { id, name, props: defaultsFor('font'), type: 'font', file };
}
/** The typeface is uploaded before the command runs, as an image is for `CreateTextureCommand`. */
export class CreateFontCommand implements Command {
readonly label: string;
readonly asset: FontAsset;
export class CreateFontCommand extends ImportFileCommand<FontAsset> {
constructor(name: string, file: string) {
this.asset = buildFontAsset(name, file);
this.label = `Import font "${name}"`;
}
execute(project: Project): void {
if (!isFontFile(this.asset.file))
throw new CommandError(`"${this.asset.file}" is not a stored font file`);
project.assets.push(this.asset);
}
undo(project: Project): void {
project.assets = removeById(project.assets, this.asset.id);
super(buildFontAsset(name, file), 'font', isFontFile);
}
}
@@ -163,24 +156,9 @@ export function buildSoundAsset(name: string, file: string, id = newId()): Sound
return { id, name, props: defaultsFor('sound'), type: 'sound', file };
}
/** The sound is uploaded before the command runs, as a typeface is for `CreateFontCommand`. */
export class CreateSoundCommand implements Command {
readonly label: string;
readonly asset: SoundAsset;
export class CreateSoundCommand extends ImportFileCommand<SoundAsset> {
constructor(name: string, file: string) {
this.asset = buildSoundAsset(name, file);
this.label = `Import sound "${name}"`;
}
execute(project: Project): void {
if (!isSoundFile(this.asset.file))
throw new CommandError(`"${this.asset.file}" is not a stored sound file`);
project.assets.push(this.asset);
}
undo(project: Project): void {
project.assets = removeById(project.assets, this.asset.id);
super(buildSoundAsset(name, file), 'sound', isSoundFile);
}
}
+6 -16
View File
@@ -19,7 +19,7 @@ import {
import type { PropertyValue } from '../model/Properties';
import type { Project } from '../model/Project';
import { type Removed, removeFirst, removeWhere, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command';
import { type Command, CommandError, RenameCommand } from './Command';
import { nextFieldName } from './naming';
import { requireBehavior, requireNode, requireNodeDef } from './require';
@@ -260,27 +260,17 @@ export class SetPortWriteCommand implements Command {
}
/** Rewrites the edges and inline values pointing at the port too, in every graph that has any. */
export class RenamePortCommand implements Command {
readonly label: string;
export class RenamePortCommand extends RenameCommand {
constructor(
private readonly assetId: string,
private readonly nodeId: string,
private readonly from: string,
private readonly to: string,
from: string,
to: string,
) {
this.label = `Rename ${from}`;
super(`Rename ${from}`, from, to);
}
execute(project: Project): void {
this.rename(project, this.from, this.to);
}
undo(project: Project): void {
this.rename(project, this.to, this.from);
}
private rename(project: Project, from: string, to: string): void {
protected rename(project: Project, from: string, to: string): void {
if (from === to) return;
const node = requireNode(project, this.assetId, this.nodeId);
const sites = portSites(project, this.assetId, node);
+11 -31
View File
@@ -17,7 +17,7 @@ import {
variableDef,
} from '../properties/PropertySchema';
import { type Removed, removeFirst, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command';
import { type Command, CommandError, RenameCommand } from './Command';
import { renameVariablePorts } from './portCommands';
import { requireVariableOwner } from './require';
@@ -117,26 +117,16 @@ export class RemoveVariableCommand implements Command {
}
/** Renames everywhere: definition, default, instance overrides and the graph nodes referring to it. */
export class RenameVariableCommand implements Command {
readonly label: string;
export class RenameVariableCommand extends RenameCommand {
constructor(
private readonly assetId: string,
private readonly from: string,
private readonly to: string,
from: string,
to: string,
) {
this.label = `Rename variable ${from}`;
super(`Rename variable ${from}`, from, to);
}
execute(project: Project): void {
this.rename(project, this.from, this.to);
}
undo(project: Project): void {
this.rename(project, this.to, this.from);
}
private rename(project: Project, from: string, to: string): void {
protected rename(project: Project, from: string, to: string): void {
const asset = requireVariableOwner(project, this.assetId);
if (from === to) return;
if (!isValidVariableName(to)) throw new CommandError(INVALID_NAME);
@@ -316,27 +306,17 @@ export class RemoveStructFieldCommand implements Command {
}
}
export class RenameStructFieldCommand implements Command {
readonly label: string;
export class RenameStructFieldCommand extends RenameCommand {
constructor(
private readonly assetId: string,
private readonly key: string,
private readonly from: string,
private readonly to: string,
from: string,
to: string,
) {
this.label = `Rename field ${from}`;
super(`Rename field ${from}`, from, to);
}
execute(project: Project): void {
this.rename(project, this.from, this.to);
}
undo(project: Project): void {
this.rename(project, this.to, this.from);
}
private rename(project: Project, from: string, to: string): void {
protected rename(project: Project, from: string, to: string): void {
const owner = requireFieldOwner(project, this.assetId, this.key);
if (from === to) return;
if (!isValidFieldName(to)) throw new CommandError(INVALID_FIELD);
+2 -2
View File
@@ -19,8 +19,8 @@ const MAX_STEPS_PER_FIRE = 100_000;
/**
* How far exec edges may nest. The interpreter recurses along them, so this is what keeps a flow
* feeding back into itself off the call stack – a job the step budget can no longer do now that a
* legitimate pulse may run tens of thousands of steps.
* feeding back into itself off the call stack – the step budget cannot, since a legitimate pulse
* may run tens of thousands of steps.
*/
const MAX_DEPTH = 1000;
+106
View File
@@ -0,0 +1,106 @@
import { edgesInto, findNode, type Graph, type GraphNode } from './Graph';
import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry';
export interface EmitRules<D extends NodeDef, T> {
isDef: (def: NodeDef) => def is D;
/** Names the node kind in "Not a … node". */
noun: string;
/** A node's outputs from its inputs; called once per node, in dependency order. */
emit: (def: D, inputs: Record<string, T>, node: GraphNode) => Record<string, T>;
/** An input without an edge: the typed-in value or what the node says it means unfed. */
unfed: (node: GraphNode, port: PortDef, def: D | undefined) => T | null;
/** An edge's value on its way into `port`; null (after reporting a problem) when it cannot go in. */
conform?: (node: GraphNode, port: PortDef, value: T) => T | null;
}
export interface GraphEmitter<T> {
/** Per node id, the first thing that keeps the graph from compiling. */
readonly problems: Record<string, string>;
problem(node: GraphNode, text: string): void;
/** What feeds an input, emitting whatever it depends on first. */
input(node: GraphNode, port: PortDef): T | null;
/** Whether the walk reached the node. */
reached(node: GraphNode): boolean;
/** Reports property names that are invalid (`invalid` returns the text) or taken twice. */
checkNames(
nodes: GraphNode[],
nameOf: (node: GraphNode) => string,
invalid: (name: string) => string,
): void;
/** The problem of the first node in graph order, or null when there is none. */
firstProblem(): string | null;
}
/**
* The walk shared by the graph compilers: every node reachable from where `input` is first asked
* is emitted once, before anything that reads it, and a node feeding itself is a problem.
*/
export function graphEmitter<D extends NodeDef, T>(graph: Graph, rules: EmitRules<D, T>): GraphEmitter<T> {
const problems: Record<string, string> = {};
const emitted = new Map<string, Record<string, T>>();
const visiting = new Set<string>();
const problem = (node: GraphNode, text: string) => {
if (!(node.id in problems)) problems[node.id] = text;
};
const emitNode = (node: GraphNode): Record<string, T> | null => {
const done = emitted.get(node.id);
if (done) return done;
if (visiting.has(node.id)) {
problem(node, 'Feeds itself');
return null;
}
const def = nodeDef(node.type);
if (!def || !rules.isDef(def)) {
problem(node, `Not a ${rules.noun} node`);
return null;
}
visiting.add(node.id);
const inputs: Record<string, T> = {};
let complete = true;
for (const port of def.inputs) {
const value = input(node, port);
if (value) inputs[port.key] = value;
else complete = false;
}
visiting.delete(node.id);
if (!complete) return null;
const outputs = rules.emit(def, inputs, node);
emitted.set(node.id, outputs);
return outputs;
};
const input = (node: GraphNode, port: PortDef): T | null => {
const edge = edgesInto(graph, node.id, port.key)[0];
if (edge) {
const source = findNode(graph, edge.from.node);
const value = (source && emitNode(source))?.[edge.from.port];
if (!value) return null;
return rules.conform ? rules.conform(node, port, value) : value;
}
const def = nodeDef(node.type);
return rules.unfed(node, port, def && rules.isDef(def) ? def : undefined);
};
return {
problems,
problem,
input,
reached: (node) => emitted.has(node.id),
checkNames(nodes, nameOf, invalid) {
const names = new Set<string>();
for (const node of nodes) {
const name = nameOf(node);
const text = invalid(name);
if (text) problem(node, text);
else if (names.has(name)) problem(node, `Another property is named "${name}"`);
names.add(name);
}
},
firstProblem() {
const first = graph.nodes.find((n) => n.id in problems);
return first ? problems[first.id]! : null;
},
};
}
+38 -75
View File
@@ -1,5 +1,6 @@
import { edgesInto, findNode, type Graph, type GraphNode } from '../graph/Graph';
import { nodeDef, type PortDef } from '../graph/NodeRegistry';
import { graphEmitter } from '../graph/emitGraph';
import type { Graph } from '../graph/Graph';
import { nodeDef } from '../graph/NodeRegistry';
import type { FlatDef } from '../properties/PropertySchema';
import {
isParticlePropertyNode,
@@ -13,6 +14,7 @@ import {
PARTICLE_OUTPUTS,
PARTICLE_TIME,
type ParticleEnv,
type ParticleNodeDef,
type ParticleExpr,
type ParticleOutputKey,
} from './nodes';
@@ -44,79 +46,40 @@ export interface ParticlesBuild {
* particle. There is no interpreter and no eval – the compiler composes closures.
*/
export function compileParticles(graph: Graph): ParticlesBuild {
const problems: Record<string, string> = {};
const problem = (node: GraphNode, text: string) => {
if (!(node.id in problems)) problems[node.id] = text;
};
const names = new Set<string>();
for (const node of graph.nodes) {
if (!isParticlePropertyNode(node)) continue;
const name = propertyNameOf(node);
if (!isValidParticlePropertyName(name))
problem(node, 'Name must be one word: letters, digits, _ – and not one of the source’s own rows');
else if (names.has(name)) problem(node, `Another property is named "${name}"`);
names.add(name);
}
const params = particleParamDefs(graph);
const paramIndex = (name: string) => params.findIndex((row) => row.key === name);
const ops: ParticleExpr[] = [];
const emitted = new Map<string, Record<string, ParticleExpr>>();
const visiting = new Set<string>();
const emitNode = (node: GraphNode): Record<string, ParticleExpr> | null => {
const done = emitted.get(node.id);
if (done) return done;
if (visiting.has(node.id)) {
problem(node, 'Feeds itself');
return null;
}
const def = nodeDef(node.type);
if (!def || !isParticleNodeDef(def)) {
problem(node, 'Not a particle node');
return null;
}
visiting.add(node.id);
const inputs: Record<string, ParticleExpr> = {};
let complete = true;
for (const port of def.inputs) {
const expr = input(node, port);
if (expr) inputs[port.key] = expr;
else complete = false;
}
visiting.delete(node.id);
if (!complete) return null;
const outputs: Record<string, ParticleExpr> = {};
for (const [key, expr] of Object.entries(
def.emit(inputs, node.params, { nodeId: node.id, paramIndex }),
)) {
const slot = ops.length;
ops.push(expr);
outputs[key] = (_env, locals) => locals[slot]!;
}
emitted.set(node.id, outputs);
return outputs;
};
const walk = graphEmitter<ParticleNodeDef, ParticleExpr>(graph, {
isDef: isParticleNodeDef,
noun: 'particle',
emit: (def, inputs, node) => {
const outputs: Record<string, ParticleExpr> = {};
for (const [key, expr] of Object.entries(
def.emit(inputs, node.params, { nodeId: node.id, paramIndex }),
)) {
const slot = ops.length;
ops.push(expr);
outputs[key] = (_env, locals) => locals[slot]!;
}
return outputs;
},
unfed: (node, port, def) => {
if (port.field) {
const typed = Number(node.params[port.key] ?? port.default ?? 0);
const value = Number.isFinite(typed) ? typed : 0;
return () => value;
}
return def?.unfed?.[port.key] ?? (() => 0);
},
});
const { problems } = walk;
/** What feeds an input: the edge's source, the typed-in value, or what the node says (`unfed`). */
const input = (node: GraphNode, port: PortDef): ParticleExpr | null => {
const edge = edgesInto(graph, node.id, port.key)[0];
if (edge) {
const source = findNode(graph, edge.from.node);
const outputs = source && emitNode(source);
return outputs?.[edge.from.port] ?? null;
}
if (port.field) {
const typed = Number(node.params[port.key] ?? port.default ?? 0);
const value = Number.isFinite(typed) ? typed : 0;
return () => value;
}
const def = nodeDef(node.type);
const unfed = def && isParticleNodeDef(def) ? def.unfed?.[port.key] : undefined;
return unfed ?? (() => 0);
};
walk.checkNames(graph.nodes.filter(isParticlePropertyNode), propertyNameOf, (name) =>
isValidParticlePropertyName(name)
? ''
: 'Name must be one word: letters, digits, _ – and not one of the source’s own rows',
);
const output = graph.nodes.find((n) => n.type === PARTICLE_OUTPUT);
if (!output) return { compiled: null, problems, reason: 'No Output node' };
@@ -124,17 +87,17 @@ export function compileParticles(graph: Graph): ParticlesBuild {
const reads: ParticleExpr[] = [];
for (const key of PARTICLE_OUTPUTS) {
const port = outputDef.inputs.find((p) => p.key === key)!;
const expr = input(output, port);
const expr = walk.input(output, port);
if (!expr) break;
reads.push(expr);
}
const first = graph.nodes.find((n) => n.id in problems);
if (first || reads.length !== PARTICLE_OUTPUTS.length)
return { compiled: null, problems, reason: first ? problems[first.id]! : 'Cannot compile' };
const failed = walk.firstProblem();
if (failed !== null || reads.length !== PARTICLE_OUTPUTS.length)
return { compiled: null, problems, reason: failed ?? 'Cannot compile' };
const locals = new Float64Array(ops.length);
const time = graph.nodes.some((n) => n.type === PARTICLE_TIME && emitted.has(n.id));
const time = graph.nodes.some((n) => n.type === PARTICLE_TIME && walk.reached(n));
const compiled: CompiledParticles = {
params,
time,
+49 -87
View File
@@ -1,5 +1,6 @@
import { edgesInto, findNode, type Graph, type GraphNode } from '../graph/Graph';
import { nodeDef, type PortDef } from '../graph/NodeRegistry';
import { graphEmitter } from '../graph/emitGraph';
import { edgesInto, type Graph } from '../graph/Graph';
import { nodeDef } from '../graph/NodeRegistry';
import { colorOf } from '../model/Properties';
import { isSpriteProp, isValidFieldName } from '../properties/PropertySchema';
import { isPropertyNode, propertyName, shaderParamDefs } from '../properties/shaders';
@@ -14,6 +15,7 @@ import {
SHADER_OUTPUT,
SHADER_SAMPLE,
SHADER_TIME,
type ShaderNodeDef,
SHEET_EDGE,
STRAIGHT_FN,
TEXEL_ALPHA,
@@ -55,101 +57,61 @@ export interface ShaderBuild {
* arrives premultiplied, so the prelude unpremultiplies and the epilogue multiplies back.
*/
export function compileShader(graph: Graph): ShaderBuild {
const problems: Record<string, string> = {};
const problem = (node: GraphNode, text: string) => {
if (!(node.id in problems)) problems[node.id] = text;
};
const names = new Set<string>();
for (const node of graph.nodes) {
if (!isPropertyNode(node)) continue;
const name = propertyName(node);
if (!isValidFieldName(name)) problem(node, 'Name must be one word: letters, digits, _');
else if (isSpriteProp(name)) problem(node, `"${name}" is a sprite property`);
else if (names.has(name)) problem(node, `Another property is named "${name}"`);
names.add(name);
}
const lines: string[] = [];
const emitted = new Map<string, Record<string, Expr>>();
const visiting = new Set<string>();
let count = 0;
const emitNode = (node: GraphNode): Record<string, Expr> | null => {
const done = emitted.get(node.id);
if (done) return done;
if (visiting.has(node.id)) {
problem(node, 'Feeds itself');
return null;
}
const def = nodeDef(node.type);
if (!def || !isShaderNodeDef(def)) {
problem(node, 'Not a shader node');
return null;
}
visiting.add(node.id);
const inputs: Record<string, Expr> = {};
let complete = true;
for (const port of def.inputs) {
const expr = input(node, port);
if (expr) inputs[port.key] = expr;
else complete = false;
}
visiting.delete(node.id);
if (!complete) return null;
const outputs: Record<string, Expr> = {};
for (const [key, expr] of Object.entries(def.emit(inputs, node.params))) {
const local = `n${count++}_${key}`;
lines.push(`${expr.type} ${local} = ${expr.code};`);
outputs[key] = { code: local, type: expr.type };
}
emitted.set(node.id, outputs);
return outputs;
};
const walk = graphEmitter<ShaderNodeDef, Expr>(graph, {
isDef: isShaderNodeDef,
noun: 'shader',
emit: (def, inputs, node) => {
const outputs: Record<string, Expr> = {};
for (const [key, expr] of Object.entries(def.emit(inputs, node.params))) {
const local = `n${count++}_${key}`;
lines.push(`${expr.type} ${local} = ${expr.code};`);
outputs[key] = { code: local, type: expr.type };
}
return outputs;
},
// With nothing to type and nothing the node says, an input is the texel: its colour for a
// colour port, its alpha for a number.
unfed: (node, port, def) => {
const typed = node.params[port.key] ?? port.default;
if (port.field?.kind === 'color')
return { code: vec3Literal(colorOf(typed ?? 0xffffff, 0xffffff)), type: 'vec3' };
if (port.field) return { code: floatLiteral(Number(typed ?? 0)), type: 'float' };
return def?.unfed?.[port.key] ?? (port.type === 'number' ? TEXEL_ALPHA : TEXEL_COLOR);
},
conform: (node, port, expr) => {
if (port.type === 'number' && expr.type === 'vec3') {
walk.problem(node, `"${port.key}" needs a number, not a colour`);
return null;
}
if (port.type === 'vec3' && expr.type === 'float') return { code: asVec3(expr), type: 'vec3' };
return expr;
},
});
const { problems } = walk;
/**
* What feeds an input: the edge's source, the typed-in value, or – with nothing to type – what
* the node says (`unfed`), else the texel: its colour for a colour port, its alpha for a number.
*/
const input = (node: GraphNode, port: PortDef): Expr | null => {
const edge = edgesInto(graph, node.id, port.key)[0];
if (edge) {
const source = findNode(graph, edge.from.node);
const outputs = source && emitNode(source);
const expr = outputs?.[edge.from.port];
if (!expr) return null;
return conform(node, port, expr);
}
const typed = node.params[port.key] ?? port.default;
if (port.field?.kind === 'color')
return { code: vec3Literal(colorOf(typed ?? 0xffffff, 0xffffff)), type: 'vec3' };
if (port.field) return { code: floatLiteral(Number(typed ?? 0)), type: 'float' };
const def = nodeDef(node.type);
const unfed = def && isShaderNodeDef(def) ? def.unfed?.[port.key] : undefined;
return unfed ?? (port.type === 'number' ? TEXEL_ALPHA : TEXEL_COLOR);
};
const conform = (node: GraphNode, port: PortDef, expr: Expr): Expr | null => {
if (port.type === 'number' && expr.type === 'vec3') {
problem(node, `"${port.key}" needs a number, not a colour`);
return null;
}
if (port.type === 'vec3' && expr.type === 'float') return { code: asVec3(expr), type: 'vec3' };
return expr;
};
walk.checkNames(graph.nodes.filter(isPropertyNode), propertyName, (name) =>
!isValidFieldName(name)
? 'Name must be one word: letters, digits, _'
: isSpriteProp(name)
? `"${name}" is a sprite property`
: '',
);
const output = graph.nodes.find((n) => n.type === SHADER_OUTPUT);
if (!output) return { compiled: null, problems, reason: 'No Output node' };
const outputDef = nodeDef(SHADER_OUTPUT)!;
const color = input(output, outputDef.inputs[0]!);
const alpha = input(output, outputDef.inputs[1]!);
const color = walk.input(output, outputDef.inputs[0]!);
const alpha = walk.input(output, outputDef.inputs[1]!);
const first = graph.nodes.find((n) => n.id in problems);
if (first || !color || !alpha)
return { compiled: null, problems, reason: first ? problems[first.id]! : 'Cannot compile' };
const failed = walk.firstProblem();
if (failed !== null || !color || !alpha)
return { compiled: null, problems, reason: failed ?? 'Cannot compile' };
const uniforms = shaderParamDefs(graph).map((row) => ({ name: row.key, default: row.default }));
const time = graph.nodes.some((n) => n.type === SHADER_TIME && emitted.has(n.id));
const time = graph.nodes.some((n) => n.type === SHADER_TIME && walk.reached(n));
// The fragment runs in the sprite's own draw (#77). `vColor` is the tint, premultiplied like the
// texel; it goes in before the graph, so `Texture color` is the sprite as it draws without a shader.
// The version line is what makes Pixi compile the program as GLSL ES 3.00 (`GlProgram` looks for
@@ -179,7 +141,7 @@ export function compileShader(graph: Graph): ShaderBuild {
const shifted = graph.nodes.some(
(n) =>
n.type === SHADER_SAMPLE &&
emitted.has(n.id) &&
walk.reached(n) &&
(edgesInto(graph, n.id, 'u').length > 0 || edgesInto(graph, n.id, 'v').length > 0),
);
const edge = shifted ? edgeOf(output.params) : SHEET_EDGE;
+1 -1
View File
@@ -361,7 +361,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
canvasRect: () => editor.stateMachineEditor.canvas.canvasRect(),
center: () => centreOf(editor.stateMachineEditor.canvas),
snapping: () => editor.stateMachineEditor.canvas.snapEnabled,
/** Where a state's hidden pin sits. It is no longer grabbable – `portGrabbable` is the check. */
/** Where a state's hidden pin sits; it cannot be grabbed – `portGrabbable` is the check. */
portAt: (nodeId: string, port: string, isOutput: boolean) =>
editor.stateMachineEditor.canvas.portGlobal(nodeId, port, isOutput),
portGrabbable: (nodeId: string, port: string, isOutput: boolean) =>
+11 -8
View File
@@ -68,16 +68,22 @@ export interface Framing {
panY: number;
}
function bandBounds(band: Band): { minX: number; maxX: number; minY: number; maxY: number } {
return {
minX: Math.min(band.x1, band.x2),
maxX: Math.max(band.x1, band.x2),
minY: Math.min(band.y1, band.y2),
maxY: Math.max(band.y1, band.y2),
};
}
/**
* Ids of every box the band overlaps. Nodes are large rectangles, so – unlike the scene view,
* which tests an object's position – touching counts: a band drawn across a node that visibly
* caught it would otherwise leave it out.
*/
export function boxesInBand(boxes: readonly Box[], band: Band): string[] {
const minX = Math.min(band.x1, band.x2);
const maxX = Math.max(band.x1, band.x2);
const minY = Math.min(band.y1, band.y2);
const maxY = Math.max(band.y1, band.y2);
const { minX, maxX, minY, maxY } = bandBounds(band);
return boxes
.filter((b) => b.x <= maxX && b.x + b.w >= minX && b.y <= maxY && b.y + b.h >= minY)
.map((b) => b.id);
@@ -90,10 +96,7 @@ export function boxesInBand(boxes: readonly Box[], band: Band): string[] {
* Enclosing it is a deliberate gesture, and it is the one the eye reads as "this box, too".
*/
export function boxesWithinBand(boxes: readonly Box[], band: Band): string[] {
const minX = Math.min(band.x1, band.x2);
const maxX = Math.max(band.x1, band.x2);
const minY = Math.min(band.y1, band.y2);
const maxY = Math.max(band.y1, band.y2);
const { minX, maxX, minY, maxY } = bandBounds(band);
return boxes
.filter((b) => b.x >= minX && b.x + b.w <= maxX && b.y >= minY && b.y + b.h <= maxY)
.map((b) => b.id);
+24 -25
View File
@@ -1143,6 +1143,21 @@ export class GraphCanvas extends Widget {
}
}
private launchBeads(assetId: string, trace: Trace | null): void {
if (!trace || assetId !== this.beadAsset) {
this.beadTimes.clear();
this.beadFired.clear();
this.beadAsset = assetId;
}
for (const [id, time] of trace?.edges ?? []) {
if (this.beadFired.get(id) === time) continue;
this.beadFired.set(id, time);
const times = this.beadTimes.get(id) ?? [];
this.beadTimes.set(id, times);
launchBead(times, time);
}
}
/** A port's centre in graph coordinates. */
private anchorPoint(side: PortSide | null): { x: number; y: number } | null {
if (!side) return null;
@@ -1170,9 +1185,7 @@ export class GraphCanvas extends Widget {
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 };
return view && p ? globalPoint(view, p) : null;
}
/** The port a running edge drag would connect to, or null when the drop would do nothing. */
@@ -1222,8 +1235,7 @@ export class GraphCanvas extends Widget {
.drawnMarks()
.find((m) => m.edgeIds.includes(edgeId) && m.isOutput === isOutput);
if (!mark) return null;
const g = this.canvas.toGlobal({ x: mark.x, y: mark.y });
return { x: g.x, y: g.y, links: mark.edgeIds.length, label: mark.text };
return { ...globalPoint(this.canvas, mark), links: mark.edgeIds.length, label: mark.text };
}
nodeRect(nodeId: string): Rect | null {
@@ -1272,21 +1284,6 @@ export class GraphCanvas extends Widget {
return this.views.get(nodeId)?.inputLines ?? null;
}
private launchBeads(assetId: string, trace: Trace | null): void {
if (!trace || assetId !== this.beadAsset) {
this.beadTimes.clear();
this.beadFired.clear();
this.beadAsset = assetId;
}
for (const [id, time] of trace?.edges ?? []) {
if (this.beadFired.get(id) === time) continue;
this.beadFired.set(id, time);
const times = this.beadTimes.get(id) ?? [];
this.beadTimes.set(id, times);
launchBead(times, time);
}
}
beadsDrawn(): readonly { edgeId: string; progress: number; x: number; y: number }[] {
return this.edgeLayer.drawnBeads();
}
@@ -1298,9 +1295,7 @@ export class GraphCanvas extends Widget {
/** 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 };
return view ? globalPoint(view, { x: view.w / 2, y: TITLE_HEIGHT / 2 }) : null;
}
/** Every group and comment as it is drawn: the box, the text on it, and both its colours. */
@@ -1334,8 +1329,7 @@ export class GraphCanvas extends Widget {
const local = corner
? { x: view.w - RESIZE_HANDLE / 2, y: view.h - RESIZE_HANDLE / 2 }
: { x: view.w / 2, y: ANNOTATION_TITLE_HEIGHT / 2 };
const g = view.toGlobal(local);
return { x: g.x, y: g.y };
return globalPoint(view, local);
}
/** The rectangle a box is drawn as, on screen – what a fit has to bring into view. */
@@ -1352,6 +1346,11 @@ export class GraphCanvas extends Widget {
type Rect = { x: number; y: number; w: number; h: number };
function globalPoint(target: Container, local: { x: number; y: number }): { x: number; y: number } {
const g = target.toGlobal({ x: local.x, y: local.y });
return { x: g.x, y: g.y };
}
function globalRect(target: Container, w: number, h: number): Rect {
const topLeft = target.toGlobal({ x: 0, y: 0 });
const bottomRight = target.toGlobal({ x: w, y: h });
+9 -13
View File
@@ -175,12 +175,7 @@ function buildPickedPortSection(
for (const port of ports) {
const toggle = writeToggle(host, assetId, node, port);
if (custom) {
const name = new TextField(port.key, {
width: NAME_WIDTH,
onChange: (next) =>
host.state.execute(new RenamePortCommand(assetId, node.id, port.key, next.trim())),
});
portRow(host, assetId, node, port, name, toggle);
portRow(host, assetId, node, port, portNameField(host, assetId, node, port), toggle);
continue;
}
const def = choices.find((d) => d.key === port.key);
@@ -236,13 +231,14 @@ export function buildPortSection(
: 'No outputs yet. Every output added here becomes a result of running this behavior.',
);
for (const port of ports) {
const name = new TextField(port.key, {
width: NAME_WIDTH,
onChange: (next) => host.state.execute(new RenamePortCommand(assetId, node.id, port.key, next.trim())),
});
portRow(host, assetId, node, port, name);
}
for (const port of ports) portRow(host, assetId, node, port, portNameField(host, assetId, node, port));
}
function portNameField(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): TextField {
return new TextField(port.key, {
width: NAME_WIDTH,
onChange: (next) => host.state.execute(new RenamePortCommand(assetId, node.id, port.key, next.trim())),
});
}
function writeToggle(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): Widget[] {
+2 -2
View File
@@ -28,8 +28,8 @@ export function screenMargin(screenHeight: number): number {
/**
* Where a popup of `height` ends up when it was asked to open at `wanted`. The margin has to be
* enforced here, not only in `roomFor`: a popup anchored low is *shifted* up to fit, and shifting
* it against the raw canvas height parks it on the bottom edge – which is why the gap used to be
* visible above and missing below.
* it against the raw canvas height would park it on the bottom edge, with the gap above it and none
* below.
*
* Downwards only, though. Above the popup the margin is what the height cap spends, not a distance
* to keep: a menu hanging off a button near the top of the screen belongs under its button, and
+52
View File
@@ -0,0 +1,52 @@
/**
* Uploaded files the view draws with, keyed by file name. Nothing is evicted: file names are
* content addressed, so a loaded file can never go stale. A miss is not an error – the subclass
* starts the load and reports "not yet", and `onChange` fires when the file lands.
*/
export abstract class FileLibrary<T> {
private base: string;
private readonly loading = new Set<string>();
/** Never retried, so one broken reference cannot spam the network. */
private readonly failed = new Set<string>();
private readonly listeners = new Set<() => void>();
constructor(
base: string,
private readonly tag: string,
) {
this.base = base;
}
/** Where the files are served from. Set once at start-up, before anything draws. */
configure(base: string): void {
this.base = base.replace(/\/+$/, '');
}
/** Runs `fn` whenever a file has arrived; returns the unsubscribe. */
onChange(fn: () => void): () => void {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
protected abstract fetchFile(url: string, file: string): Promise<T>;
/** Keeps what arrived; the listeners run right after. */
protected abstract keep(file: string, loaded: T): void;
protected startLoading(file: string): void {
if (this.loading.has(file) || this.failed.has(file)) return;
this.loading.add(file);
void this.fetchFile(`${this.base}/${file}`, file).then(
(loaded) => {
this.loading.delete(file);
this.keep(file, loaded);
for (const listener of this.listeners) listener();
},
(error: unknown) => {
this.loading.delete(file);
this.failed.add(file);
console.error(`[${this.tag}] cannot load ${file}`, error);
},
);
}
}
+13 -38
View File
@@ -1,24 +1,15 @@
import { fontFamilyOf } from '../core/model/font';
const DEFAULT_BASE = '/game-maker/data/fonts';
import { FileLibrary } from './FileLibrary';
/**
* Registers uploaded typefaces with the browser, keyed by file name – the image library's twin.
* Nothing is evicted: file names are content addressed, so a face can never go stale. A miss is
* not an error; it starts the load and reports the fallback, and the redraw comes when the face
* lands. Text therefore appears in the default typeface for a frame or two and then reflows.
* Registers uploaded typefaces with the browser. Text appears in the default typeface for a frame
* or two and then reflows when the face lands.
*/
export class FontLibrary {
private base = DEFAULT_BASE;
export class FontLibrary extends FileLibrary<FontFace> {
private readonly ready = new Set<string>();
private readonly loading = new Set<string>();
/** Never retried, so one broken reference cannot spam the network. */
private readonly failed = new Set<string>();
private readonly listeners = new Set<() => void>();
/** Where the files are served from. Set once at start-up, before anything draws. */
configure(base: string): void {
this.base = base.replace(/\/+$/, '');
constructor() {
super('/game-maker/data/fonts', 'fonts');
}
/**
@@ -32,31 +23,15 @@ export class FontLibrary {
return '';
}
/** Runs `fn` whenever a typeface has arrived; returns the unsubscribe. */
onChange(fn: () => void): () => void {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
// `FontFace` rather than a stylesheet rule: it reports when the bytes are actually usable,
// and a family name is asked for by the same content-addressed key that fetched them.
protected fetchFile(url: string, file: string): Promise<FontFace> {
return new FontFace(fontFamilyOf(file), `url("${url}")`).load();
}
private startLoading(file: string): void {
if (this.loading.has(file) || this.failed.has(file)) return;
this.loading.add(file);
// `FontFace` rather than a stylesheet rule: it reports when the bytes are actually usable,
// and a family name is asked for by the same content-addressed key that fetched them.
const face = new FontFace(fontFamilyOf(file), `url("${this.base}/${file}")`);
void face.load().then(
(loaded) => {
this.loading.delete(file);
document.fonts.add(loaded);
this.ready.add(file);
for (const listener of this.listeners) listener();
},
(error: unknown) => {
this.loading.delete(file);
this.failed.add(file);
console.error(`[fonts] cannot load ${file}`, error);
},
);
protected keep(file: string, loaded: FontFace): void {
document.fonts.add(loaded);
this.ready.add(file);
}
}
+13 -39
View File
@@ -1,8 +1,7 @@
import { Assets, CanvasSource, Texture } from 'pixi.js';
import type { FrameRect, TextureRef } from '../core/model/texture';
import { type EdgeMode, SHEET_EDGE } from '../core/shader/nodes';
const DEFAULT_BASE = '/game-maker/data/textures';
import { FileLibrary } from './FileLibrary';
/**
* Pixi turns `clamp-to-edge` into `repeat` on every texture a Graphics fill draws with
@@ -22,24 +21,14 @@ const ADDRESS_MODE: Record<EdgeMode, 'repeat' | 'mirror-repeat' | 'clamp-to-edge
clamp: 'clamp-to-edge',
};
/**
* Keeps uploaded images on the GPU, keyed by file name. Nothing is evicted: file names are
* content addressed, so a cached texture can never go stale. A miss is not an error – it starts
* the load and reports "not yet", so callers never await and get a redraw when the picture lands.
*/
export class TextureLibrary {
private base = DEFAULT_BASE;
/** Keeps uploaded images on the GPU; callers never await and get a redraw when the picture lands. */
export class TextureLibrary extends FileLibrary<Texture> {
private readonly loaded = new Map<string, Texture>();
/** Frames cut from their sheets, keyed by file and rectangle; kept like the files. */
private readonly crops = new Map<string, Texture>();
private readonly loading = new Set<string>();
/** Never retried, so one broken reference cannot spam the network. */
private readonly failed = new Set<string>();
private readonly listeners = new Set<() => void>();
/** Where the files are served from. Set once at start-up, before anything draws. */
configure(base: string): void {
this.base = base.replace(/\/+$/, '');
constructor() {
super('/game-maker/data/textures', 'textures');
}
/**
@@ -83,12 +72,6 @@ export class TextureLibrary {
return this.loaded.get(file) ?? null;
}
/** Runs `fn` whenever a picture has arrived; returns the unsubscribe. */
onChange(fn: () => void): () => void {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
/**
* A frame as a texture of its own, cut the first time it is asked for. Drawn from the sheet
* through a fill matrix it showed a row of the neighbouring cell along any edge that fell
@@ -110,23 +93,14 @@ export class TextureLibrary {
return texture;
}
private startLoading(file: string): void {
if (this.loading.has(file) || this.failed.has(file)) return;
this.loading.add(file);
void Assets.load<Texture>(`${this.base}/${file}`).then(
(texture) => {
this.loading.delete(file);
texture.source.style.addressMode = EDGE_MODE;
texture.source.style.update();
this.loaded.set(file, texture);
for (const listener of this.listeners) listener();
},
(error: unknown) => {
this.loading.delete(file);
this.failed.add(file);
console.error(`[textures] cannot load ${file}`, error);
},
);
protected fetchFile(url: string): Promise<Texture> {
return Assets.load<Texture>(url);
}
protected keep(file: string, texture: Texture): void {
texture.source.style.addressMode = EDGE_MODE;
texture.source.style.update();
this.loaded.set(file, texture);
}
}