diff --git a/README.md b/README.md index 57d8e36d..fac327fc 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 790 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 809 checks (GM_STEPS=20 for one step) node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index 82121ffa..6cd66e3e 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -41,6 +41,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset { return { ...base, type: 'shader', graph: createEmptyGraph() }; case 'font': return { ...base, type: 'font', file: '' }; + case 'particles': + return { ...base, type: 'particles', graph: createEmptyGraph() }; } } diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index a1f88519..89d5e3b1 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -26,6 +26,11 @@ export interface EntityHandle { z: number; props: PropertyBag; parent: EntityHandle | null; + /** + * Counts the Emit pulses a particle source has been given. Kept off `props` for the reason the + * animation clock is: what the world writes back to the model must not grow an override per frame. + */ + emits?: number; } /** Position, rotation, scale and mirror of every ancestor applied, nearest last. */ diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index 02111f32..79d7f303 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -19,10 +19,11 @@ export interface PortDef { } /** - * `state` and `shader` are the categories no behavior graph offers: their nodes are the states of - * a state machine and the GLSL of a shader asset. + * `state`, `shader` and `particles` are the categories no behavior graph offers: their nodes are the + * states of a state machine, the GLSL of a shader asset and the motion of one particle. */ -export type NodeCategory = 'event' | 'flow' | 'data' | 'variable' | 'action' | 'state' | 'shader'; +export type NodeCategory = + 'event' | 'flow' | 'data' | 'variable' | 'action' | 'state' | 'shader' | 'particles'; /** * How a loop node asks for one of its exec outputs to be run repeatedly: `port` carries the body, diff --git a/src/core/graph/nodes/action/Emit.ts b/src/core/graph/nodes/action/Emit.ts new file mode 100644 index 00000000..653ca6f5 --- /dev/null +++ b/src/core/graph/nodes/action/Emit.ts @@ -0,0 +1,19 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asEntity } from '../../values'; +import { exec, obj } from '../shared'; + +export const Emit: NodeDef = { + type: 'Emit', + label: 'Emit', + category: 'action', + description: + 'Starts a particle source over: a Burst source throws another burst, a Continuous one begins its stream again at the first particle.', + inputs: [exec(), obj('source')], + outputs: [exec()], + params: [], + run: (_ctx, inputs) => { + const target = asEntity(inputs.source); + if (target) target.emits = (target.emits ?? 0) + 1; + return { next: 'exec' }; + }, +}; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 289be162..dd7dc973 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -1,5 +1,6 @@ import { type NodeDef, registerNode } from '../NodeRegistry'; import { Destroy } from './action/Destroy'; +import { Emit } from './action/Emit'; import { Log } from './action/Log'; import { Move } from './action/Move'; import { RestartGame } from './action/RestartGame'; @@ -88,6 +89,7 @@ import { RunBehavior } from './flow/RunBehavior'; import { Sequence } from './flow/Sequence'; import { SetState } from './flow/SetState'; import { StateBranch } from './flow/StateBranch'; +import { PARTICLE_NODES } from '../../particles/nodes'; import { SHADER_NODES } from '../../shader/nodes'; import { State } from '../stateMachine'; import { Variables } from './variable/Variables'; @@ -182,13 +184,15 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ SetPosition, Spawn, Destroy, + Emit, Log, StopGame, RestartGame, ]; for (const def of BUILT_IN_NODES) registerNode(def); -// Registered so a machine's or a shader's graph loads and draws, but not built in: no behavior -// graph offers them. +// Registered so a machine's, a shader's or a particle graph loads and draws, but not built in: no +// behavior graph offers them. registerNode(State); for (const def of SHADER_NODES) registerNode(def); +for (const def of PARTICLE_NODES) registerNode(def); diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index 78d26474..864ef665 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -110,6 +110,16 @@ export interface FontAsset extends AssetBase { file: string; } +/** + * The graph is the motion of one particle: every output of its `Output` node is a function of the + * particle's age and its own random number (see `core/particles/compile.ts`). A placed object is + * the source that emits them; how many and for how long are its properties. + */ +export interface ParticlesAsset extends AssetBase { + type: 'particles'; + graph: Graph; +} + export type Asset = | SpriteAsset | CameraAsset @@ -122,13 +132,16 @@ export type Asset = | AnimationAsset | StateMachineAsset | ShaderAsset - | FontAsset; + | FontAsset + | ParticlesAsset; /** The assets edited as a node graph – what the graph canvas and the graph commands work on. */ -export type GraphAsset = BehaviorAsset | StateMachineAsset | ShaderAsset; +export type GraphAsset = BehaviorAsset | StateMachineAsset | ShaderAsset | ParticlesAsset; + +const GRAPH_TYPES: ReadonlySet = new Set(['behavior', 'statemachine', 'shader', 'particles']); export function isGraphAsset(asset: Asset): asset is GraphAsset { - return asset.type === 'behavior' || asset.type === 'statemachine' || asset.type === 'shader'; + return GRAPH_TYPES.has(asset.type); } export function isSceneAsset(asset: Asset): asset is SceneAsset { diff --git a/src/core/model/AssetTypes.ts b/src/core/model/AssetTypes.ts index 63289d3a..3cb3333a 100644 --- a/src/core/model/AssetTypes.ts +++ b/src/core/model/AssetTypes.ts @@ -11,6 +11,7 @@ export const ASSET_TYPES = [ 'statemachine', 'shader', 'font', + 'particles', ] as const; export type AssetType = (typeof ASSET_TYPES)[number]; @@ -28,6 +29,7 @@ export const ASSET_TYPE_LABELS: Record = { statemachine: 'State machine', shader: 'Shader', font: 'Font', + particles: 'Particles', }; /** diff --git a/src/core/particles/compile.ts b/src/core/particles/compile.ts new file mode 100644 index 00000000..8bd241c2 --- /dev/null +++ b/src/core/particles/compile.ts @@ -0,0 +1,165 @@ +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; + +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; + /** One line for the tab: empty once compiled. */ + reason: string; +} + +/** + * What the motion depends on and nothing else: node ids, types, params and declared ports, and + * which port feeds which. Positions and folding are the editor's, so a moved node does not compile + * again – the same rule the shader fingerprint follows (#65). + */ +export function particlesFingerprint(graph: Graph): string { + return JSON.stringify({ + nodes: graph.nodes.map((n) => [n.id, n.type, n.params, n.ports ?? null]), + edges: graph.edges.map((e) => [e.from.node, e.from.port, e.to.node, e.to.port]), + }); +} + +/** + * 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 = {}; + const problem = (node: GraphNode, text: string) => { + if (!(node.id in problems)) problems[node.id] = text; + }; + + const names = new Set(); + 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>(); + const visiting = new Set(); + + const emitNode = (node: GraphNode): Record | 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 = {}; + 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 = {}; + 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 }; +} diff --git a/src/core/particles/emission.ts b/src/core/particles/emission.ts new file mode 100644 index 00000000..3098eae6 --- /dev/null +++ b/src/core/particles/emission.ts @@ -0,0 +1,58 @@ +import type { EmitMode } from '../properties/particles'; + +/** What the source's own rows say about how many particles there are and for how long. */ +export interface EmitterSettings { + mode: EmitMode; + /** Continuous: particles per second. */ + rate: number; + /** Burst: how many go out at once. */ + count: number; + /** Seconds one particle lives. */ + lifetime: number; + /** The most that may be alive at once; the oldest are the ones dropped. */ + max: number; +} + +/** Which particles are alive, as a range of indices – `to < from` is none. Both ends inclusive. */ +export interface LiveRange { + from: number; + to: number; +} + +export const NO_PARTICLES: LiveRange = { from: 0, to: -1 }; + +/** + * When particle `index` was born, in seconds since the source started. A continuous source emits on + * a fixed grid – particle n is born at n/rate – which is what makes the whole field a function of + * time rather than of how many frames have gone by. A burst is every particle at once. + */ +export function birthTime(settings: EmitterSettings, index: number): number { + if (settings.mode === 'burst') return 0; + return settings.rate > 0 ? index / settings.rate : 0; +} + +/** + * The particles alive at `time`. `emitUntil` is how far the source was still emitting: switched off, + * it stops taking in new ones while the ones in flight live out their lives. + */ +export function liveRange(settings: EmitterSettings, time: number, emitUntil = time): LiveRange { + const { lifetime, max } = settings; + if (lifetime <= 0 || max < 1 || time < 0) return NO_PARTICLES; + if (settings.mode === 'burst') { + const count = Math.min(Math.floor(settings.count), Math.floor(max)); + if (count < 1 || time >= lifetime || emitUntil < 0) return NO_PARTICLES; + return { from: 0, to: count - 1 }; + } + const rate = settings.rate; + if (rate <= 0) return NO_PARTICLES; + const newest = Math.floor(Math.min(time, emitUntil) * rate); + if (newest < 0) return NO_PARTICLES; + // Born no earlier than one lifetime ago: `n/rate > time - lifetime`. + const oldest = Math.max(0, Math.floor((time - lifetime) * rate) + 1); + if (oldest > newest) return NO_PARTICLES; + return { from: Math.max(oldest, newest - Math.floor(max) + 1), to: newest }; +} + +export function liveCount(range: LiveRange): number { + return Math.max(0, range.to - range.from + 1); +} diff --git a/src/core/particles/nodes.ts b/src/core/particles/nodes.ts new file mode 100644 index 00000000..70514fff --- /dev/null +++ b/src/core/particles/nodes.ts @@ -0,0 +1,523 @@ +import type { NodeDef, PortDef } from '../graph/NodeRegistry'; +import { type PropertyBag, textOf } from '../model/Properties'; +import { flatDef } from '../properties/PropertySchema'; +import { + PARTICLE_PROPERTY, + PROPERTY_DEFAULT_PARAM, + PROPERTY_MAX_PARAM, + PROPERTY_MIN_PARAM, + PROPERTY_NAME_PARAM, + propertyNameOf, +} from '../properties/particles'; + +/** What one particle is, at one moment: everything a graph may read about it. */ +export interface ParticleEnv { + /** 0 at birth, 1 at the end of its lifetime. */ + age: number; + /** The same, in seconds – what the formulas of a fall are written in. */ + ageSeconds: number; + /** Seconds since the source started emitting; the same for every particle alive. */ + time: number; + /** Which particle this is, counting up from the first the source ever emitted. */ + index: number; + /** Fixed for the particle's whole life: what makes two of them differ. */ + seed: number; + /** The source's own Color row, which is what an unconnected colour output reads. */ + color: number; + /** The values of the graph's Property nodes, in the order the compiler declared them. */ + params: readonly number[]; +} + +/** + * One output of one node, as a function of the particle. `locals` holds the outputs already + * computed this evaluation, so a node feeding two others is evaluated once (see `compile.ts`). + */ +export type ParticleExpr = (env: ParticleEnv, locals: Float64Array) => number; + +/** What a node may need beyond its inputs: which node it is, and where its parameter's value lands. */ +export interface ParticleEmitContext { + nodeId: string; + /** The slot in `env.params` this graph's parameter of that name was given. */ + paramIndex(name: string): number; +} + +/** + * A node of a particle graph. It never runs as a behavior does: `emit` builds the function each of + * its outputs is. Every value is a number – a colour is a packed 0xRRGGBB one, as everywhere else + * in the project. + */ +export interface ParticleNodeDef extends NodeDef { + category: 'particles'; + emit( + inputs: Record, + params: PropertyBag, + ctx: ParticleEmitContext, + ): Record; + /** What an input with nothing to type on reads while unconnected; without it, zero. */ + unfed?: Record; +} + +export function isParticleNodeDef(def: NodeDef): def is ParticleNodeDef { + return def.category === 'particles' && 'emit' in def; +} + +export const PARTICLE_OUTPUT = 'ParticleOutput'; +export const PARTICLE_RANDOM = 'ParticleRandom'; +export const PARTICLE_TIME = 'ParticleTime'; + +/** The seven things a particle is drawn with – every output of the Output node. */ +export const PARTICLE_OUTPUTS = ['x', 'y', 'scaleX', 'scaleY', 'rotation', 'color', 'alpha'] as const; + +export type ParticleOutputKey = (typeof PARTICLE_OUTPUTS)[number]; + +const DEG = Math.PI / 180; +const NUMBER_STEP = 0.1; + +/** + * A particle's own random number, 0..1: fixed for its whole life, and different per node, so the + * direction and the speed of one particle are not the same draw. Mixing `salt` in is what separates + * the nodes; the node's id is hashed into it, so an edit elsewhere in the graph does not reshuffle it. + */ +export function random01(seed: number, salt: number): number { + let h = (seed ^ salt) >>> 0; + h = Math.imul(h ^ (h >>> 16), 0x45d9f3b) >>> 0; + h = Math.imul(h ^ (h >>> 16), 0x45d9f3b) >>> 0; + h = (h ^ (h >>> 16)) >>> 0; + return h / 0x100000000; +} + +export function hashId(id: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < id.length; i++) h = Math.imul(h ^ id.charCodeAt(i), 0x01000193) >>> 0; + return h >>> 0; +} + +const numberOut = (key: string): PortDef => ({ key, label: key, type: 'number' }); + +/** A number input with an inline value, so a constant needs no node of its own. */ +const numberIn = (key: string, value: number, step = NUMBER_STEP): PortDef => ({ + key, + label: key, + type: 'number', + default: value, + field: { key, label: key, kind: 'number', default: value, step }, +}); + +/** A number input carrying a colour: the field is a swatch, the value a packed 0xRRGGBB. */ +const colorIn = (key: string, value = 0xffffff): PortDef => ({ + key, + label: key, + type: 'number', + default: value, + field: flatDef(key, 'color', { label: key, value }), +}); + +const constant = + (value: number): ParticleExpr => + () => + value; + +function source(type: string, label: string, key: string, description: string, read: ParticleExpr) { + const def: ParticleNodeDef = { + type, + label, + category: 'particles', + description, + inputs: [], + outputs: [numberOut(key)], + params: [], + emit: () => ({ [key]: read }), + }; + return def; +} + +export const ParticleAge = source( + 'ParticleAge', + 'Age', + 'age', + 'How far through its life this particle is: 0 when it is born, 1 when it dies.', + (env) => env.age, +); + +export const ParticleAgeSeconds = source( + 'ParticleAgeSeconds', + 'Age in seconds', + 'seconds', + 'How long this particle has been alive, in seconds – the t of a formula of motion.', + (env) => env.ageSeconds, +); + +export const ParticleTimeNode = source( + PARTICLE_TIME, + 'Time', + 'seconds', + 'Seconds since the source started emitting. The same for every particle alive, so it drifts them together.', + (env) => env.time, +); + +export const ParticleIndex = source( + 'ParticleIndex', + 'Index', + 'index', + 'Which particle this is, counting up from the first one the source emitted: a fan or a ring instead of a scatter.', + (env) => env.index, +); + +/** Two Randoms give one particle two different draws – that is what the node id in the salt is for. */ +export const ParticleRandom: ParticleNodeDef = { + type: PARTICLE_RANDOM, + label: 'Random', + category: 'particles', + description: + 'A number drawn once per particle and kept for its whole life. Two Random nodes draw differently, so direction and speed can scatter apart.', + inputs: [numberIn('min', 0), numberIn('max', 1)], + outputs: [numberOut('value')], + params: [], + emit: ({ min, max }, _params, ctx) => { + const salt = hashId(ctx.nodeId); + return { + value: (env, locals) => { + const lo = min!(env, locals); + return lo + random01(env.seed, salt) * (max!(env, locals) - lo); + }, + }; + }, +}; + +/** + * The offset gravity has added by now: ½·g·t². Written as a node because a formula, unlike a + * simulation, has to say the whole fall rather than the force behind it. + */ +export const ParticleGravity: ParticleNodeDef = { + type: 'ParticleGravity', + label: 'Gravity', + category: 'particles', + description: + 'How far a steady pull has moved the particle by now, down being positive: ½ × strength × age². Add it to the offset.', + inputs: [numberIn('strength', 800, 10)], + outputs: [numberOut('x'), numberOut('y')], + params: [], + emit: ({ strength }) => ({ + x: constant(0), + y: (env, locals) => 0.5 * strength!(env, locals) * env.ageSeconds * env.ageSeconds, + }), +}; + +/** The offset a constant speed has added by now: v₀·t, aimed by an angle in degrees. */ +export const ParticleStartVelocity: ParticleNodeDef = { + type: 'ParticleStartVelocity', + label: 'Start velocity', + category: 'particles', + description: + 'How far the particle has flown at a steady speed: angle in degrees (0 is right, 90 is down), speed in pixels per second.', + inputs: [numberIn('angle', 0, 1), numberIn('speed', 200, 10)], + outputs: [numberOut('x'), numberOut('y')], + params: [], + emit: ({ angle, speed }) => ({ + x: (env, locals) => Math.cos(angle!(env, locals) * DEG) * speed!(env, locals) * env.ageSeconds, + y: (env, locals) => Math.sin(angle!(env, locals) * DEG) * speed!(env, locals) * env.ageSeconds, + }), +}; + +export const ParticleNumber: ParticleNodeDef = { + type: 'ParticleNumber', + label: 'Number', + category: 'particles', + description: 'A constant number.', + inputs: [], + outputs: [numberOut('value')], + params: [{ key: 'value', label: 'Value', kind: 'number', default: 0, step: NUMBER_STEP }], + emit: (_inputs, params) => ({ value: constant(Number(params.value ?? 0) || 0) }), +}; + +export const ParticleColor: ParticleNodeDef = { + type: 'ParticleColor', + label: 'Color', + category: 'particles', + description: 'A constant colour.', + inputs: [], + outputs: [numberOut('color')], + params: [flatDef('value', 'color', { label: 'Value', value: 0xffffff })], + emit: (_inputs, params) => ({ color: constant(Number(params.value ?? 0xffffff) || 0) }), +}; + +/** `name` is a row on every source placed from this asset, and a value here. */ +export const ParticleProperty: ParticleNodeDef = { + type: PARTICLE_PROPERTY, + label: 'Property', + category: 'particles', + description: 'A number the source sets: a property row on the placed source, and a value here.', + inputs: [], + outputs: [numberOut('value')], + params: [ + flatDef(PROPERTY_NAME_PARAM, 'text', { label: 'Name', value: 'value' }), + { key: PROPERTY_DEFAULT_PARAM, label: 'Default', kind: 'number', default: 0, step: NUMBER_STEP }, + { key: PROPERTY_MIN_PARAM, label: 'Min', kind: 'number', default: 0, step: NUMBER_STEP }, + { key: PROPERTY_MAX_PARAM, label: 'Max', kind: 'number', default: 1, step: NUMBER_STEP }, + ], + derivePorts: ({ node }) => ({ label: propertyNameOf(node) || ParticleProperty.label }), + emit: (_inputs, params, ctx) => { + // The slot is bound once, here: reading it per particle would cost a lookup by name each time. + const slot = ctx.paramIndex(paramNameOf(params)); + return { value: slot < 0 ? constant(0) : (env) => env.params[slot] ?? 0 }; + }, +}; + +/** + * Where the particle is, how big, how turned and what colour – all of it relative to the source. + * An unconnected input leaves that part of the particle as it would be without a graph. + */ +export const ParticleOutput: ParticleNodeDef = { + type: PARTICLE_OUTPUT, + label: 'Output', + category: 'particles', + description: + 'What the particle looks like: x and y offset it from the source, the scales multiply its size, rotation turns it, colour tints it and alpha fades it.', + inputs: [ + { key: 'x', label: 'x', type: 'number', inline: false }, + { key: 'y', label: 'y', type: 'number', inline: false }, + { key: 'scaleX', label: 'scaleX', type: 'number', inline: false }, + { key: 'scaleY', label: 'scaleY', type: 'number', inline: false }, + { key: 'rotation', label: 'rotation', type: 'number', inline: false }, + { key: 'color', label: 'color', type: 'number', inline: false }, + { key: 'alpha', label: 'alpha', type: 'number', inline: false }, + ], + outputs: [], + params: [], + unfed: { + x: constant(0), + y: constant(0), + scaleX: constant(1), + scaleY: constant(1), + rotation: constant(0), + color: (env) => env.color, + alpha: constant(1), + }, + emit: () => ({}), +}; + +function arithmetic( + type: string, + label: string, + description: string, + bDefault: number, + op: (a: number, b: number) => number, +): ParticleNodeDef { + return { + type, + label, + category: 'particles', + description, + inputs: [numberIn('a', bDefault === 1 ? 1 : 0), numberIn('b', bDefault)], + outputs: [numberOut('result')], + params: [], + emit: ({ a, b }) => ({ result: (env, locals) => op(a!(env, locals), b!(env, locals)) }), + }; +} + +export const ParticleAdd = arithmetic('ParticleAdd', 'Add', 'a + b.', 0, (a, b) => a + b); +export const ParticleSubtract = arithmetic('ParticleSubtract', 'Subtract', 'a − b.', 0, (a, b) => a - b); +export const ParticleMultiply = arithmetic('ParticleMultiply', 'Multiply', 'a × b.', 1, (a, b) => a * b); +/** Division by zero would give Infinity, which draws as nothing at all; it reads as 0 instead. */ +export const ParticleDivide = arithmetic('ParticleDivide', 'Divide', 'a ÷ b; ÷ 0 is 0.', 1, (a, b) => + b === 0 ? 0 : a / b, +); + +function unary(type: string, label: string, description: string, fn: (v: number) => number) { + const def: ParticleNodeDef = { + type, + label, + category: 'particles', + description, + inputs: [numberIn('value', 0)], + outputs: [numberOut('result')], + params: [], + emit: ({ value }) => ({ result: (env, locals) => fn(value!(env, locals)) }), + }; + return def; +} + +export const ParticleSin = unary( + 'ParticleSin', + 'Sin', + 'The sine of an angle in degrees: a wave between −1 and 1, one full turn every 360.', + (v) => Math.sin(v * DEG), +); +export const ParticleCos = unary( + 'ParticleCos', + 'Cos', + 'The cosine of an angle in degrees: the same wave as Sin, a quarter turn ahead.', + (v) => Math.cos(v * DEG), +); +export const ParticleSqrt = unary( + 'ParticleSqrt', + 'Sqrt', + 'The square root: rises fast at first and then slows down, which is how smoke drifts up. Below 0 it is 0.', + (v) => (v > 0 ? Math.sqrt(v) : 0), +); +export const ParticleAbs = unary( + 'ParticleAbs', + 'Abs', + 'The distance from 0, sign dropped: −0.3 becomes 0.3.', + Math.abs, +); +export const ParticleFloor = unary( + 'ParticleFloor', + 'Floor', + 'Rounds down to a whole number: 2.75 becomes 2.', + Math.floor, +); +export const ParticleFract = unary( + 'ParticleFract', + 'Fract', + 'The part after the decimal point: a rising value becomes a sawtooth from 0 to 1.', + (v) => v - Math.floor(v), +); + +export const ParticleMin: ParticleNodeDef = { + type: 'ParticleMin', + label: 'Min', + category: 'particles', + description: 'The smaller of two numbers.', + inputs: [numberIn('a', 0), numberIn('b', 0)], + outputs: [numberOut('result')], + params: [], + emit: ({ a, b }) => ({ result: (env, locals) => Math.min(a!(env, locals), b!(env, locals)) }), +}; + +export const ParticleMax: ParticleNodeDef = { + type: 'ParticleMax', + label: 'Max', + category: 'particles', + description: 'The larger of two numbers.', + inputs: [numberIn('a', 0), numberIn('b', 0)], + outputs: [numberOut('result')], + params: [], + emit: ({ a, b }) => ({ result: (env, locals) => Math.max(a!(env, locals), b!(env, locals)) }), +}; + +export const ParticleClamp: ParticleNodeDef = { + type: 'ParticleClamp', + label: 'Clamp', + category: 'particles', + description: 'Holds a value between min and max: below min it is min, above max it is max.', + inputs: [numberIn('value', 0), numberIn('min', 0), numberIn('max', 1)], + outputs: [numberOut('result')], + params: [], + emit: ({ value, min, max }) => ({ + result: (env, locals) => Math.min(Math.max(value!(env, locals), min!(env, locals)), max!(env, locals)), + }), +}; + +export const ParticleMix: ParticleNodeDef = { + type: 'ParticleMix', + label: 'Mix', + category: 'particles', + description: 'Blends from a to b: t = 0 is a, t = 1 is b, beyond that it carries on.', + inputs: [numberIn('a', 0), numberIn('b', 1), numberIn('t', 0.5)], + outputs: [numberOut('result')], + params: [], + emit: ({ a, b, t }) => ({ + result: (env, locals) => { + const from = a!(env, locals); + return from + (b!(env, locals) - from) * t!(env, locals); + }, + }), +}; + +/** + * Colours are packed numbers, so blending them is a channel-by-channel job a plain Mix cannot do – + * mixing 0xff0000 and 0x0000ff as numbers would run through green. + */ +export const ParticleMixColor: ParticleNodeDef = { + type: 'ParticleMixColor', + label: 'Mix color', + category: 'particles', + description: 'Blends two colours channel by channel: t = 0 is a, t = 1 is b. A flame fading to smoke.', + inputs: [colorIn('a', 0xffffff), colorIn('b', 0x000000), numberIn('t', 0.5)], + outputs: [numberOut('color')], + params: [], + emit: ({ a, b, t }) => ({ + color: (env, locals) => mixColor(a!(env, locals), b!(env, locals), t!(env, locals)), + }), +}; + +export function mixColor(a: number, b: number, t: number): number { + const k = t < 0 ? 0 : t > 1 ? 1 : t; + const channel = (shift: number) => { + const from = (a >> shift) & 0xff; + const value = Math.round(from + (((b >> shift) & 0xff) - from) * k); + return (value < 0 ? 0 : value > 255 ? 255 : value) << shift; + }; + return channel(16) | channel(8) | channel(0); +} + +export const ParticleStep: ParticleNodeDef = { + type: 'ParticleStep', + label: 'Step', + category: 'particles', + description: '0 while value is below edge, 1 from edge on: a hard cut.', + inputs: [numberIn('edge', 0.5), numberIn('value', 0)], + outputs: [numberOut('result')], + params: [], + emit: ({ edge, value }) => ({ + result: (env, locals) => (value!(env, locals) < edge!(env, locals) ? 0 : 1), + }), +}; + +/** Negative bases have no real power, so the value is held at 0 there, as the shader's Pow does. */ +export const ParticlePow: ParticleNodeDef = { + type: 'ParticlePow', + label: 'Pow', + category: 'particles', + description: + 'value to the power of exponent: 2 squares it, 0.5 is the square root. Below 0 the value counts as 0.', + inputs: [numberIn('value', 0), numberIn('exponent', 2)], + outputs: [numberOut('result')], + params: [], + emit: ({ value, exponent }) => ({ + result: (env, locals) => Math.pow(Math.max(value!(env, locals), 0), exponent!(env, locals)), + }), +}; + +/** Every node a particle graph offers. Registered by `graph/nodes`, offered by no behavior palette. */ +export const PARTICLE_NODES: readonly ParticleNodeDef[] = [ + ParticleAge, + ParticleAgeSeconds, + ParticleTimeNode, + ParticleIndex, + ParticleRandom, + ParticleGravity, + ParticleStartVelocity, + ParticleOutput, + ParticleProperty, + ParticleNumber, + ParticleColor, + ParticleAdd, + ParticleSubtract, + ParticleMultiply, + ParticleDivide, + ParticleMin, + ParticleMax, + ParticleClamp, + ParticleMix, + ParticleMixColor, + ParticleSin, + ParticleCos, + ParticleSqrt, + ParticleAbs, + ParticleFloor, + ParticleFract, + ParticleStep, + ParticlePow, +]; + +/** The name a Property node placed from the palette gets: `value`, then `value2`, `value3` … */ +export function nextParticlePropertyName(taken: ReadonlySet): string { + if (!taken.has('value')) return 'value'; + for (let n = 2; ; n++) if (!taken.has(`value${n}`)) return `value${n}`; +} + +export function paramNameOf(params: PropertyBag): string { + return textOf(params[PROPERTY_NAME_PARAM] ?? ''); +} diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index ab4cc33c..273d199f 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -23,6 +23,20 @@ import { findAssetOf, type Project } from '../model/Project'; import type { SceneObject } from '../model/SceneObject'; import { flatListOf, listItemOf } from './listItems'; import { FONT_KEY } from './fonts'; +import { + BURST_COUNT_KEY, + EMITTING_KEY, + LIFETIME_KEY, + MAX_PARTICLES_KEY, + MODE_KEY, + MODE_OPTIONS, + MOVE_WITH_SOURCE_KEY, + PARTICLE_COLOR_KEY, + PARTICLE_SIZE_KEY, + PARTICLE_TEXTURE_KEY, + particleParamDefs, + RATE_KEY, +} from './particles'; import { SHADER_KEY, shaderOf, shaderParamDefs } from './shaders'; /** @@ -109,7 +123,13 @@ export const TRANSFORM_PROPS: PropertySchema = [ ]; /** Cameras and behaviors are placed by the chain but have no look of their own to turn or size. */ -const TRANSFORM_TYPES: ReadonlySet = new Set(['sprite', 'text', 'collider', 'container']); +const TRANSFORM_TYPES: ReadonlySet = new Set([ + 'sprite', + 'text', + 'collider', + 'container', + 'particles', +]); export function hasTransformProps(type: AssetType): boolean { return TRANSFORM_TYPES.has(type); @@ -192,6 +212,28 @@ const ASSET_SCHEMAS: Record = { shader: [], // The typeface itself is intrinsic data on the asset (see FontAsset), not a property. font: [], + // How many particles and for how long; what one of them does is the asset's graph, whose Property + // nodes add their rows after these – see `schemaForBag`. + particles: [ + { key: EMITTING_KEY, label: 'Emitting', kind: 'boolean', default: true }, + { key: MODE_KEY, label: 'Mode', kind: 'enum', default: 'continuous', options: MODE_OPTIONS }, + { key: RATE_KEY, label: 'Rate', kind: 'number', default: 20, min: 0, step: 1 }, + { key: BURST_COUNT_KEY, label: 'Burst count', kind: 'number', default: 20, min: 0, step: 1, decimals: 0 }, + { key: LIFETIME_KEY, label: 'Lifetime', kind: 'number', default: 1, min: 0.01, step: 0.1 }, + { + key: MAX_PARTICLES_KEY, + label: 'Max particles', + kind: 'number', + default: 500, + min: 1, + step: 50, + decimals: 0, + }, + { key: PARTICLE_SIZE_KEY, label: 'Size', kind: 'number', default: 16, min: 0.1, step: 1 }, + { key: PARTICLE_TEXTURE_KEY, label: 'Texture', kind: 'texture', default: '' }, + { key: PARTICLE_COLOR_KEY, label: 'Color', kind: 'color', default: 0xffffff }, + { key: MOVE_WITH_SOURCE_KEY, label: 'Move with source', kind: 'boolean', default: false }, + ], collider: [ { key: 'shape', @@ -260,6 +302,8 @@ export function schemaForObject(project: Project, asset: Asset, object: SceneObj function schemaForBag(project: Project, asset: Asset, bag: PropertyBag): PropertySchema { if (asset.type === 'behavior') return [...FULL_SCHEMAS.behavior, ...asset.variables]; if (asset.type === 'sprite') return [...FULL_SCHEMAS.sprite, ...shaderParamRows(project, shaderOf(bag))]; + // Unlike a sprite's shader, a source's graph is its own asset's, so no id has to be looked up. + if (asset.type === 'particles') return [...FULL_SCHEMAS.particles, ...particleParamDefs(asset.graph)]; return FULL_SCHEMAS[asset.type]; } diff --git a/src/core/properties/bounds.ts b/src/core/properties/bounds.ts index 9c3933fc..07458816 100644 --- a/src/core/properties/bounds.ts +++ b/src/core/properties/bounds.ts @@ -24,6 +24,7 @@ const TEXT_DEFAULTS = defaultsFor('text'); /** Symbol sizes in world units. */ export const BEHAVIOR_SYMBOL = 28; export const CONTAINER_SYMBOL = 24; +export const PARTICLES_SYMBOL = 24; /** Rough width of one character relative to the font size, for a plain sans. */ const CHAR_WIDTH = 0.55; @@ -59,6 +60,10 @@ export function footprintOf(type: AssetType, props: PropertyBag): { w: number; h return { w: BEHAVIOR_SYMBOL, h: BEHAVIOR_SYMBOL }; case 'container': return { w: CONTAINER_SYMBOL, h: CONTAINER_SYMBOL }; + // The particles themselves are drawn wherever the graph puts them; what can be grabbed and + // framed is the source, which is a point with a handle around it. + case 'particles': + return { w: PARTICLES_SYMBOL, h: PARTICLES_SYMBOL }; default: return cameraFrame(props); } diff --git a/src/core/properties/particles.ts b/src/core/properties/particles.ts new file mode 100644 index 00000000..f262a0bf --- /dev/null +++ b/src/core/properties/particles.ts @@ -0,0 +1,103 @@ +import type { Graph, GraphNode } from '../graph/Graph'; +import { type PropertyBag, textOf } from '../model/Properties'; +import { type FlatDef, isValidFieldName } from './PropertySchema'; + +/** The node of a particle graph that declares a parameter: a row on every source placed from the asset. */ +export const PARTICLE_PROPERTY = 'ParticleProperty'; +export const PROPERTY_NAME_PARAM = 'name'; +export const PROPERTY_DEFAULT_PARAM = 'default'; +export const PROPERTY_MIN_PARAM = 'min'; +export const PROPERTY_MAX_PARAM = 'max'; + +/** The rows a particle source has of its own, before the ones its graph declares. */ +export const EMITTING_KEY = 'emitting'; +export const MODE_KEY = 'mode'; +export const RATE_KEY = 'rate'; +export const BURST_COUNT_KEY = 'count'; +export const LIFETIME_KEY = 'lifetime'; +export const MAX_PARTICLES_KEY = 'maxParticles'; +export const PARTICLE_SIZE_KEY = 'size'; +export const PARTICLE_TEXTURE_KEY = 'texture'; +export const PARTICLE_COLOR_KEY = 'color'; +export const MOVE_WITH_SOURCE_KEY = 'moveWithSource'; + +export type EmitMode = 'continuous' | 'burst'; + +export const MODE_OPTIONS: { value: EmitMode; label: string }[] = [ + { value: 'continuous', label: 'Continuous' }, + { value: 'burst', label: 'Burst' }, +]; + +export function emitModeOf(props: PropertyBag): EmitMode { + return textOf(props[MODE_KEY] ?? '') === 'burst' ? 'burst' : 'continuous'; +} + +export function isParticlePropertyNode(node: GraphNode): boolean { + return node.type === PARTICLE_PROPERTY; +} + +export function propertyNameOf(node: GraphNode): string { + return textOf(node.params[PROPERTY_NAME_PARAM] ?? ''); +} + +/** + * A parameter's name is a property key on the source, so it has to be an identifier – and not one of + * the source's own rows, which would put two rows of that name in one inspector. + */ +export function isValidParticlePropertyName(name: string): boolean { + return isValidFieldName(name) && !RESERVED_KEYS.has(name); +} + +const RESERVED_KEYS: ReadonlySet = new Set([ + EMITTING_KEY, + MODE_KEY, + RATE_KEY, + BURST_COUNT_KEY, + LIFETIME_KEY, + MAX_PARTICLES_KEY, + PARTICLE_SIZE_KEY, + PARTICLE_TEXTURE_KEY, + PARTICLE_COLOR_KEY, + MOVE_WITH_SOURCE_KEY, +]); + +/** + * The parameters a particle graph declares, in node order: one number row per `Property` node with + * a usable name. A second node of the same name declares nothing new – both read the one value. + */ +export function particleParamDefs(graph: Graph): FlatDef<'number'>[] { + const rows: FlatDef<'number'>[] = []; + const seen = new Set(); + for (const node of graph.nodes) { + if (!isParticlePropertyNode(node)) continue; + const name = propertyNameOf(node); + if (!isValidParticlePropertyName(name) || seen.has(name)) continue; + seen.add(name); + rows.push(propertyRow(name, node.params)); + } + return rows; +} + +function propertyRow(name: string, params: PropertyBag): FlatDef<'number'> { + const min = numberParam(params, PROPERTY_MIN_PARAM, 0); + const max = numberParam(params, PROPERTY_MAX_PARAM, 1); + const row: FlatDef<'number'> = { + key: name, + label: name, + kind: 'number', + default: numberParam(params, PROPERTY_DEFAULT_PARAM, 0), + min, + step: niceStep(max > min ? max - min : 1), + }; + // A max at or below the min would pin the value; treated as "no max" instead. + return max > min ? { ...row, max } : row; +} + +function numberParam(params: PropertyBag, key: string, fallback: number): number { + const n = Number(params[key]); + return Number.isFinite(n) ? n : fallback; +} + +function niceStep(range: number): number { + return 10 ** Math.floor(Math.log10(range)) / 20; +} diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index eaceebbf..881261dd 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -12,6 +12,7 @@ import { nodeDef, STATE, } from '../graph'; +import { isParticleNodeDef } from '../particles/nodes'; import { isShaderNodeDef } from '../shader/nodes'; import type { Asset, TextureFrame } from '../model/Asset'; import { isAssetType } from '../model/AssetTypes'; @@ -48,6 +49,7 @@ import { type StructFieldDef, variableDef, } from '../properties/PropertySchema'; +import { particleParamDefs } from '../properties/particles'; import { SHADER_KEY, shaderParamDefs } from '../properties/shaders'; /** @@ -64,6 +66,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { const rawProps = new Map>(); const shaderGraphs = new Map(); const shaderRows = new Map(); + const particleGraphs = new Map(); for (const a of rawAssets) { if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue; rawProps.set(a.id, asRecord(a.props)); @@ -71,6 +74,12 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { const vars = normalizeVariables(a.variables); variables.set(a.id, vars); schemas.set(a.id, [...COMMON_PROPS, ...vars]); + } else if (a.type === 'particles') { + // A source's rows end with its own graph's parameters, so the graph is needed before the + // overrides of the objects placed from it can be coerced. + const graph = normalizeGraph(a.graph, isParticleNodeDef); + particleGraphs.set(a.id, graph); + schemas.set(a.id, [...schemaFor('particles'), ...particleParamDefs(graph)]); } else { if (a.type === 'shader') { const graph = normalizeGraph(a.graph, isShaderNodeDef); @@ -115,6 +124,8 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { assets.push({ id, type, name, props, graph: normalizeGraph(a.graph, (def) => def.type === STATE) }); } else if (type === 'shader') { assets.push({ id, type, name, props, graph: shaderGraphs.get(id) ?? createEmptyGraph() }); + } else if (type === 'particles') { + assets.push({ id, type, name, props, graph: particleGraphs.get(id) ?? createEmptyGraph() }); } else if (type === 'texture') { assets.push({ id, type, name, props, ...normalizeTexture(a, takenIds) }); } else if (type === 'font') { diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index e1f9194f..3b3003cc 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -21,6 +21,7 @@ import { HierarchyPanel } from './panels/HierarchyPanel'; import { Toolbar } from './panels/Toolbar'; import { type PlayHost, PlaySession, type WorldSnapshot } from './PlaySession'; import { ProjectSession } from './ProjectSession'; +import { ParticlesEditor } from './particles/ParticlesEditor'; import { ShaderEditor } from './shader/ShaderEditor'; import { StateMachineEditor } from './statemachine/StateMachineEditor'; import { installShortcuts } from './shortcuts'; @@ -29,7 +30,16 @@ import { TextureEditor } from './texture/TextureEditor'; import { installFileImport } from './fileImport'; export type CentreTab = - 'scene' | 'graph' | 'statemachine' | 'shader' | 'game' | 'texture' | 'animation' | 'container' | 'list'; + | 'scene' + | 'graph' + | 'statemachine' + | 'shader' + | 'particles' + | 'game' + | 'texture' + | 'animation' + | 'container' + | 'list'; const TAB_ORDER = [ 'scene', @@ -37,6 +47,7 @@ const TAB_ORDER = [ 'graph', 'statemachine', 'shader', + 'particles', 'texture', 'animation', 'container', @@ -49,6 +60,7 @@ const TAB_LABELS: Record = { graph: 'Behavior', statemachine: 'State machine', shader: 'Shader', + particles: 'Particles', texture: 'Texture', animation: 'Animation', container: 'Container', @@ -61,6 +73,7 @@ const TAB_OF_ASSET: Partial> = { behavior: 'graph', statemachine: 'statemachine', shader: 'shader', + particles: 'particles', texture: 'texture', animation: 'animation', container: 'container', @@ -87,6 +100,7 @@ export class Editor implements PlayHost { readonly graphEditor: GraphEditor; readonly stateMachineEditor: StateMachineEditor; readonly shaderEditor: ShaderEditor; + readonly particlesEditor: ParticlesEditor; readonly textureEditor: TextureEditor; readonly animationEditor: AnimationEditor; readonly containerEditor: ContainerEditor; @@ -129,6 +143,7 @@ export class Editor implements PlayHost { this.graphEditor = new GraphEditor(state); this.stateMachineEditor = new StateMachineEditor(state); this.shaderEditor = new ShaderEditor(state); + this.particlesEditor = new ParticlesEditor(state); this.textureEditor = new TextureEditor(state); this.animationEditor = new AnimationEditor(state); this.containerEditor = new ContainerEditor(state); @@ -175,6 +190,7 @@ export class Editor implements PlayHost { graph: this.graphEditor, statemachine: this.stateMachineEditor, shader: this.shaderEditor, + particles: this.particlesEditor, texture: this.textureEditor, animation: this.animationEditor, container: this.containerEditor, @@ -233,7 +249,9 @@ export class Editor implements PlayHost { ? 'statemachine' : state.selectedShaderId ? 'shader' - : 'graph'; + : state.selectedParticlesId + ? 'particles' + : 'graph'; if (this.tab !== tab) this.showTab(tab); } if (s?.kind === 'frame' && this.tab !== 'texture') this.showTab('texture'); @@ -305,6 +323,7 @@ export class Editor implements PlayHost { this.gameView.animate(); this.console.refresh(); if (this.tab === 'animation') this.animationEditor.tick(dt); + if (this.tab === 'particles') this.particlesEditor.tick(); } private measureFps(dt: number): void { diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index e3edf2e9..74323ee3 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -3,6 +3,7 @@ import { resolveObjects } from '../core/model/instance'; import { findObjectHolder } from '../core/model/Project'; import type { FrameRect } from '../core/model/texture'; import { activeDrag, Button, ui, Widget } from '../ui'; +import { particleLibrary } from '../viewport/ParticleLibrary'; import { shaderLibrary } from '../viewport/ShaderLibrary'; import { textureLibrary } from '../viewport/TextureLibrary'; import type { CentreTab, Editor } from './Editor'; @@ -280,6 +281,26 @@ export function installDebugHook(app: Application, state: EditorState, editor: E center: () => centreOf(editor.stateMachineEditor.canvas), snapping: () => editor.stateMachineEditor.canvas.snapEnabled, }, + particles: { + current: () => editor.particlesEditor.currentAssetId, + pickerAt: () => centreOf(editor.particlesEditor.picker), + info: () => editor.particlesEditor.shownInfo, + nodeAt: (nodeId: string) => editor.particlesEditor.canvas.nodeGlobal(nodeId), + nodeLabel: (nodeId: string) => editor.particlesEditor.canvas.nodeLabel(nodeId), + nodeRect: (nodeId: string) => editor.particlesEditor.canvas.nodeRect(nodeId), + nodeProblem: (nodeId: string) => editor.particlesEditor.canvas.nodeProblem(nodeId), + canvasRect: () => editor.particlesEditor.canvas.canvasRect(), + emptySpot: () => editor.particlesEditor.canvas.emptySpotGlobal(), + center: () => centreOf(editor.particlesEditor.canvas), + /** The strip below the graph: how many particles it draws and which second it stands at. */ + preview: () => ({ + count: editor.particlesEditor.preview.count, + time: Math.round(editor.particlesEditor.preview.time * 100) / 100, + holding: editor.particlesEditor.preview.holding, + }), + /** Jumps the preview to a second – exact, because a particle is a function of its age. */ + hold: (at: number | null) => editor.particlesEditor.preview.hold(at), + }, shader: { current: () => editor.shaderEditor.currentAssetId, pickerAt: () => centreOf(editor.shaderEditor.picker), @@ -425,6 +446,17 @@ export function installDebugHook(app: Application, state: EditorState, editor: E }, /** Holds the shader clock at `at` seconds, so a Time shader's pixels can be measured; null releases it. */ shaderTime: (at: number | null) => shaderLibrary.freezeTime(at), + /** The same for particle time, kept apart so one can be held without the other. */ + particleTime: (at: number | null) => particleLibrary.freezeTime(at), + particleCompiles: () => particleLibrary.compiles, + /** Every source's clock back to zero on the current (usually frozen) second. */ + particlesRestart: () => { + viewport.restartParticles(particleLibrary.now()); + gameView.restartParticles(particleLibrary.now()); + }, + /** What a source is drawing in the scene tab, or in the game view while playing. */ + particlesOf: (objectId: string, where: 'scene' | 'game' = 'scene', limit = 8) => + (where === 'game' ? gameView : viewport).particleInfo(objectId, limit), /** Graphs compiled so far; a moved node must not add one (#65). */ shaderCompiles: () => shaderLibrary.compiles, /** Which views redraw every tick because a Time shader is on screen. */ diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index 2aed7ee4..8064673a 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -28,6 +28,7 @@ import { } from '../../core/graph'; import type { GraphAsset } from '../../core/model/Asset'; import { graphLookup } from '../../core/model/graphLookup'; +import { particleLibrary } from '../../viewport/ParticleLibrary'; import { shaderLibrary } from '../../viewport/ShaderLibrary'; import { findAsset, findGraphAsset } from '../../core/model/Project'; import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem'; @@ -219,8 +220,13 @@ export class GraphCanvas extends Widget { } const seen = new Set(); const lookup = graphLookup(this.state.project); - // A shader node's problem is the compiler's, not a call's. - const shaderProblems = asset.type === 'shader' ? shaderLibrary.buildFor(asset).problems : null; + // In a compiled graph a node's problem is the compiler's, not a call's. + const compilerProblems = + asset.type === 'shader' + ? shaderLibrary.buildFor(asset).problems + : asset.type === 'particles' + ? particleLibrary.buildFor(asset).problems + : null; for (const node of asset.graph.nodes) { const def = resolveDef(node, asset.graph, lookup); if (!def) continue; @@ -233,8 +239,8 @@ export class GraphCanvas extends Widget { this.views.set(node.id, view); this.nodeLayer.addChild(view); } - const problem = shaderProblems - ? (shaderProblems[node.id] ?? null) + const problem = compilerProblems + ? (compilerProblems[node.id] ?? null) : callProblem(node, lookup, asset.id); view.sync(node, asset.graph, this.state.project, def, problem); } diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index e3195571..98631d2a 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -53,6 +53,8 @@ export function categoryColor(category: NodeCategory): number { return c.categoryState; case 'shader': return c.categoryShader; + case 'particles': + return c.categoryParticles; } } diff --git a/src/editor/graph/paletteEntries.ts b/src/editor/graph/paletteEntries.ts index 3acdf234..25562b3d 100644 --- a/src/editor/graph/paletteEntries.ts +++ b/src/editor/graph/paletteEntries.ts @@ -15,7 +15,14 @@ import type { GraphAsset } from '../../core/model/Asset'; import { graphLookup } from '../../core/model/graphLookup'; import type { PropertyBag } from '../../core/model/Properties'; import type { Project } from '../../core/model/Project'; +import { + isParticlePropertyNode, + PARTICLE_PROPERTY, + PROPERTY_NAME_PARAM as PARTICLE_NAME_PARAM, + propertyNameOf, +} from '../../core/properties/particles'; import { nextPropertyName, PROPERTY_NAME_PARAM, SHADER_PROPERTY } from '../../core/properties/shaders'; +import { nextParticlePropertyName, PARTICLE_NODES, PARTICLE_OUTPUT } from '../../core/particles/nodes'; import { SHADER_NODES, SHADER_OUTPUT } from '../../core/shader/nodes'; /** @@ -48,9 +55,19 @@ export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntr return { id: def.type, hint: def.category, def, params }; }); } + if (asset.type === 'particles') { + const hasOutput = asset.graph.nodes.some((n) => n.type === PARTICLE_OUTPUT); + const taken = new Set(asset.graph.nodes.filter(isParticlePropertyNode).map(propertyNameOf)); + return PARTICLE_NODES.filter((def) => def.type !== PARTICLE_OUTPUT || !hasOutput).map((def) => { + const params: PropertyBag = + def.type === PARTICLE_PROPERTY ? { [PARTICLE_NAME_PARAM]: nextParticlePropertyName(taken) } : {}; + return { id: def.type, hint: def.category, def, params }; + }); + } const entries: PaletteEntry[] = []; for (const def of allNodeDefs()) { - if (def.type === RUN_BEHAVIOR || def.category === 'state' || def.category === 'shader') continue; + if (def.type === RUN_BEHAVIOR || def.category === 'state') continue; + if (def.category === 'shader' || def.category === 'particles') continue; if (isTerminalType(def.type) && terminalNode(asset.graph, def.type)) continue; entries.push({ id: def.type, hint: def.category, def, params: {} }); } diff --git a/src/editor/particles/ParticlePreview.ts b/src/editor/particles/ParticlePreview.ts new file mode 100644 index 00000000..7b3451ee --- /dev/null +++ b/src/editor/particles/ParticlePreview.ts @@ -0,0 +1,104 @@ +import { Container, Graphics, Texture } from 'pixi.js'; +import type { ParticlesAsset } from '../../core/model/Asset'; +import { translation } from '../../core/model/matrix'; +import type { PropertyBag } from '../../core/model/Properties'; +import { defaultsFor } from '../../core/properties/PropertySchema'; +import { type Size, Theme, Widget } from '../../ui'; +import { ParticleField } from '../../viewport/particles'; +import { particleLibrary } from '../../viewport/ParticleLibrary'; + +const MIN_HEIGHT = 40; + +/** + * The asset's own particles, emitted from the middle of the strip by a source with the type's + * default rows. It is the one place a formula graph can be judged: the numbers say nothing. The + * picture is deliberately a plain square – which texture a source throws belongs to the placed + * object, not to the asset the tab is showing. + */ +export class ParticlePreview extends Widget { + private readonly bg = new Graphics(); + private readonly content = new Container(); + private readonly clip = new Graphics(); + private readonly field = new ParticleField(); + private asset: ParticlesAsset | null = null; + /** Null: the preview runs on the library clock. A number: it stands at that second. */ + private held: number | null = null; + + constructor() { + super(); + this.minHeight = MIN_HEIGHT; + this.content.addChild(this.field.container); + this.content.mask = this.clip; + this.addChild(this.bg, this.content, this.clip); + } + + override measure(): Size { + return { w: Math.max(this.minWidth, 100), h: Math.max(this.minHeight, MIN_HEIGHT) }; + } + + show(asset: ParticlesAsset | null): void { + this.asset = asset; + this.invalidate(); + } + + /** Seconds the shown source has been emitting, whether held or running. */ + get time(): number { + return this.held ?? Math.max(0, this.field.elapsed(particleLibrary.now())); + } + + get holding(): boolean { + return this.held !== null; + } + + hold(at: number | null): void { + this.held = at; + this.invalidate(); + } + + restart(): void { + this.field.restart(particleLibrary.now()); + this.invalidate(); + } + + /** How many particles the preview is drawing – what the checks read. */ + get count(): number { + return this.field.count; + } + + override redraw(): void { + const c = Theme.colors; + // A ground of its own and a rule along the top: on the graph's own background the strip was + // invisible as a thing, and a particle standing still read as a node that had lost its box. + this.bg.clear().rect(0, 0, this.w, this.h).fill(c.panel); + this.bg.rect(0, 0, this.w, 1).fill(c.border); + this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff); + this.field.container.visible = this.asset !== null; + if (!this.asset) return; + const build = particleLibrary.buildFor(this.asset); + const rows = build.compiled?.params ?? []; + const props = previewProps(rows); + const params = rows.map((row) => Number(props[row.key] ?? row.default)); + // The source sits in the middle of the strip; a fountain then has room above and below it. + const m = translation(this.w / 2, this.h / 2); + this.field.sync(build.compiled, props, params, m, Texture.WHITE, this.clock()); + } + + /** Holding at second t means handing the field a clock that reads exactly t since it started. */ + private clock(): number { + const now = particleLibrary.now(); + if (this.held === null) return now; + return now - this.field.elapsed(now) + this.held; + } + + override destroy(): void { + this.field.destroy(); + super.destroy(); + } +} + +/** A source as the asset alone describes it: the type's defaults, the graph's parameters at theirs. */ +function previewProps(params: readonly { key: string; default: number }[]): PropertyBag { + const props = defaultsFor('particles'); + for (const row of params) props[row.key] = row.default; + return props; +} diff --git a/src/editor/particles/ParticlesEditor.ts b/src/editor/particles/ParticlesEditor.ts new file mode 100644 index 00000000..7e4bafdc --- /dev/null +++ b/src/editor/particles/ParticlesEditor.ts @@ -0,0 +1,107 @@ +import type { Asset, ParticlesAsset } from '../../core/model/Asset'; +import { LIFETIME_KEY } from '../../core/properties/particles'; +import { defaultsFor } from '../../core/properties/PropertySchema'; +import { particleLibrary } from '../../viewport/ParticleLibrary'; +import { Button, clamp, HStack, Label, Slider, Spacer, Splitter, Theme } from '../../ui'; +import { AssetTabEditor } from '../AssetTabEditor'; +import { GraphCanvas } from '../graph/GraphCanvas'; +import { SnapToggle } from '../graph/SnapToggle'; +import type { EditorState } from '../state'; +import { ParticlePreview } from './ParticlePreview'; + +/** Room kept for "N parameters", or for the reason the graph does not compile. */ +const INFO_WIDTH = 220; +/** How far the time slider reaches: three lifetimes, so a particle's whole arc fits with room after. */ +const SPAN_LIFETIMES = 3; +const MIN_SPAN = 2; +/** The strip can be dragged shut to its bar and open to half a tall window. */ +const MIN_PREVIEW = 60; +const MAX_PREVIEW = 500; + +/** + * The nodes of one particle asset, above a strip that actually emits them. A formula graph cannot be + * read as a picture, so the tab shows one – and because every particle is a function of its age, the + * slider can jump to any second exactly, without simulating the way there. + */ +export class ParticlesEditor extends AssetTabEditor { + readonly preview = new ParticlePreview(); + private readonly snap = new SnapToggle(() => this.canvas); + private readonly slider = new Slider(0, 0, MIN_SPAN, 140); + private readonly holdButton = new Button('Hold', { onClick: () => this.toggleHold() }); + private readonly timeLabel = new Label('0.00 s', { style: 'small' }); + readonly splitter = new Splitter('y'); + private span = MIN_SPAN; + private previewHeight = 180; + + constructor(state: EditorState) { + super(state, { noun: 'Particles', infoWidth: INFO_WIDTH }); + this.slider.onChange = (value) => { + this.preview.hold(value); + this.syncTime(); + }; + this.mount(new GraphCanvas(state, 'particles'), this.snap.widgets); + // The clock belongs to the strip, not to the asset, so its controls sit above the strip: the + // header is already carrying the picker, Snap, Fit and the readout. + const bar = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' }); + bar.minHeight = Theme.controlHeight + Theme.padding * 2; + bar.add(new Label('Preview', { style: 'muted' })); + bar.add(this.holdButton); + bar.add(this.slider); + bar.add(this.timeLabel); + bar.add(new Button('Restart', { onClick: () => this.preview.restart() })); + bar.add(new Spacer(), 1); + // Draggable like the console: how much of the tab is graph and how much is picture is a matter + // of what is being worked on, and on a short window a fixed strip eats the canvas. + this.splitter.onDrag = (delta) => { + this.previewHeight = clamp(this.previewHeight - delta, MIN_PREVIEW, MAX_PREVIEW); + this.preview.minHeight = this.previewHeight; + this.relayout(); + }; + this.add(this.splitter); + this.add(bar); + this.preview.minHeight = this.previewHeight; + this.add(this.preview); + state.on('mode', () => this.refresh()); + } + + /** Called every frame while the tab is showing: the strip is a moving picture. */ + tick(): void { + this.preview.invalidate(); + if (!this.preview.holding) this.syncTime(); + } + + private toggleHold(): void { + this.preview.hold(this.preview.holding ? null : this.preview.time); + this.holdButton.setText(this.preview.holding ? 'Run' : 'Hold'); + this.syncTime(); + } + + private syncTime(): void { + const time = this.preview.time; + this.timeLabel.setText(`${time.toFixed(2)} s`); + if (!this.preview.holding) this.slider.setValue(time % this.span); + } + + protected override selectedId(): string { + return this.state.selectedParticlesId ?? ''; + } + + protected override isMine(asset: Asset): asset is ParticlesAsset { + return asset.type === 'particles'; + } + + protected override infoText(asset: ParticlesAsset): string { + const build = particleLibrary.buildFor(asset); + if (!build.compiled) return build.reason; + const count = build.compiled.params.length; + return `${count} ${count === 1 ? 'parameter' : 'parameters'}`; + } + + protected override syncExtras(asset: ParticlesAsset | null): void { + this.snap.setEnabled(Boolean(asset)); + this.preview.show(asset); + const lifetime = Number(defaultsFor('particles')[LIFETIME_KEY] ?? 1); + this.span = Math.max(MIN_SPAN, lifetime * SPAN_LIFETIMES); + this.slider.setRange(0, this.span); + } +} diff --git a/src/editor/state.ts b/src/editor/state.ts index 4d48e862..49fb415a 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -376,7 +376,12 @@ export class EditorState { return this.selectedGraphId('shader'); } - private selectedGraphId(type: 'behavior' | 'statemachine' | 'shader'): string | null { + /** The particle asset the selection refers to: the asset, a placed source, or one of its nodes. */ + get selectedParticlesId(): string | null { + return this.selectedGraphId('particles'); + } + + private selectedGraphId(type: 'behavior' | 'statemachine' | 'shader' | 'particles'): string | null { const s = this.selection; if (!s) return null; if (s.kind === 'frame') return null; diff --git a/src/editor/viewport/WorldViewport.ts b/src/editor/viewport/WorldViewport.ts index fecff374..2a3161fc 100644 --- a/src/editor/viewport/WorldViewport.ts +++ b/src/editor/viewport/WorldViewport.ts @@ -1,4 +1,12 @@ -import { Container, type FederatedPointerEvent, Graphics, type Mesh, Text, TextStyle } from 'pixi.js'; +import { + Container, + type FederatedPointerEvent, + Graphics, + type Mesh, + Text, + Texture, + TextStyle, +} from 'pixi.js'; import { isSymbolType } from '../../core/model/AssetTypes'; import { type Extent, scaleOf } from '../../core/model/matrix'; import { sortByZ, type Vec2, textOf } from '../../core/model/Properties'; @@ -8,6 +16,10 @@ import type { EditorState } from '../state'; import { type Size, Theme, Widget } from '../../ui'; import { fontLibrary } from '../../viewport/FontLibrary'; import type { DrawItem } from '../../viewport/items'; +import { hashId } from '../../core/particles/nodes'; +import { ParticleField } from '../../viewport/particles'; +import { particleLibrary } from '../../viewport/ParticleLibrary'; +import { SHEET_EDGE } from '../../core/shader/nodes'; import { shaderLibrary } from '../../viewport/ShaderLibrary'; import { type ShaderSource, wornShader } from '../../viewport/shaders'; import { @@ -39,6 +51,8 @@ export abstract class WorldViewport extends Widget { private readonly texts = new Map(); /** Pictures of sprite items: siblings as well, each right under its node. */ private readonly sprites = new Map(); + /** The drawn particles of each source, one container – and one draw call – per source. */ + private readonly fields = new Map(); private visibleCount = 0; private animated = false; protected lastItems: DrawItem[] = []; @@ -99,6 +113,40 @@ export abstract class WorldViewport extends Widget { return { rendering: this.currentView() !== null, visibleItems: this.visibleCount }; } + /** + * The particles of one source as drawn, in world units, oldest first – what a check reads instead + * of guessing from pixels. `limit` keeps a stream of 500 from becoming 500 lines of JSON. + */ + particleInfo( + objectId: string, + limit = 8, + ): { + count: number; + drawn: { x: number; y: number; scaleX: number; alpha: number; tint: number }[]; + } | null { + const field = this.fields.get(objectId); + if (!field || !field.container.visible) return null; + return { + count: field.count, + drawn: field.container.particleChildren.slice(0, limit).map((p) => ({ + x: Math.round(p.x * 100) / 100, + y: Math.round(p.y * 100) / 100, + scaleX: Math.round(p.scaleX * 1000) / 1000, + alpha: Math.round(p.alpha * 1000) / 1000, + tint: p.tint, + })), + }; + } + + /** + * Starts every source's clock over at `now`. With particle time frozen this is what makes a + * measurement exact: freeze, restart, move the clock to the second wanted, read. + */ + restartParticles(now: number): void { + for (const field of this.fields.values()) field.restart(now); + this.redraw(); + } + /** True after a redraw that drew a sprite wearing a Time shader: the picture moves by itself. */ get animates(): boolean { return this.animated; @@ -179,8 +227,9 @@ export abstract class WorldViewport extends Widget { const size = footprintOf(item.type, item.props); const extent = footprintExtent(item.type, item.props, item.m); // A switched-off object is not drawn at all, in the scene view as in the game; it is - // selected and switched back on from the hierarchy panel. - const onScreen = item.enabled && rectsIntersect(extent, bounds); + // selected and switched back on from the hierarchy panel. A source is never culled by its + // own little square: its particles may be anywhere on screen while it is not. + const onScreen = item.enabled && (item.type === 'particles' || rectsIntersect(extent, bounds)); let node = this.nodes.get(item.id); if (!node) { node = new Graphics(); @@ -195,6 +244,7 @@ export abstract class WorldViewport extends Widget { this.syncDecorations(item, onScreen, view.scale, extent); this.syncText(item, onScreen, view.scale); this.syncSprite(item, onScreen, size, shaders); + this.syncParticles(item, onScreen); if (!onScreen) continue; this.visibleCount++; drawItem(node, item, size, view.scale * scaleOf(placementOf(item.type, item.m))); @@ -207,6 +257,8 @@ export abstract class WorldViewport extends Widget { const sprite = this.sprites.get(id); if (sprite) disposeSpriteMesh(sprite); this.sprites.delete(id); + this.fields.get(id)?.destroy(); + this.fields.delete(id); node.destroy(); this.nodes.delete(id); this.disposeDecorations(id); @@ -230,9 +282,46 @@ export abstract class WorldViewport extends Widget { if (node) this.objects.addChild(node); const text = this.texts.get(item.id); if (text) this.objects.addChild(text); + const field = this.fields.get(item.id); + if (field) this.objects.addChild(field.container); } } + /** + * The particles of one source. Their positions are world units, like everything else under + * `objects`, so the container itself carries no transform: the source's own goes into each + * particle, which is what lets one stay where it was born while its source flies on. + */ + private syncParticles(item: DrawItem, onScreen: boolean): void { + let field = this.fields.get(item.id); + if (item.type !== 'particles' || !item.particles || !onScreen) { + if (field) field.container.visible = false; + return; + } + if (!field) { + field = new ParticleField(); + this.fields.set(item.id, field); + this.objects.addChild(field.container); + } + field.container.visible = true; + // A source is a moving picture by definition, so the view keeps redrawing while one is on screen. + this.animated = true; + const build = particleLibrary.buildFor(item.particles); + const compiled = build.compiled; + const params = compiled ? compiled.params.map((row) => Number(item.props[row.key] ?? row.default)) : []; + const texture = item.texture ? textureLibrary.get(item.texture, SHEET_EDGE) : null; + field.sync( + compiled, + item.props, + params, + item.m, + texture ?? Texture.WHITE, + particleLibrary.now(), + item.emits, + hashId(item.id), + ); + } + private syncSprite(item: DrawItem, onScreen: boolean, size: Size, shaders: ShaderSource): void { let sprite = this.sprites.get(item.id); if (item.type !== 'sprite' || !onScreen) { diff --git a/src/runtime/Entity.ts b/src/runtime/Entity.ts index 8381921e..c0ee3dc8 100644 --- a/src/runtime/Entity.ts +++ b/src/runtime/Entity.ts @@ -23,6 +23,8 @@ export interface Entity { parent: Entity | null; /** The animation a sprite wears and how far it is; kept out of `props`, which the world writes back. */ animation?: AnimationClock; + /** How often an `Emit` node has restarted this particle source; out of `props` for the same reason. */ + emits?: number; } export interface AnimationClock { diff --git a/src/ui/Theme.ts b/src/ui/Theme.ts index 4ef48d73..0b0f867e 100644 --- a/src/ui/Theme.ts +++ b/src/ui/Theme.ts @@ -56,6 +56,7 @@ export const Theme = { categoryAction: 0x2f6fd6, categoryState: 0x8e5ad6, categoryShader: 0x1f8f8f, + categoryParticles: 0xc26a1f, portExec: 0xffffff, portNumber: 0x6fcf97, portBoolean: 0xe57373, diff --git a/src/ui/index.ts b/src/ui/index.ts index 9bbdb7cd..66b1bd6f 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -10,6 +10,7 @@ export { Button, type ButtonOptions } from './widgets/Button'; export { Dropdown } from './widgets/Dropdown'; export { Checkbox } from './widgets/Checkbox'; export { Panel } from './widgets/Panel'; +export { Slider } from './widgets/Slider'; export { Splitter } from './widgets/Splitter'; export { ScrollContainer } from './widgets/ScrollContainer'; export { type DropSpot, ListView, type ListItem, type PickModifiers } from './widgets/ListView'; diff --git a/src/ui/widgets/Slider.ts b/src/ui/widgets/Slider.ts new file mode 100644 index 00000000..f121606a --- /dev/null +++ b/src/ui/widgets/Slider.ts @@ -0,0 +1,84 @@ +import { type FederatedPointerEvent, Graphics } from 'pixi.js'; +import { clamp, centreY } from '../math'; +import { Theme } from '../Theme'; +import { Widget } from '../Widget'; + +const TRACK_HEIGHT = 4; +const KNOB_RADIUS = 6; + +/** A value picked by dragging along a track. Reports every step of the drag, not just the end. */ +export class Slider extends Widget { + private readonly bar = new Graphics(); + private dragging = false; + onChange?: (value: number) => void; + + constructor( + private value = 0, + private min = 0, + private max = 1, + width = 160, + ) { + super(); + this.focusable = true; + this.cursor = Theme.hoverCursor; + this.minWidth = width; + this.minHeight = Theme.controlHeight; + this.addChild(this.bar); + this.trackHover(); + this.on('pointerdown', (e: FederatedPointerEvent) => { + this.dragging = true; + this.pick(e); + e.stopPropagation(); + }); + this.on('globalpointermove', (e: FederatedPointerEvent) => { + if (this.dragging) this.pick(e); + }); + this.onPointerRelease(() => { + this.dragging = false; + this.invalidate(); + }); + } + + /** Does not fire `onChange`, unlike a drag. */ + setValue(value: number): void { + const next = clamp(value, this.min, this.max); + if (next === this.value) return; + this.value = next; + this.invalidate(); + } + + setRange(min: number, max: number): void { + this.min = min; + this.max = max > min ? max : min + 1; + this.setValue(this.value); + this.invalidate(); + } + + get currentValue(): number { + return this.value; + } + + private pick(e: FederatedPointerEvent): void { + const span = Math.max(1, this.w - KNOB_RADIUS * 2); + const at = clamp((e.global.x - this.getGlobalPosition().x - KNOB_RADIUS) / span, 0, 1); + const next = this.min + at * (this.max - this.min); + if (next === this.value) return; + this.value = next; + this.invalidate(); + this.onChange?.(next); + } + + override redraw(): void { + const c = Theme.colors; + const y = centreY(this.h, TRACK_HEIGHT); + const span = Math.max(1, this.w - KNOB_RADIUS * 2); + const at = KNOB_RADIUS + ((this.value - this.min) / (this.max - this.min)) * span; + this.bar.clear(); + this.bar.rect(KNOB_RADIUS, y, span, TRACK_HEIGHT).fill(c.control); + this.bar.rect(KNOB_RADIUS, y, at - KNOB_RADIUS, TRACK_HEIGHT).fill(c.accent); + this.bar + .circle(at, this.h / 2, KNOB_RADIUS) + .fill(this.hovered || this.dragging ? c.controlHover : c.control) + .stroke({ width: 1, color: this.focused ? c.accent : c.border }); + } +} diff --git a/src/viewport/ParticleLibrary.ts b/src/viewport/ParticleLibrary.ts new file mode 100644 index 00000000..6bf14031 --- /dev/null +++ b/src/viewport/ParticleLibrary.ts @@ -0,0 +1,40 @@ +import type { ParticlesAsset } from '../core/model/Asset'; +import { compileParticles, type ParticlesBuild, particlesFingerprint } from '../core/particles/compile'; + +/** + * Compiled particle graphs, one per asset, recompiled when the graph's content changes – the same + * contract the `ShaderLibrary` keeps, with a clock of its own so a test can hold particle time + * still without freezing the shaders too. + */ +export class ParticleLibrary { + private readonly builds = new Map(); + /** How often a graph went through the compiler – what the checks read to prove a move did not. */ + compiles = 0; + private readonly started = performance.now(); + private frozenAt: number | null = null; + + /** Seconds since the library came up – the clock every source's age is measured on. */ + now(): number { + return this.frozenAt ?? (performance.now() - this.started) / 1000; + } + + freezeTime(at: number | null): void { + this.frozenAt = at; + } + + get frozen(): number | null { + return this.frozenAt; + } + + buildFor(asset: ParticlesAsset): ParticlesBuild { + const fingerprint = particlesFingerprint(asset.graph); + const cached = this.builds.get(asset.id); + if (cached && cached.fingerprint === fingerprint) return cached.build; + this.compiles += 1; + const build = compileParticles(asset.graph); + this.builds.set(asset.id, { fingerprint, build }); + return build; + } +} + +export const particleLibrary = new ParticleLibrary(); diff --git a/src/viewport/items.ts b/src/viewport/items.ts index a205d735..ddcbaff9 100644 --- a/src/viewport/items.ts +++ b/src/viewport/items.ts @@ -4,7 +4,8 @@ import { treeOrder } from '../core/model/hierarchy'; import { resolvedHolder } from '../core/model/instance'; import type { Mat } from '../core/model/matrix'; import type { PropertyBag } from '../core/model/Properties'; -import { findAsset, type ObjectHolder, type Project } from '../core/model/Project'; +import type { ParticlesAsset } from '../core/model/Asset'; +import { findAsset, findAssetOf, type ObjectHolder, type Project } from '../core/model/Project'; import { fontFileOf } from '../core/model/font'; import { resolveTexture, type TextureRef } from '../core/model/texture'; import { enabledAlongChain } from '../core/properties/enabled'; @@ -34,6 +35,10 @@ export interface DrawItem { texture: TextureRef | null; /** The typeface file a text wears, resolved here for the same reason; `''` is the default one. */ font: string; + /** The asset a particle source emits from, for the same reason again: it carries the graph. */ + particles: ParticlesAsset | null; + /** How often an `Emit` node has restarted this source; a change is what makes it start over. */ + emits: number; } interface DrawList { @@ -41,6 +46,12 @@ interface DrawList { background: number; } +/** Both kinds of object that name a picture in `texture`: the sprite that wears it and the source + * that throws it. */ +function wearsPicture(type: AssetType): boolean { + return type === 'sprite' || type === 'particles'; +} + /** * A scene or a container template, in tree order – the tie-break for equal `z` – at the editor * positions, with the content of placed containers resolved in. A container has no background of @@ -70,8 +81,10 @@ export function collectHolderItems( props, parentId: object.parentId, enabled: true, - texture: asset.type === 'sprite' ? resolveTexture(project, props) : null, + texture: wearsPicture(asset.type) ? resolveTexture(project, props) : null, font: asset.type === 'text' ? fontFileOf(project, fontOf(props)) : '', + particles: asset.type === 'particles' ? asset : null, + emits: 0, }); } const byId = new Map(items.map((i) => [i.id, i])); @@ -104,8 +117,10 @@ export function collectWorldItems(world: World): DrawList { parentId: e.parent?.objectId ?? '', enabled: true, // Resolved per frame on purpose: a `Properties` node may swap `texture`, an animation runs. - texture: e.type === 'sprite' ? resolveTexture(world.project, e.props, animationTime(e)) : null, + texture: wearsPicture(e.type) ? resolveTexture(world.project, e.props, animationTime(e)) : null, font: e.type === 'text' ? fontFileOf(world.project, fontOf(e.props)) : '', + particles: e.type === 'particles' ? (findAssetOf(world.project, e.assetId, 'particles') ?? null) : null, + emits: e.emits ?? 0, })); return { items, background: world.backgroundColor }; } diff --git a/src/viewport/particles.ts b/src/viewport/particles.ts new file mode 100644 index 00000000..48cb2c8b --- /dev/null +++ b/src/viewport/particles.ts @@ -0,0 +1,179 @@ +import { Particle, ParticleContainer, type Texture } from 'pixi.js'; +import { type CompiledParticles, emptyParticleOut, type ParticleOut } from '../core/particles/compile'; +import { birthTime, type EmitterSettings, liveRange } from '../core/particles/emission'; +import type { ParticleEnv } from '../core/particles/nodes'; +import { type Mat, scaleOf } from '../core/model/matrix'; +import { booleanOf, type PropertyBag } from '../core/model/Properties'; +import { + BURST_COUNT_KEY, + emitModeOf, + EMITTING_KEY, + LIFETIME_KEY, + MAX_PARTICLES_KEY, + MOVE_WITH_SOURCE_KEY, + PARTICLE_COLOR_KEY, + PARTICLE_SIZE_KEY, + RATE_KEY, +} from '../core/properties/particles'; + +const DEG = Math.PI / 180; + +export function emitterSettings(props: PropertyBag): EmitterSettings { + return { + mode: emitModeOf(props), + rate: numberOf(props[RATE_KEY], 20), + count: numberOf(props[BURST_COUNT_KEY], 20), + lifetime: numberOf(props[LIFETIME_KEY], 1), + max: Math.max(1, numberOf(props[MAX_PARTICLES_KEY], 500)), + }; +} + +function numberOf(value: unknown, fallback: number): number { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} + +/** + * One particle source as it is drawn: a Pixi `ParticleContainer`, which puts all of them in a single + * draw call, plus the little state a formula cannot hold – when emission started and, for particles + * left behind in the world, where their source stood when they were born. + */ +export class ParticleField { + readonly container = new ParticleContainer({ + dynamicProperties: { position: true, vertex: true, rotation: true, color: true, uvs: false }, + }); + private readonly out: ParticleOut = emptyParticleOut(); + private readonly pool: Particle[] = []; + /** Seconds, on the library's clock, at which this source last started emitting. */ + private startedAt: number | null = null; + /** How far it was still taking in new particles: frozen while `emitting` is off. */ + private emittedUntil = 0; + /** The source's transform when each particle was born, kept per index modulo the capacity. */ + private born: Mat[] = []; + private capacity = 0; + private highest = -1; + /** The Emit count last seen; a change is a request to start over. */ + private emits = -1; + + constructor() { + this.container.eventMode = 'none'; + } + + /** Seconds the source has been emitting; -1 before it ever started. */ + elapsed(now: number): number { + return this.startedAt === null ? -1 : now - this.startedAt; + } + + /** Starts the clock over: a burst goes out again, a continuous stream begins at particle 0. */ + restart(now: number): void { + this.startedAt = now; + this.emittedUntil = 0; + this.reset(); + } + + private reset(): void { + this.highest = -1; + this.born = []; + this.capacity = 0; + } + + /** + * Brings the drawn particles up to `now`. Everything about one particle comes out of `compiled`; + * the only thing read from a frame is how far the clock has moved. + */ + sync( + compiled: CompiledParticles | null, + props: PropertyBag, + params: readonly number[], + m: Mat, + texture: Texture, + now: number, + emits = 0, + /** Mixed into every particle's seed, so two sources of one asset do not scatter identically. */ + salt = 0, + ): void { + const emitting = booleanOf(props[EMITTING_KEY] ?? true); + if (this.emits !== emits) { + // Not on the first sight of a source: that would throw its first burst away again. + if (this.emits >= 0) this.restart(now); + this.emits = emits; + } + if (this.startedAt === null) this.startedAt = now; + const time = now - this.startedAt; + if (emitting) this.emittedUntil = time; + + const settings = emitterSettings(props); + const range = compiled ? liveRange(settings, time, this.emittedUntil) : { from: 0, to: -1 }; + const capacity = Math.max(1, Math.floor(settings.max)); + if (capacity !== this.capacity) { + this.capacity = capacity; + this.born = []; + this.highest = -1; + } + // Stepping back in time (the tab's slider) leaves birth places that no longer belong to anyone. + if (range.to < this.highest) this.highest = range.from - 1; + for (let i = Math.max(this.highest + 1, range.from); i <= range.to; i++) this.born[i % capacity] = m; + this.highest = Math.max(this.highest, range.to); + + const moves = booleanOf(props[MOVE_WITH_SOURCE_KEY] ?? false); + const size = Math.max(0.01, numberOf(props[PARTICLE_SIZE_KEY], 16)); + const sourceColor = numberOf(props[PARTICLE_COLOR_KEY], 0xffffff); + const baseW = texture.width || 1; + const baseH = texture.height || 1; + const env: ParticleEnv = { + age: 0, + ageSeconds: 0, + time, + index: 0, + seed: 0, + color: sourceColor, + params, + }; + + // The shader samples the container's own texture, not each particle's, so it has to be set here. + this.container.texture = texture; + const particles = this.container.particleChildren; + particles.length = 0; + if (compiled) + for (let index = range.from; index <= range.to; index++) { + const ageSeconds = time - birthTime(settings, index); + env.index = index; + env.seed = index ^ salt; + env.ageSeconds = ageSeconds; + env.age = settings.lifetime > 0 ? ageSeconds / settings.lifetime : 0; + compiled.evaluate(env, this.out); + const place = moves ? m : (this.born[index % capacity] ?? m); + const p = this.take(particles.length, texture); + p.texture = texture; + p.x = place.a * this.out.x + place.c * this.out.y + place.tx; + p.y = place.b * this.out.x + place.d * this.out.y + place.ty; + const scale = scaleOf(place); + p.scaleX = (size * this.out.scaleX * scale) / baseW; + p.scaleY = (size * this.out.scaleY * scale) / baseH; + p.rotation = this.out.rotation * DEG + Math.atan2(place.b, place.a); + p.tint = this.out.color; + p.alpha = this.out.alpha; + particles.push(p); + } + this.container.update(); + } + + /** Particles are reused rather than rebuilt: a stream of 500 would otherwise allocate 500 a frame. */ + private take(slot: number, texture: Texture): Particle { + let p = this.pool[slot]; + if (!p) { + p = new Particle({ texture, anchorX: 0.5, anchorY: 0.5 }); + this.pool[slot] = p; + } + return p; + } + + /** How many are drawn right now – what the checks read. */ + get count(): number { + return this.container.particleChildren.length; + } + + destroy(): void { + this.container.destroy(); + } +} diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts index e4fde9d0..1e710938 100644 --- a/src/viewport/symbols.ts +++ b/src/viewport/symbols.ts @@ -84,6 +84,9 @@ export function drawItem( node.clear(); node.hitArea = null; switch (item.type) { + // A source draws nothing of its own – what is seen of it are its particles. It still owns the + // square around its position, so it can be clicked and dragged like anything else. + case 'particles': case 'sprite': node.hitArea = new Rectangle(-size.w / 2, -size.h / 2, size.w, size.h); return; diff --git a/tests/core/graph.test.ts b/tests/core/graph.test.ts index 75b1b479..0560984a 100644 --- a/tests/core/graph.test.ts +++ b/tests/core/graph.test.ts @@ -74,8 +74,8 @@ describe('node registry', () => { expect(new Set(def.outputs.map((p) => p.key)).size).toBe(def.outputs.length); // An event is seeded by the runtime, a state is a name in a machine: neither runs. if (def.category === 'event' || def.category === 'state') expect(def.inputs).toHaveLength(0); - // A shader node is compiled to GLSL, never run. - else if (def.category === 'shader') expect('emit' in def).toBe(true); + // A shader node is compiled to GLSL and a particle node to a function: neither is ever run. + else if (def.category === 'shader' || def.category === 'particles') expect('emit' in def).toBe(true); else expect(def.run).toBeTypeOf('function'); expect(keys.every((k) => k.length > 0)).toBe(true); } diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 358e4e1a..d08ebf10 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -56,6 +56,8 @@ const run = (type: string, c: Case): { result: RunResult; ctx: FakeContext } => }; const at = (name: string, x: number, y: number) => ({ ...fakeEntity(name), x, y }); +/** Its own entity, because Emit's whole effect is the count it leaves on the one it is handed. */ +const emitTarget = fakeEntity('Sparks'); const painted = fakeHandle({ width: 100, text: '' }); const pointer = (overrides: Partial): Partial => ({ @@ -647,6 +649,19 @@ const CASES: Record = { next: 'exec', }, ], + Emit: [ + { + name: 'no source is a no-op that still continues the flow', + inputs: { source: null }, + next: 'exec', + }, + { + name: 'counts a pulse on the source it is given', + inputs: { source: emitTarget }, + next: 'exec', + after: () => expect(emitTarget.emits).toBe(1), + }, + ], Log: [ { name: 'logs its message and continues', diff --git a/tests/core/particles.test.ts b/tests/core/particles.test.ts new file mode 100644 index 00000000..4a6d0126 --- /dev/null +++ b/tests/core/particles.test.ts @@ -0,0 +1,335 @@ +/** + * A particle asset is a graph that says what one particle *is*, as a function of its age and of a + * random number drawn once for it – not what forces act on it. That choice is what these tests + * pin: the same second always looks the same, and the whole field is a function of the clock. + */ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import { AddNodeCommand, ConnectCommand, MoveNodeCommand } from '../../src/core/commands/graphCommands'; +import { isPlaceableType } from '../../src/core/model/AssetTypes'; +import { findAssetOf } from '../../src/core/model/Project'; +import { + compileParticles, + emptyParticleOut, + type ParticleOut, + particlesFingerprint, +} from '../../src/core/particles/compile'; +import { birthTime, liveCount, liveRange } from '../../src/core/particles/emission'; +import { + hashId, + mixColor, + PARTICLE_NODES, + PARTICLE_OUTPUT, + PARTICLE_OUTPUTS, + PARTICLE_RANDOM, + type ParticleEnv, +} from '../../src/core/particles/nodes'; +import { + BURST_COUNT_KEY, + EMITTING_KEY, + LIFETIME_KEY, + MAX_PARTICLES_KEY, + PARTICLE_PROPERTY, + particleParamDefs, + RATE_KEY, +} from '../../src/core/properties/particles'; +import { schemaFor, schemaForAsset } from '../../src/core/properties/PropertySchema'; +import { paletteEntries } from '../../src/editor/graph/paletteEntries'; +import { makeProject, type ProjectFixture, roundTrip } from '../helpers'; + +/** Command-driven editing of a particle asset's graph, the way the tab does it. */ +function particlesEditor(f: ProjectFixture, name = 'Sparks') { + const create = new CreateAssetCommand('particles', name); + f.stack.execute(create); + const id = create.asset.id; + const asset = () => findAssetOf(f.project, id, 'particles')!; + const add = (type: string, params = {}) => { + const command = new AddNodeCommand(id, type, 0, 0, params); + f.stack.execute(command); + return command.node.id; + }; + const link = (from: string, fromPort: string, to: string, toPort: string) => + f.stack.execute(new ConnectCommand(id, { node: from, port: fromPort }, { node: to, port: toPort })); + return { id, asset, add, link }; +} + +const env = (over: Partial = {}): ParticleEnv => ({ + age: 0, + ageSeconds: 0, + time: 0, + index: 0, + seed: 0, + color: 0xffffff, + params: [], + ...over, +}); + +function run(p: ReturnType, at: Partial): ParticleOut { + const build = compileParticles(p.asset().graph); + expect(build.reason).toBe(''); + const out = emptyParticleOut(); + build.compiled!.evaluate(env(at), out); + return out; +} + +describe('a particle asset', () => { + it('is placeable, carries a graph, and starts out with nothing to compile', () => { + const f = makeProject(); + const p = particlesEditor(f); + expect(isPlaceableType('particles')).toBe(true); + expect(p.asset().graph).toEqual({ nodes: [], edges: [] }); + expect(compileParticles(p.asset().graph).reason).toBe('No Output node'); + // The source's own rows are there from the start; the graph adds to them. + expect(schemaFor('particles').map((d) => d.key)).toContain(RATE_KEY); + expect(schemaFor('particles').map((d) => d.key)).toContain(EMITTING_KEY); + }); + + it('leaves every unconnected output at what a particle without a graph would be', () => { + const f = makeProject(); + const p = particlesEditor(f); + p.add(PARTICLE_OUTPUT); + const out = run(p, { age: 0.5, color: 0x336699 }); + expect(out).toEqual({ + x: 0, + y: 0, + scaleX: 1, + scaleY: 1, + rotation: 0, + // Unconnected, the colour is the source's own row – not a hard-coded white. + color: 0x336699, + alpha: 1, + }); + }); + + it('fades a particle out over its life when Age feeds alpha through Subtract', () => { + const f = makeProject(); + const p = particlesEditor(f); + const output = p.add(PARTICLE_OUTPUT); + const age = p.add('ParticleAge'); + const fade = p.add('ParticleSubtract', { a: 1 }); + p.link(age, 'age', fade, 'b'); + p.link(fade, 'result', output, 'alpha'); + expect(run(p, { age: 0 }).alpha).toBe(1); + expect(run(p, { age: 0.25 }).alpha).toBe(0.75); + expect(run(p, { age: 1 }).alpha).toBe(0); + }); + + it('falls by ½gt², which is the whole point of a formula instead of a force', () => { + const f = makeProject(); + const p = particlesEditor(f); + const output = p.add(PARTICLE_OUTPUT); + const gravity = p.add('ParticleGravity', { strength: 800 }); + p.link(gravity, 'y', output, 'y'); + expect(run(p, { ageSeconds: 0 }).y).toBe(0); + expect(run(p, { ageSeconds: 0.5 }).y).toBe(100); + expect(run(p, { ageSeconds: 1 }).y).toBe(400); + }); + + it('aims Start velocity by degrees, 0 to the right and 90 down', () => { + const f = makeProject(); + const p = particlesEditor(f); + const output = p.add(PARTICLE_OUTPUT); + const v = p.add('ParticleStartVelocity', { angle: 90, speed: 200 }); + p.link(v, 'x', output, 'x'); + p.link(v, 'y', output, 'y'); + const out = run(p, { ageSeconds: 0.5 }); + expect(out.x).toBeCloseTo(0, 10); + expect(out.y).toBeCloseTo(100, 10); + }); + + it('draws one random number per particle and a different one per Random node', () => { + const f = makeProject(); + const p = particlesEditor(f); + const output = p.add(PARTICLE_OUTPUT); + const a = p.add(PARTICLE_RANDOM, { min: 0, max: 100 }); + const b = p.add(PARTICLE_RANDOM, { min: 0, max: 100 }); + p.link(a, 'value', output, 'x'); + p.link(b, 'value', output, 'y'); + const first = run(p, { seed: 7, ageSeconds: 0 }); + // The same particle at a later moment: its draw has not moved. + const later = run(p, { seed: 7, ageSeconds: 9, age: 0.9 }); + expect(later.x).toBe(first.x); + // Two nodes, two draws – otherwise direction and speed of one particle would be the same number. + expect(first.y).not.toBe(first.x); + // A different particle draws differently. + expect(run(p, { seed: 8 }).x).not.toBe(first.x); + // And so does the same index at another source: the drawing party's salt goes into the seed, + // which is what keeps two fountains of one asset from being the same fountain twice. + expect(run(p, { seed: 7 ^ hashId('source-b') }).x).not.toBe(first.x); + }); + + it('feeds one node into two without evaluating it twice differently', () => { + const f = makeProject(); + const p = particlesEditor(f); + const output = p.add(PARTICLE_OUTPUT); + const r = p.add(PARTICLE_RANDOM, { min: 0, max: 100 }); + p.link(r, 'value', output, 'x'); + p.link(r, 'value', output, 'y'); + const out = run(p, { seed: 3 }); + expect(out.x).toBe(out.y); + }); + + it('declares a row per Property node and reads its value from the source', () => { + const f = makeProject(); + const p = particlesEditor(f); + const output = p.add(PARTICLE_OUTPUT); + p.add(PARTICLE_PROPERTY, { name: 'speed', default: 3, min: 0, max: 10 }); + const second = p.add(PARTICLE_PROPERTY, { name: 'spread', default: 1, min: 0, max: 2 }); + p.link(second, 'value', output, 'x'); + const rows = particleParamDefs(p.asset().graph); + expect(rows.map((r) => r.key)).toEqual(['speed', 'spread']); + expect(schemaForAsset(f.project, p.asset()).map((d) => d.key)).toEqual([ + ...schemaFor('particles').map((d) => d.key), + 'speed', + 'spread', + ]); + // `spread` is the second row, so it reads slot 1 – not slot 0, and not by name per particle. + expect(run(p, { params: [99, 42] }).x).toBe(42); + }); + + it('refuses a parameter named after one of the source’s own rows', () => { + const f = makeProject(); + const p = particlesEditor(f); + p.add(PARTICLE_OUTPUT); + const bad = p.add(PARTICLE_PROPERTY, { name: RATE_KEY }); + const build = compileParticles(p.asset().graph); + expect(build.compiled).toBeNull(); + expect(build.problems[bad]).toContain('Name must be one word'); + expect(particleParamDefs(p.asset().graph)).toEqual([]); + }); + + it('names the node that feeds itself instead of hanging', () => { + const f = makeProject(); + const p = particlesEditor(f); + const output = p.add(PARTICLE_OUTPUT); + const a = p.add('ParticleAdd'); + const b = p.add('ParticleAdd'); + p.link(a, 'result', b, 'a'); + p.link(b, 'result', a, 'a'); + p.link(a, 'result', output, 'x'); + const build = compileParticles(p.asset().graph); + expect(build.compiled).toBeNull(); + expect(Object.values(build.problems)).toContain('Feeds itself'); + }); + + it('keeps the fingerprint when a node only moves, and changes it on a parameter', () => { + const f = makeProject(); + const p = particlesEditor(f); + const output = p.add(PARTICLE_OUTPUT); + const n = p.add('ParticleNumber', { value: 2 }); + p.link(n, 'value', output, 'x'); + const before = particlesFingerprint(p.asset().graph); + f.stack.execute(new MoveNodeCommand(p.id, n, 120, 80)); + expect(particlesFingerprint(p.asset().graph)).toBe(before); + f.stack.execute(new AddNodeCommand(p.id, 'ParticleNumber', 0, 0, { value: 5 })); + expect(particlesFingerprint(p.asset().graph)).not.toBe(before); + }); + + it('survives a round trip through the project file, graph and rows intact', () => { + const f = makeProject(); + const p = particlesEditor(f); + const output = p.add(PARTICLE_OUTPUT); + p.add(PARTICLE_PROPERTY, { name: 'speed', default: 3, min: 0, max: 10 }); + const g = p.add('ParticleGravity', { strength: 400 }); + p.link(g, 'y', output, 'y'); + const back = roundTrip(f.project); + const asset = findAssetOf(back, p.id, 'particles')!; + expect(asset.graph.nodes).toHaveLength(3); + expect(asset.graph.edges).toHaveLength(1); + expect(particleParamDefs(asset.graph).map((r) => r.key)).toEqual(['speed']); + const out = emptyParticleOut(); + compileParticles(asset.graph).compiled!.evaluate(env({ ageSeconds: 1 }), out); + expect(out.y).toBe(200); + }); + + it('offers only particle nodes in its palette, and the Output only while there is none', () => { + const f = makeProject(); + const p = particlesEditor(f); + const before = paletteEntries(f.project, p.asset()); + expect(before.every((e) => e.def.category === 'particles')).toBe(true); + expect(before.map((e) => e.id)).toContain(PARTICLE_OUTPUT); + expect(before).toHaveLength(PARTICLE_NODES.length); + // A behavior node has no business here, and neither has a shader one. + expect(before.map((e) => e.id)).not.toContain('Branch'); + p.add(PARTICLE_OUTPUT); + expect(paletteEntries(f.project, p.asset()).map((e) => e.id)).not.toContain(PARTICLE_OUTPUT); + }); + + it('numbers the Property nodes it places from the palette', () => { + const f = makeProject(); + const p = particlesEditor(f); + const first = paletteEntries(f.project, p.asset()).find((e) => e.id === PARTICLE_PROPERTY)!; + expect(first.params.name).toBe('value'); + p.add(PARTICLE_PROPERTY, first.params); + const second = paletteEntries(f.project, p.asset()).find((e) => e.id === PARTICLE_PROPERTY)!; + expect(second.params.name).toBe('value2'); + }); +}); + +describe('when particles are alive', () => { + const settings = { + mode: 'continuous' as const, + rate: 10, + count: 20, + lifetime: 1, + max: 500, + }; + + it('puts a continuous source on a fixed grid, so the field is a function of the clock', () => { + // Particle n is born at n/rate: at 2 s exactly, particles 11..20 are the ones within a lifetime. + expect(birthTime(settings, 15)).toBe(1.5); + expect(liveRange(settings, 2)).toEqual({ from: 11, to: 20 }); + expect(liveCount(liveRange(settings, 2))).toBe(10); + // Before the first one is due there is nothing, and one frame in there is exactly one. + expect(liveCount(liveRange(settings, 0))).toBe(1); + expect(liveCount(liveRange(settings, 0.05))).toBe(1); + }); + + it('drops the oldest when the cap is reached, never the newest', () => { + expect(liveRange({ ...settings, max: 4 }, 2)).toEqual({ from: 17, to: 20 }); + }); + + it('lets the ones in flight live out their lives after emission stops', () => { + // Emission stopped at 1 s; at 1.5 s only what was born after 0.5 s is still alive. + expect(liveRange(settings, 1.5, 1)).toEqual({ from: 6, to: 10 }); + // A whole lifetime after the stop there is nothing left. + expect(liveCount(liveRange(settings, 2.01, 1))).toBe(0); + }); + + it('sends a burst out at once and ends it one lifetime later', () => { + const burst = { ...settings, mode: 'burst' as const, count: 30 }; + expect(birthTime(burst, 29)).toBe(0); + expect(liveRange(burst, 0.5)).toEqual({ from: 0, to: 29 }); + expect(liveCount(liveRange(burst, 1))).toBe(0); + // The cap holds for a burst too. + expect(liveRange({ ...burst, max: 8 }, 0.5)).toEqual({ from: 0, to: 7 }); + }); + + it('emits nothing without a rate, a lifetime or a future', () => { + expect(liveCount(liveRange({ ...settings, rate: 0 }, 2))).toBe(0); + expect(liveCount(liveRange({ ...settings, lifetime: 0 }, 2))).toBe(0); + expect(liveCount(liveRange(settings, -1))).toBe(0); + }); +}); + +describe('a particle colour', () => { + it('blends channel by channel, which a plain number mix cannot', () => { + expect(mixColor(0xff0000, 0x0000ff, 0)).toBe(0xff0000); + expect(mixColor(0xff0000, 0x0000ff, 1)).toBe(0x0000ff); + // Half way is dark magenta – mixing the packed numbers would have run through green. + expect(mixColor(0xff0000, 0x0000ff, 0.5)).toBe(0x800080); + expect(mixColor(0x000000, 0xffffff, 2)).toBe(0xffffff); + }); +}); + +describe('the source rows', () => { + it('name every setting the emission rule reads', () => { + const keys = schemaFor('particles').map((d) => d.key); + for (const key of [RATE_KEY, BURST_COUNT_KEY, LIFETIME_KEY, MAX_PARTICLES_KEY, EMITTING_KEY]) + expect(keys).toContain(key); + }); + + it('has one Output input per drawn value', () => { + expect([...PARTICLE_OUTPUTS]).toEqual(['x', 'y', 'scaleX', 'scaleY', 'rotation', 'color', 'alpha']); + }); +}); diff --git a/tools/e2e/14-overflow.mjs b/tools/e2e/14-overflow.mjs index 56575c1a..6403c09d 100644 --- a/tools/e2e/14-overflow.mjs +++ b/tools/e2e/14-overflow.mjs @@ -17,6 +17,7 @@ const LABELS = { animation: 'Animation', container: 'Container', list: 'List', + particles: 'Particles', }; export async function overflow(t) { @@ -63,9 +64,16 @@ export async function overflow(t) { const wide = await json('__gm.tabRow()'); check( 'every tab button ends inside the centre column', - spillOf(wide, await columnRight()).length === 0 && wide.hidden.length === 0, + spillOf(wide, await columnRight()).length === 0, JSON.stringify({ spilled: spillOf(wide, await columnRight()), hidden: wide.hidden }), ); + // The tenth tab (Particles, #73) is the case #84 was built for: 631 px of nine tabs already left + // 35 px in a 1280 px window, so at ten the row has to put something away even undisturbed. + check( + 'at the test window’s width the row already needs its overflow, and still shows what is edited', + wide.hidden.length > 0 && wide.shown.some((b) => b.tab === 'scene'), + JSON.stringify({ hidden: wide.hidden, shown: wide.shown.map((b) => b.tab) }), + ); // 14b-ii. Narrowed until the row cannot hold nine tabs, the leftovers go behind the overflow // button instead of under the inspector – the guard against the tenth tab (#84). @@ -113,13 +121,13 @@ export async function overflow(t) { await sleep(300); const back = await json('__gm.tabRow()'); check( - 'giving the room back brings every tab into the row', - back.hidden.length === 0 && spillOf(back, await columnRight()).length === 0, - JSON.stringify({ hidden: back.hidden }), + 'giving the room back brings tabs into the row again, and none past the edge', + back.hidden.length < narrow.hidden.length && spillOf(back, await columnRight()).length === 0, + JSON.stringify({ hidden: back.hidden, narrow: narrow.hidden }), ); // 14c. Every tab, because each one owns a different set of controls. - for (const tab of ['scene', 'graph', 'texture', 'game', 'container', 'list']) { + for (const tab of ['scene', 'graph', 'texture', 'game', 'container', 'list', 'particles']) { await evaluate(`__gm.showTab(${JSON.stringify(tab)})`); await sleep(400); const spills = await json('__gm.textOverflows()'); @@ -152,7 +160,7 @@ export async function overflow(t) { // 14f. The other direction: a control narrow enough to swallow its whole label draws nothing // at all, which the overflow walk cannot see – an empty text spills over no edge. - for (const tab of ['scene', 'graph', 'texture', 'container', 'list']) { + for (const tab of ['scene', 'graph', 'texture', 'container', 'list', 'particles']) { await evaluate(`__gm.showTab(${JSON.stringify(tab)})`); await sleep(400); const dropped = await json('__gm.labelsDropped()'); diff --git a/tools/e2e/31-particles.mjs b/tools/e2e/31-particles.mjs new file mode 100644 index 00000000..db24f404 --- /dev/null +++ b/tools/e2e/31-particles.mjs @@ -0,0 +1,332 @@ +import { LANES } from './lib/scaffold.mjs'; + +/** + * Particle assets (#73): a graph that says what one particle *is* as a function of its age and of a + * random number drawn once for it. Because nothing is simulated, the whole field is a function of + * the clock – which is what lets every check here freeze particle time and read an exact second + * instead of sleeping and hoping. + * + * The source lives in its own lane (`__gm.lookAt`): the origin is crowded by what the earlier steps + * leave behind, and symbols draw above objects. + */ +const LANE = LANES.particles; +const SOURCE = 'pt-source'; +/** The type's defaults: 20 a second, each living one second, 500 at most. */ +const RATE = 20; + +export async function particles(t) { + const { check, sleep, json, evaluate, mutate, click, clickText, locate, typeText, press, shot } = t; + const { wheel } = t; + const { select } = t; + /** Freeze, restart every source's clock, then step the clock to `seconds` and let it redraw. */ + const at = async (seconds) => { + await evaluate(`__gm.particleTime(${1000 + seconds})`); + await sleep(150); + }; + const rewind = async () => { + await evaluate('__gm.particleTime(1000)'); + await sleep(120); + await evaluate('__gm.particlesRestart()'); + await sleep(120); + }; + const drawn = (limit = 8) => json(`__gm.particlesOf(${JSON.stringify(SOURCE)}, 'scene', ${limit})`); + const clickLast = async (text) => { + const p = await locate(text, true); + await click(p.x, p.y); + await sleep(200); + }; + /** + * The `+` menu has one entry per asset type and scrolls past 320 px, so the last of them sits + * under the mask: `locate` still finds it, but a click there lands on nothing. Scroll it in first. + */ + const pickFromMenu = async (label) => { + const box = await json('__gm.overlayRect()'); + let p = await locate(label, true); + let scrolled = false; + if (box && (!p || p.y > box.y + box.h - 4)) { + await wheel(box.x + box.w / 2, box.y + box.h / 2, 300); + await sleep(250); + p = await locate(label, true); + scrolled = true; + } + await click(p.x, p.y); + await sleep(250); + return { box, p, scrolled }; + }; + /** Places a node from the palette. The list scrolls, so the name is searched into view first. */ + const placeNode = async (label, dx, dy) => { + const canvas = await json('__gm.particles.canvasRect()'); + await click(canvas.x + dx, canvas.y + dy, 'right'); + await sleep(300); + await typeText(label); + await sleep(200); + await clickLast(label); + await sleep(300); + }; + const nodeIdOf = (type) => + json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(assetId)}) + .graph.nodes.find((n) => n.type === ${JSON.stringify(type)})?.id ?? ''`); + /** + * Adds a node straight to the graph. The palette is proved by the two nodes placed through it + * above; the tab's canvas is short (the preview strip takes the lower part), so dropping five + * more through it would be a test of where they happen to land rather than of what they do. + */ + const addNode = async (id, type, params = {}) => { + await mutate(` + __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(assetId)}).graph.nodes.push({ + id: ${JSON.stringify(id)}, type: ${JSON.stringify(type)}, x: 0, y: 0, + params: ${JSON.stringify(params)} });`); + await sleep(150); + }; + const link = async (id, from, fromPort, to, toPort) => { + await mutate(` + __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(assetId)}).graph.edges.push({ + id: ${JSON.stringify(id)}, from: { node: ${JSON.stringify(from)}, port: ${JSON.stringify(fromPort)} }, + to: { node: ${JSON.stringify(to)}, port: ${JSON.stringify(toPort)} } });`); + await sleep(150); + }; + let assetId = ''; + + await press('Escape'); + await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); + await evaluate('__gm.state.select(null)'); + await evaluate("__gm.showTab('scene')"); + await sleep(200); + + // 31a. "+ → Particles" makes the asset and opens its tab; with no Output there is nothing to run. + await evaluate('__gm.console.clear()'); + await clickText('+'); + await sleep(200); + const picked = await pickFromMenu('Particles'); + await sleep(400); + const created = await json(`{ + tab: __gm.tab(), + current: __gm.particles.current(), + info: __gm.particles.info(), + assets: __gm.state.project.assets.filter((a) => a.type === 'particles').map((a) => a.id), + }`); + check( + 'the + menu creates a particle asset and opens the Particles tab on it, with nothing to compile', + created.tab === 'particles' && + created.assets.length === 1 && + created.current === created.assets[0] && + created.info === 'No Output node', + JSON.stringify({ ...created, picked }), + ); + if (created.assets.length !== 1) return; + assetId = created.assets[0]; + // Renamed away from "Particles": the tab button says that too, and a lookup by text would find it. + await mutate(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(assetId)}).name = 'Sparks'`); + await sleep(200); + + // 31b. The palette of this tab holds particle nodes and nothing else. + const canvas = await json('__gm.particles.canvasRect()'); + await click(canvas.x + 80, canvas.y + 60, 'right'); + await sleep(300); + const palette = await json('__gm.overlayTexts()'); + check( + 'the palette offers particle nodes, not behavior ones', + palette.includes('Output') && palette.includes('Gravity') && !palette.includes('Branch'), + JSON.stringify(palette.slice(0, 12)), + ); + await press('Escape'); + await sleep(200); + + // 31c. One Output node is a whole graph: a particle without rules is one at the source, full size. + await placeNode('Output', 460, 60); + const withOutput = await json('{ info: __gm.particles.info(), preview: __gm.particles.preview() }'); + check( + 'an Output node alone compiles, and the strip below the graph emits', + withOutput.info === '0 parameters' && withOutput.preview.count > 0, + JSON.stringify(withOutput), + ); + + // 31d. The slider jumps to a second exactly: 20 a second for one second means 0..10 are alive + // at 0.5 s – eleven of them, whatever the frame rate was on the way there. + await evaluate('__gm.particles.hold(0.5)'); + await sleep(200); + const held = await json('__gm.particles.preview()'); + check( + 'holding the preview at 0.5 s shows exactly the particles due by then, not a frame-rate count', + held.holding === true && held.time === 0.5 && held.count === RATE / 2 + 1, + JSON.stringify(held), + ); + await shot('31-particles-tab'); + await evaluate('__gm.particles.hold(null)'); + + // 31e. Placed in the scene, the same asset is a source with its own rows. + await mutate(` + __gm.state.activeScene.objects.push({ id: ${JSON.stringify(SOURCE)}, assetId: ${JSON.stringify(assetId)}, + name: 'Fountain', position: { x: 0, y: ${LANE}, z: 0 }, editorOffset: { x: 0, y: 0 }, + overrides: {}, parentId: '', instanceEdits: {} }); + __gm.lookAt(0, ${LANE});`); + await evaluate("__gm.showTab('scene')"); + await sleep(400); + await rewind(); + await at(0.5); + const inScene = await drawn(); + check( + 'the placed source draws the same field in the scene, on the same clock', + Boolean(inScene) && inScene.count === RATE / 2 + 1, + JSON.stringify(inScene && { count: inScene.count, first: inScene.drawn[0] }), + ); + if (!inScene) return; + + // 31f. Gravity is ½gt², not a force stepped frame by frame: the oldest particle, half a second + // old, is exactly 100 px below the source at strength 800. + await evaluate("__gm.showTab('particles')"); + await sleep(250); + await placeNode('Gravity', 80, 60); + const gravityId = await nodeIdOf('ParticleGravity'); + const outputId = await nodeIdOf('ParticleOutput'); + await mutate(` + const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(assetId)}).graph; + graph.edges.push({ id: 'pt-e1', from: { node: ${JSON.stringify(gravityId)}, port: 'y' }, + to: { node: ${JSON.stringify(outputId)}, port: 'y' } });`); + await evaluate("__gm.showTab('scene')"); + await sleep(300); + await rewind(); + await at(0.5); + const falling = await drawn(2); + const oldest = falling?.drawn[0]; + check( + 'a Gravity node puts the oldest particle exactly ½·800·t² below the source', + Boolean(oldest) && Math.abs(oldest.y - (LANE + 100)) < 0.5 && Math.abs(oldest.x) < 0.5, + JSON.stringify({ lane: LANE, oldest }), + ); + + // 31g. The same second twice is the same picture – the promise a formula makes and a simulation + // cannot: stepping the clock away and back lands on the very same numbers. + await at(1.4); + await at(0.5); + const again = await drawn(2); + check( + 'stepping the clock away and back reproduces the field exactly', + JSON.stringify(again?.drawn) === JSON.stringify(falling?.drawn), + JSON.stringify({ first: falling?.drawn[0], again: again?.drawn[0] }), + ); + + // 31h. Age → alpha: the old ones are the faint ones. Age is 0..1 over the lifetime, so + // Subtract(1, age) is a fade-out. + await addNode('pt-sub', 'ParticleSubtract', { a: 1 }); + await addNode('pt-age', 'ParticleAge'); + await link('pt-e2', 'pt-age', 'age', 'pt-sub', 'b'); + await link('pt-e3', 'pt-sub', 'result', outputId, 'alpha'); + await sleep(300); + await rewind(); + await at(0.5); + const fading = await drawn(11); + const first = fading?.drawn[0]; + const last = fading?.drawn.at(-1); + check( + 'Age into alpha fades the old ones out: the oldest is at 0.5, the newest at 1', + Boolean(first && last) && Math.abs(first.alpha - 0.5) < 0.02 && Math.abs(last.alpha - 1) < 0.02, + JSON.stringify({ oldest: first?.alpha, newest: last?.alpha }), + ); + + // 31i. A Property node is a row on the placed source, like a shader parameter on a sprite. + await addNode('pt-prop', 'ParticleProperty', { name: 'drift', default: 0, min: -200, max: 200 }); + await link('pt-e4', 'pt-prop', 'value', outputId, 'x'); + await sleep(300); + await select({ kind: 'object', holderId: 'scene1', objectIds: [SOURCE], objectId: SOURCE }); + await sleep(350); + const driftRow = await json("__gm.inspectorField('drift')"); + const rateRow = await json("__gm.inspectorField('Rate')"); + check( + 'the graph’s Property becomes a row on the source, below its own rows', + Boolean(driftRow) && Boolean(rateRow) && driftRow.y > rateRow.y, + JSON.stringify({ drift: driftRow, rate: rateRow }), + ); + await mutate(` + __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}).overrides.drift = 60;`); + await sleep(250); + await rewind(); + await at(0.5); + const drifted = await drawn(2); + check( + 'setting that row on the source moves its particles', + Math.abs((drifted?.drawn[0]?.x ?? 0) - 60) < 0.5, + JSON.stringify(drifted?.drawn[0]), + ); + + // 31j. Emitting off keeps the ones in flight and takes in no new ones; a lifetime later the + // field is empty, and switching it back on fills it again. + await mutate(` + __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}).overrides.emitting = false;`); + await at(0.6); + const stopping = await drawn(1); + await at(1.6); + const stopped = await drawn(1); + check( + 'switching Emitting off lets the ones in flight live out their lives, then leaves nothing', + (stopping?.count ?? 0) > 0 && stopped?.count === 0, + JSON.stringify({ stopping: stopping?.count, stopped: stopped?.count }), + ); + await mutate(` + __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}).overrides.emitting = true;`); + await sleep(200); + await rewind(); + await at(0.5); + check('switching it back on fills the field again', (await drawn(1))?.count === RATE / 2 + 1, ''); + + // 31k. Burst: every particle at once, so they all share one age – and they end together. + await mutate(` + const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); + Object.assign(o.overrides, { mode: 'burst', count: 7 });`); + await sleep(200); + await rewind(); + await at(0.5); + const burst = await drawn(7); + const alphas = new Set(burst?.drawn.map((p) => p.alpha)); + await at(1.2); + const afterBurst = await drawn(1); + check( + 'a burst throws its whole count at once, all of one age, and ends one lifetime later', + burst?.count === 7 && alphas.size === 1 && afterBurst?.count === 0, + JSON.stringify({ count: burst?.count, alphas: [...alphas], after: afterBurst?.count }), + ); + + // 31l. It really draws: the pixel where a burst particle sits is not the scene background. + await mutate(` + const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); + Object.assign(o.overrides, { size: 40, color: 0x3cc85a, drift: 0 });`); + await sleep(200); + await rewind(); + await at(0.1); + const spotOn = await json(`__gm.objectScreen(${JSON.stringify(SOURCE)})`); + const drawnAt = await drawn(1); + // ½·800·0.1² = 4 px down, so the particle still covers the source's own point. + const colour = await json(`__gm.pixelAt(${Math.round(spotOn.x)}, ${Math.round(spotOn.y)})`); + check( + 'a particle actually paints where the numbers say it is', + colour !== '#22252c' && (drawnAt?.count ?? 0) > 0, + JSON.stringify({ colour, at: spotOn, drawn: drawnAt?.drawn[0] }), + ); + await shot('31-particles-scene'); + + // 31m. The graph tab draws no Pixi complaint of its own. + const logged = await json('__gm.console.lines()'); + check( + 'nothing about the particle tab draws a complaint from Pixi', + !logged.some((line) => /Deprecation/i.test(JSON.stringify(line))), + JSON.stringify(logged.slice(0, 3)), + ); + + // 31n. The step takes back everything it made, so the steps after it see the fixture again. + await evaluate('__gm.particleTime(null)'); + await evaluate('__gm.state.select(null)'); + await mutate(` + const project = __gm.state.project; + project.assets = project.assets.filter((a) => a.id !== ${JSON.stringify(assetId)}); + __gm.state.activeScene.objects = __gm.state.activeScene.objects.filter( + (o) => o.id !== ${JSON.stringify(SOURCE)}); + __gm.lookAt(0, 0);`); + await evaluate("__gm.showTab('scene')"); + await sleep(300); + check( + 'the step leaves nothing of its own behind', + (await json("__gm.state.project.assets.filter((a) => a.type === 'particles').length")) === 0 && + (await drawn(1)) === null, + '', + ); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 962bf8b1..c9f6dde1 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -33,6 +33,7 @@ import { consolePanel } from './27-console.mjs'; import { shader } from './28-shader.mjs'; import { transform } from './29-transform.mjs'; import { font } from './30-font.mjs'; +import { particles } from './31-particles.mjs'; export const STEPS = [ hierarchy, @@ -65,4 +66,5 @@ export const STEPS = [ shader, transform, font, + particles, ]; diff --git a/tools/e2e/lib/scaffold.mjs b/tools/e2e/lib/scaffold.mjs index 99809693..8e3b3b2b 100644 --- a/tools/e2e/lib/scaffold.mjs +++ b/tools/e2e/lib/scaffold.mjs @@ -18,6 +18,7 @@ export const LANES = { console: -1100, transform: -1500, font: -1900, + particles: -2300, }; /**