Files
feuerwerk/src/core/particles/compile.ts
T
adminandClaude Fable 5.1 cc6db5f350
game-maker / gate (push) Successful in 1m33s
game-maker / suite (push) Successful in 5m46s
Three stores, three importers and three pickers that differ only in their type are one of each (#85)
Refactoring round one: HttpFontStore/HttpSoundStore/HttpTextureStore collapse into a generic
HttpFileStore over a FileStore contract; fontImport/soundImport/textureImport share assetImport
with an ImportKind per sort; ImagePicker/FontPicker/SoundPicker sit on FilePicker and describe
only their row. The group/comment drag logic leaves GraphCanvas for annotationDrag.ts (with its
own unit test), declaredRows.ts replaces the duplicated row build in particles.ts and shaders.ts,
and dead code in font/sound/compile/emission/transform/actions is gone. README: asset list,
check count 958, the two-job CI, the Scenes wiki page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:38:54 +02:00

154 lines
5.7 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 { edgesInto, findNode, type Graph, type GraphNode } from '../graph/Graph';
import { nodeDef, type PortDef } from '../graph/NodeRegistry';
import type { FlatDef } from '../properties/PropertySchema';
import {
isParticlePropertyNode,
isValidParticlePropertyName,
particleParamDefs,
propertyNameOf,
} from '../properties/particles';
import {
isParticleNodeDef,
PARTICLE_OUTPUT,
PARTICLE_OUTPUTS,
PARTICLE_TIME,
type ParticleEnv,
type ParticleExpr,
type ParticleOutputKey,
} from './nodes';
/** What one particle is drawn with, everything relative to its source. */
export type ParticleOut = Record<ParticleOutputKey, number>;
export interface CompiledParticles {
/** The rows the graph adds to every source placed from this asset, in node order. */
params: FlatDef<'number'>[];
/** A Time node reaches the Output: the whole field moves, not just the particles in it. */
time: boolean;
/** Fills `out` with the seven values for this particle. */
evaluate(env: ParticleEnv, out: ParticleOut): void;
}
export interface ParticlesBuild {
/** Null while the graph has a problem – the source then emits nothing. */
compiled: CompiledParticles | null;
/** Per node id, what keeps the graph from compiling. */
problems: Record<string, string>;
/** One line for the tab: empty once compiled. */
reason: string;
}
/**
* A particle graph to one function per output: every node reachable from the Output becomes one
* local per output, in dependency order, so a node feeding two others is evaluated once per
* 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;
};
/** 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);
};
const output = graph.nodes.find((n) => n.type === PARTICLE_OUTPUT);
if (!output) return { compiled: null, problems, reason: 'No Output node' };
const outputDef = nodeDef(PARTICLE_OUTPUT)!;
const reads: ParticleExpr[] = [];
for (const key of PARTICLE_OUTPUTS) {
const port = outputDef.inputs.find((p) => p.key === key)!;
const expr = 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 locals = new Float64Array(ops.length);
const time = graph.nodes.some((n) => n.type === PARTICLE_TIME && emitted.has(n.id));
const compiled: CompiledParticles = {
params,
time,
evaluate(env, out) {
// In dependency order, so every local a later op reads is already filled.
for (let i = 0; i < ops.length; i++) locals[i] = ops[i]!(env, locals);
for (let i = 0; i < PARTICLE_OUTPUTS.length; i++) out[PARTICLE_OUTPUTS[i]!] = reads[i]!(env, locals);
},
};
return { compiled, problems, reason: '' };
}
/** A particle whose graph says nothing: at the source, full size, the source's own colour. */
export function emptyParticleOut(): ParticleOut {
return { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, color: 0xffffff, alpha: 1 };
}