A particle is a function of its age, not a thing that is stepped (#73)
game-maker / suite (push) Failing after 4m22s

The asset type `particles` is placeable and carries a graph at the same time —
`behavior` was the blueprint. Because a continuous emitter gives birth to particle n
at time n/rate, the whole field is a function of the clock rather than of the frames:
no ring-buffer bookkeeping, exact time jumps, reproducibility as a measurable property.

compile.ts follows compileShader's frame but emits JS closures into a Float64Array
instead of GLSL, and without eval; a node with two consumers is evaluated once.
Drawing is one Pixi ParticleContainer per source, so one draw call.

Collision, wind and braking in flight are out for good — that is the price of the
formula model, not an omission.

The tab count goes from nine to ten, which is exactly the case #84 was built for one
day earlier: at 1280 px the row no longer holds every tab, so guard 14b now asks for
hidden.length > 0 instead of === 0.
This commit is contained in:
root committed 2026-09-14 05:44:25 +02:00
1 parent f83c568999
commit 0f9e1611d1
39 files changed
+2385 -31

No files matched your search

+1 -1
View File
@@ -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: The browser suite and the three example games need a machine with a GPU:
```bash ```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-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
node tools/seed-actor.mjs && node tools/smoke-actor.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs
+2
View File
@@ -41,6 +41,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
return { ...base, type: 'shader', graph: createEmptyGraph() }; return { ...base, type: 'shader', graph: createEmptyGraph() };
case 'font': case 'font':
return { ...base, type: 'font', file: '' }; return { ...base, type: 'font', file: '' };
case 'particles':
return { ...base, type: 'particles', graph: createEmptyGraph() };
} }
} }
+5
View File
@@ -26,6 +26,11 @@ export interface EntityHandle {
z: number; z: number;
props: PropertyBag; props: PropertyBag;
parent: EntityHandle | null; 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. */ /** Position, rotation, scale and mirror of every ancestor applied, nearest last. */
+4 -3
View File
@@ -19,10 +19,11 @@ export interface PortDef {
} }
/** /**
* `state` and `shader` are the categories no behavior graph offers: their nodes are the states of * `state`, `shader` and `particles` are the categories no behavior graph offers: their nodes are the
* a state machine and the GLSL of a shader asset. * 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, * How a loop node asks for one of its exec outputs to be run repeatedly: `port` carries the body,
+19
View File
@@ -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' };
},
};
+6 -2
View File
@@ -1,5 +1,6 @@
import { type NodeDef, registerNode } from '../NodeRegistry'; import { type NodeDef, registerNode } from '../NodeRegistry';
import { Destroy } from './action/Destroy'; import { Destroy } from './action/Destroy';
import { Emit } from './action/Emit';
import { Log } from './action/Log'; import { Log } from './action/Log';
import { Move } from './action/Move'; import { Move } from './action/Move';
import { RestartGame } from './action/RestartGame'; import { RestartGame } from './action/RestartGame';
@@ -88,6 +89,7 @@ import { RunBehavior } from './flow/RunBehavior';
import { Sequence } from './flow/Sequence'; import { Sequence } from './flow/Sequence';
import { SetState } from './flow/SetState'; import { SetState } from './flow/SetState';
import { StateBranch } from './flow/StateBranch'; import { StateBranch } from './flow/StateBranch';
import { PARTICLE_NODES } from '../../particles/nodes';
import { SHADER_NODES } from '../../shader/nodes'; import { SHADER_NODES } from '../../shader/nodes';
import { State } from '../stateMachine'; import { State } from '../stateMachine';
import { Variables } from './variable/Variables'; import { Variables } from './variable/Variables';
@@ -182,13 +184,15 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
SetPosition, SetPosition,
Spawn, Spawn,
Destroy, Destroy,
Emit,
Log, Log,
StopGame, StopGame,
RestartGame, RestartGame,
]; ];
for (const def of BUILT_IN_NODES) registerNode(def); 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 // Registered so a machine's, a shader's or a particle graph loads and draws, but not built in: no
// graph offers them. // behavior graph offers them.
registerNode(State); registerNode(State);
for (const def of SHADER_NODES) registerNode(def); for (const def of SHADER_NODES) registerNode(def);
for (const def of PARTICLE_NODES) registerNode(def);
+16 -3
View File
@@ -110,6 +110,16 @@ export interface FontAsset extends AssetBase {
file: string; 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 = export type Asset =
| SpriteAsset | SpriteAsset
| CameraAsset | CameraAsset
@@ -122,13 +132,16 @@ export type Asset =
| AnimationAsset | AnimationAsset
| StateMachineAsset | StateMachineAsset
| ShaderAsset | ShaderAsset
| FontAsset; | FontAsset
| ParticlesAsset;
/** The assets edited as a node graph – what the graph canvas and the graph commands work on. */ /** 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<AssetType> = new Set(['behavior', 'statemachine', 'shader', 'particles']);
export function isGraphAsset(asset: Asset): asset is GraphAsset { 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 { export function isSceneAsset(asset: Asset): asset is SceneAsset {
+2
View File
@@ -11,6 +11,7 @@ export const ASSET_TYPES = [
'statemachine', 'statemachine',
'shader', 'shader',
'font', 'font',
'particles',
] as const; ] as const;
export type AssetType = (typeof ASSET_TYPES)[number]; export type AssetType = (typeof ASSET_TYPES)[number];
@@ -28,6 +29,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
statemachine: 'State machine', statemachine: 'State machine',
shader: 'Shader', shader: 'Shader',
font: 'Font', font: 'Font',
particles: 'Particles',
}; };
/** /**
+165
View File
@@ -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<ParticleOutputKey, number>;
export interface CompiledParticles {
/** The rows the graph adds to every source placed from this asset, in node order. */
params: FlatDef<'number'>[];
/** A Time node reaches the Output: the whole field moves, not just the particles in it. */
time: boolean;
/** Fills `out` with the seven values for this particle. */
evaluate(env: ParticleEnv, out: ParticleOut): void;
}
export interface ParticlesBuild {
/** Null while the graph has a problem – the source then emits nothing. */
compiled: CompiledParticles | null;
/** Per node id, what keeps the graph from compiling. */
problems: Record<string, string>;
/** One line for the tab: empty once compiled. */
reason: string;
}
/**
* 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<string, string> = {};
const problem = (node: GraphNode, text: string) => {
if (!(node.id in problems)) problems[node.id] = text;
};
const names = new Set<string>();
for (const node of graph.nodes) {
if (!isParticlePropertyNode(node)) continue;
const name = propertyNameOf(node);
if (!isValidParticlePropertyName(name))
problem(node, 'Name must be one word: letters, digits, _ – and not one of the source’s own rows');
else if (names.has(name)) problem(node, `Another property is named "${name}"`);
names.add(name);
}
const params = particleParamDefs(graph);
const paramIndex = (name: string) => params.findIndex((row) => row.key === name);
const ops: ParticleExpr[] = [];
const emitted = new Map<string, Record<string, ParticleExpr>>();
const visiting = new Set<string>();
const emitNode = (node: GraphNode): Record<string, ParticleExpr> | null => {
const done = emitted.get(node.id);
if (done) return done;
if (visiting.has(node.id)) {
problem(node, 'Feeds itself');
return null;
}
const def = nodeDef(node.type);
if (!def || !isParticleNodeDef(def)) {
problem(node, 'Not a particle node');
return null;
}
visiting.add(node.id);
const inputs: Record<string, ParticleExpr> = {};
let complete = true;
for (const port of def.inputs) {
const expr = input(node, port);
if (expr) inputs[port.key] = expr;
else complete = false;
}
visiting.delete(node.id);
if (!complete) return null;
const outputs: Record<string, ParticleExpr> = {};
for (const [key, expr] of Object.entries(
def.emit(inputs, node.params, { nodeId: node.id, paramIndex }),
)) {
const slot = ops.length;
ops.push(expr);
outputs[key] = (_env, locals) => locals[slot]!;
}
emitted.set(node.id, outputs);
return outputs;
};
/** What feeds an input: the edge's source, the typed-in value, or what the node says (`unfed`). */
const input = (node: GraphNode, port: PortDef): ParticleExpr | null => {
const edge = edgesInto(graph, node.id, port.key)[0];
if (edge) {
const source = findNode(graph, edge.from.node);
const outputs = source && emitNode(source);
return outputs?.[edge.from.port] ?? null;
}
if (port.field) {
const typed = Number(node.params[port.key] ?? port.default ?? 0);
const value = Number.isFinite(typed) ? typed : 0;
return () => value;
}
const def = nodeDef(node.type);
const unfed = def && isParticleNodeDef(def) ? def.unfed?.[port.key] : undefined;
return unfed ?? (() => 0);
};
const output = graph.nodes.find((n) => n.type === PARTICLE_OUTPUT);
if (!output) return { compiled: null, problems, reason: 'No Output node' };
const outputDef = nodeDef(PARTICLE_OUTPUT)!;
const reads: ParticleExpr[] = [];
for (const key of PARTICLE_OUTPUTS) {
const port = outputDef.inputs.find((p) => p.key === key)!;
const expr = input(output, port);
if (!expr) break;
reads.push(expr);
}
const first = graph.nodes.find((n) => n.id in problems);
if (first || reads.length !== PARTICLE_OUTPUTS.length)
return { compiled: null, problems, reason: first ? problems[first.id]! : 'Cannot compile' };
const locals = new Float64Array(ops.length);
const time = graph.nodes.some((n) => n.type === PARTICLE_TIME && emitted.has(n.id));
const compiled: CompiledParticles = {
params,
time,
evaluate(env, out) {
// In dependency order, so every local a later op reads is already filled.
for (let i = 0; i < ops.length; i++) locals[i] = ops[i]!(env, locals);
for (let i = 0; i < PARTICLE_OUTPUTS.length; i++) out[PARTICLE_OUTPUTS[i]!] = reads[i]!(env, locals);
},
};
return { compiled, problems, reason: '' };
}
/** A particle whose graph says nothing: at the source, full size, the source's own colour. */
export function emptyParticleOut(): ParticleOut {
return { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, color: 0xffffff, alpha: 1 };
}
+58
View File
@@ -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);
}
+523
View File
@@ -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<string, ParticleExpr>,
params: PropertyBag,
ctx: ParticleEmitContext,
): Record<string, ParticleExpr>;
/** What an input with nothing to type on reads while unconnected; without it, zero. */
unfed?: Record<string, ParticleExpr>;
}
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>): 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] ?? '');
}
+45 -1
View File
@@ -23,6 +23,20 @@ import { findAssetOf, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject'; import type { SceneObject } from '../model/SceneObject';
import { flatListOf, listItemOf } from './listItems'; import { flatListOf, listItemOf } from './listItems';
import { FONT_KEY } from './fonts'; 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'; 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. */ /** Cameras and behaviors are placed by the chain but have no look of their own to turn or size. */
const TRANSFORM_TYPES: ReadonlySet<AssetType> = new Set(['sprite', 'text', 'collider', 'container']); const TRANSFORM_TYPES: ReadonlySet<AssetType> = new Set([
'sprite',
'text',
'collider',
'container',
'particles',
]);
export function hasTransformProps(type: AssetType): boolean { export function hasTransformProps(type: AssetType): boolean {
return TRANSFORM_TYPES.has(type); return TRANSFORM_TYPES.has(type);
@@ -192,6 +212,28 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
shader: [], shader: [],
// The typeface itself is intrinsic data on the asset (see FontAsset), not a property. // The typeface itself is intrinsic data on the asset (see FontAsset), not a property.
font: [], 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: [ collider: [
{ {
key: 'shape', key: 'shape',
@@ -260,6 +302,8 @@ export function schemaForObject(project: Project, asset: Asset, object: SceneObj
function schemaForBag(project: Project, asset: Asset, bag: PropertyBag): PropertySchema { function schemaForBag(project: Project, asset: Asset, bag: PropertyBag): PropertySchema {
if (asset.type === 'behavior') return [...FULL_SCHEMAS.behavior, ...asset.variables]; if (asset.type === 'behavior') return [...FULL_SCHEMAS.behavior, ...asset.variables];
if (asset.type === 'sprite') return [...FULL_SCHEMAS.sprite, ...shaderParamRows(project, shaderOf(bag))]; 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]; return FULL_SCHEMAS[asset.type];
} }
+5
View File
@@ -24,6 +24,7 @@ const TEXT_DEFAULTS = defaultsFor('text');
/** Symbol sizes in world units. */ /** Symbol sizes in world units. */
export const BEHAVIOR_SYMBOL = 28; export const BEHAVIOR_SYMBOL = 28;
export const CONTAINER_SYMBOL = 24; export const CONTAINER_SYMBOL = 24;
export const PARTICLES_SYMBOL = 24;
/** Rough width of one character relative to the font size, for a plain sans. */ /** Rough width of one character relative to the font size, for a plain sans. */
const CHAR_WIDTH = 0.55; 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 }; return { w: BEHAVIOR_SYMBOL, h: BEHAVIOR_SYMBOL };
case 'container': case 'container':
return { w: CONTAINER_SYMBOL, h: CONTAINER_SYMBOL }; 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: default:
return cameraFrame(props); return cameraFrame(props);
} }
+103
View File
@@ -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<string> = 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<string>();
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;
}
+11
View File
@@ -12,6 +12,7 @@ import {
nodeDef, nodeDef,
STATE, STATE,
} from '../graph'; } from '../graph';
import { isParticleNodeDef } from '../particles/nodes';
import { isShaderNodeDef } from '../shader/nodes'; import { isShaderNodeDef } from '../shader/nodes';
import type { Asset, TextureFrame } from '../model/Asset'; import type { Asset, TextureFrame } from '../model/Asset';
import { isAssetType } from '../model/AssetTypes'; import { isAssetType } from '../model/AssetTypes';
@@ -48,6 +49,7 @@ import {
type StructFieldDef, type StructFieldDef,
variableDef, variableDef,
} from '../properties/PropertySchema'; } from '../properties/PropertySchema';
import { particleParamDefs } from '../properties/particles';
import { SHADER_KEY, shaderParamDefs } from '../properties/shaders'; import { SHADER_KEY, shaderParamDefs } from '../properties/shaders';
/** /**
@@ -64,6 +66,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
const rawProps = new Map<string, Record<string, unknown>>(); const rawProps = new Map<string, Record<string, unknown>>();
const shaderGraphs = new Map<string, Graph>(); const shaderGraphs = new Map<string, Graph>();
const shaderRows = new Map<string, PropertyDef[]>(); const shaderRows = new Map<string, PropertyDef[]>();
const particleGraphs = new Map<string, Graph>();
for (const a of rawAssets) { for (const a of rawAssets) {
if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue; if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue;
rawProps.set(a.id, asRecord(a.props)); rawProps.set(a.id, asRecord(a.props));
@@ -71,6 +74,12 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
const vars = normalizeVariables(a.variables); const vars = normalizeVariables(a.variables);
variables.set(a.id, vars); variables.set(a.id, vars);
schemas.set(a.id, [...COMMON_PROPS, ...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 { } else {
if (a.type === 'shader') { if (a.type === 'shader') {
const graph = normalizeGraph(a.graph, isShaderNodeDef); 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) }); assets.push({ id, type, name, props, graph: normalizeGraph(a.graph, (def) => def.type === STATE) });
} else if (type === 'shader') { } else if (type === 'shader') {
assets.push({ id, type, name, props, graph: shaderGraphs.get(id) ?? createEmptyGraph() }); 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') { } else if (type === 'texture') {
assets.push({ id, type, name, props, ...normalizeTexture(a, takenIds) }); assets.push({ id, type, name, props, ...normalizeTexture(a, takenIds) });
} else if (type === 'font') { } else if (type === 'font') {
+20 -1
View File
@@ -21,6 +21,7 @@ import { HierarchyPanel } from './panels/HierarchyPanel';
import { Toolbar } from './panels/Toolbar'; import { Toolbar } from './panels/Toolbar';
import { type PlayHost, PlaySession, type WorldSnapshot } from './PlaySession'; import { type PlayHost, PlaySession, type WorldSnapshot } from './PlaySession';
import { ProjectSession } from './ProjectSession'; import { ProjectSession } from './ProjectSession';
import { ParticlesEditor } from './particles/ParticlesEditor';
import { ShaderEditor } from './shader/ShaderEditor'; import { ShaderEditor } from './shader/ShaderEditor';
import { StateMachineEditor } from './statemachine/StateMachineEditor'; import { StateMachineEditor } from './statemachine/StateMachineEditor';
import { installShortcuts } from './shortcuts'; import { installShortcuts } from './shortcuts';
@@ -29,7 +30,16 @@ import { TextureEditor } from './texture/TextureEditor';
import { installFileImport } from './fileImport'; import { installFileImport } from './fileImport';
export type CentreTab = 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 = [ const TAB_ORDER = [
'scene', 'scene',
@@ -37,6 +47,7 @@ const TAB_ORDER = [
'graph', 'graph',
'statemachine', 'statemachine',
'shader', 'shader',
'particles',
'texture', 'texture',
'animation', 'animation',
'container', 'container',
@@ -49,6 +60,7 @@ const TAB_LABELS: Record<CentreTab, string> = {
graph: 'Behavior', graph: 'Behavior',
statemachine: 'State machine', statemachine: 'State machine',
shader: 'Shader', shader: 'Shader',
particles: 'Particles',
texture: 'Texture', texture: 'Texture',
animation: 'Animation', animation: 'Animation',
container: 'Container', container: 'Container',
@@ -61,6 +73,7 @@ const TAB_OF_ASSET: Partial<Record<AssetType, CentreTab>> = {
behavior: 'graph', behavior: 'graph',
statemachine: 'statemachine', statemachine: 'statemachine',
shader: 'shader', shader: 'shader',
particles: 'particles',
texture: 'texture', texture: 'texture',
animation: 'animation', animation: 'animation',
container: 'container', container: 'container',
@@ -87,6 +100,7 @@ export class Editor implements PlayHost {
readonly graphEditor: GraphEditor; readonly graphEditor: GraphEditor;
readonly stateMachineEditor: StateMachineEditor; readonly stateMachineEditor: StateMachineEditor;
readonly shaderEditor: ShaderEditor; readonly shaderEditor: ShaderEditor;
readonly particlesEditor: ParticlesEditor;
readonly textureEditor: TextureEditor; readonly textureEditor: TextureEditor;
readonly animationEditor: AnimationEditor; readonly animationEditor: AnimationEditor;
readonly containerEditor: ContainerEditor; readonly containerEditor: ContainerEditor;
@@ -129,6 +143,7 @@ export class Editor implements PlayHost {
this.graphEditor = new GraphEditor(state); this.graphEditor = new GraphEditor(state);
this.stateMachineEditor = new StateMachineEditor(state); this.stateMachineEditor = new StateMachineEditor(state);
this.shaderEditor = new ShaderEditor(state); this.shaderEditor = new ShaderEditor(state);
this.particlesEditor = new ParticlesEditor(state);
this.textureEditor = new TextureEditor(state); this.textureEditor = new TextureEditor(state);
this.animationEditor = new AnimationEditor(state); this.animationEditor = new AnimationEditor(state);
this.containerEditor = new ContainerEditor(state); this.containerEditor = new ContainerEditor(state);
@@ -175,6 +190,7 @@ export class Editor implements PlayHost {
graph: this.graphEditor, graph: this.graphEditor,
statemachine: this.stateMachineEditor, statemachine: this.stateMachineEditor,
shader: this.shaderEditor, shader: this.shaderEditor,
particles: this.particlesEditor,
texture: this.textureEditor, texture: this.textureEditor,
animation: this.animationEditor, animation: this.animationEditor,
container: this.containerEditor, container: this.containerEditor,
@@ -233,6 +249,8 @@ export class Editor implements PlayHost {
? 'statemachine' ? 'statemachine'
: state.selectedShaderId : state.selectedShaderId
? 'shader' ? 'shader'
: state.selectedParticlesId
? 'particles'
: 'graph'; : 'graph';
if (this.tab !== tab) this.showTab(tab); if (this.tab !== tab) this.showTab(tab);
} }
@@ -305,6 +323,7 @@ export class Editor implements PlayHost {
this.gameView.animate(); this.gameView.animate();
this.console.refresh(); this.console.refresh();
if (this.tab === 'animation') this.animationEditor.tick(dt); if (this.tab === 'animation') this.animationEditor.tick(dt);
if (this.tab === 'particles') this.particlesEditor.tick();
} }
private measureFps(dt: number): void { private measureFps(dt: number): void {
+32
View File
@@ -3,6 +3,7 @@ import { resolveObjects } from '../core/model/instance';
import { findObjectHolder } from '../core/model/Project'; import { findObjectHolder } from '../core/model/Project';
import type { FrameRect } from '../core/model/texture'; import type { FrameRect } from '../core/model/texture';
import { activeDrag, Button, ui, Widget } from '../ui'; import { activeDrag, Button, ui, Widget } from '../ui';
import { particleLibrary } from '../viewport/ParticleLibrary';
import { shaderLibrary } from '../viewport/ShaderLibrary'; import { shaderLibrary } from '../viewport/ShaderLibrary';
import { textureLibrary } from '../viewport/TextureLibrary'; import { textureLibrary } from '../viewport/TextureLibrary';
import type { CentreTab, Editor } from './Editor'; import type { CentreTab, Editor } from './Editor';
@@ -280,6 +281,26 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
center: () => centreOf(editor.stateMachineEditor.canvas), center: () => centreOf(editor.stateMachineEditor.canvas),
snapping: () => editor.stateMachineEditor.canvas.snapEnabled, 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: { shader: {
current: () => editor.shaderEditor.currentAssetId, current: () => editor.shaderEditor.currentAssetId,
pickerAt: () => centreOf(editor.shaderEditor.picker), 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. */ /** 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), 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). */ /** Graphs compiled so far; a moved node must not add one (#65). */
shaderCompiles: () => shaderLibrary.compiles, shaderCompiles: () => shaderLibrary.compiles,
/** Which views redraw every tick because a Time shader is on screen. */ /** Which views redraw every tick because a Time shader is on screen. */
+10 -4
View File
@@ -28,6 +28,7 @@ import {
} from '../../core/graph'; } from '../../core/graph';
import type { GraphAsset } from '../../core/model/Asset'; import type { GraphAsset } from '../../core/model/Asset';
import { graphLookup } from '../../core/model/graphLookup'; import { graphLookup } from '../../core/model/graphLookup';
import { particleLibrary } from '../../viewport/ParticleLibrary';
import { shaderLibrary } from '../../viewport/ShaderLibrary'; import { shaderLibrary } from '../../viewport/ShaderLibrary';
import { findAsset, findGraphAsset } from '../../core/model/Project'; import { findAsset, findGraphAsset } from '../../core/model/Project';
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem'; import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
@@ -219,8 +220,13 @@ export class GraphCanvas extends Widget {
} }
const seen = new Set<string>(); const seen = new Set<string>();
const lookup = graphLookup(this.state.project); const lookup = graphLookup(this.state.project);
// A shader node's problem is the compiler's, not a call's. // In a compiled graph a node's problem is the compiler's, not a call's.
const shaderProblems = asset.type === 'shader' ? shaderLibrary.buildFor(asset).problems : null; const compilerProblems =
asset.type === 'shader'
? shaderLibrary.buildFor(asset).problems
: asset.type === 'particles'
? particleLibrary.buildFor(asset).problems
: null;
for (const node of asset.graph.nodes) { for (const node of asset.graph.nodes) {
const def = resolveDef(node, asset.graph, lookup); const def = resolveDef(node, asset.graph, lookup);
if (!def) continue; if (!def) continue;
@@ -233,8 +239,8 @@ export class GraphCanvas extends Widget {
this.views.set(node.id, view); this.views.set(node.id, view);
this.nodeLayer.addChild(view); this.nodeLayer.addChild(view);
} }
const problem = shaderProblems const problem = compilerProblems
? (shaderProblems[node.id] ?? null) ? (compilerProblems[node.id] ?? null)
: callProblem(node, lookup, asset.id); : callProblem(node, lookup, asset.id);
view.sync(node, asset.graph, this.state.project, def, problem); view.sync(node, asset.graph, this.state.project, def, problem);
} }
+2
View File
@@ -53,6 +53,8 @@ export function categoryColor(category: NodeCategory): number {
return c.categoryState; return c.categoryState;
case 'shader': case 'shader':
return c.categoryShader; return c.categoryShader;
case 'particles':
return c.categoryParticles;
} }
} }
+18 -1
View File
@@ -15,7 +15,14 @@ import type { GraphAsset } from '../../core/model/Asset';
import { graphLookup } from '../../core/model/graphLookup'; import { graphLookup } from '../../core/model/graphLookup';
import type { PropertyBag } from '../../core/model/Properties'; import type { PropertyBag } from '../../core/model/Properties';
import type { Project } from '../../core/model/Project'; 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 { 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'; 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 }; 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[] = []; const entries: PaletteEntry[] = [];
for (const def of allNodeDefs()) { 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; if (isTerminalType(def.type) && terminalNode(asset.graph, def.type)) continue;
entries.push({ id: def.type, hint: def.category, def, params: {} }); entries.push({ id: def.type, hint: def.category, def, params: {} });
} }
+104
View File
@@ -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;
}
+107
View File
@@ -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<ParticlesAsset, GraphCanvas> {
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);
}
}
+6 -1
View File
@@ -376,7 +376,12 @@ export class EditorState {
return this.selectedGraphId('shader'); 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; const s = this.selection;
if (!s) return null; if (!s) return null;
if (s.kind === 'frame') return null; if (s.kind === 'frame') return null;
+92 -3
View File
@@ -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 { isSymbolType } from '../../core/model/AssetTypes';
import { type Extent, scaleOf } from '../../core/model/matrix'; import { type Extent, scaleOf } from '../../core/model/matrix';
import { sortByZ, type Vec2, textOf } from '../../core/model/Properties'; 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 { type Size, Theme, Widget } from '../../ui';
import { fontLibrary } from '../../viewport/FontLibrary'; import { fontLibrary } from '../../viewport/FontLibrary';
import type { DrawItem } from '../../viewport/items'; 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 { shaderLibrary } from '../../viewport/ShaderLibrary';
import { type ShaderSource, wornShader } from '../../viewport/shaders'; import { type ShaderSource, wornShader } from '../../viewport/shaders';
import { import {
@@ -39,6 +51,8 @@ export abstract class WorldViewport extends Widget {
private readonly texts = new Map<string, Text>(); private readonly texts = new Map<string, Text>();
/** Pictures of sprite items: siblings as well, each right under its node. */ /** Pictures of sprite items: siblings as well, each right under its node. */
private readonly sprites = new Map<string, Mesh>(); private readonly sprites = new Map<string, Mesh>();
/** The drawn particles of each source, one container – and one draw call – per source. */
private readonly fields = new Map<string, ParticleField>();
private visibleCount = 0; private visibleCount = 0;
private animated = false; private animated = false;
protected lastItems: DrawItem[] = []; protected lastItems: DrawItem[] = [];
@@ -99,6 +113,40 @@ export abstract class WorldViewport extends Widget {
return { rendering: this.currentView() !== null, visibleItems: this.visibleCount }; 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. */ /** True after a redraw that drew a sprite wearing a Time shader: the picture moves by itself. */
get animates(): boolean { get animates(): boolean {
return this.animated; return this.animated;
@@ -179,8 +227,9 @@ export abstract class WorldViewport extends Widget {
const size = footprintOf(item.type, item.props); const size = footprintOf(item.type, item.props);
const extent = footprintExtent(item.type, item.props, item.m); 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 // 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. // selected and switched back on from the hierarchy panel. A source is never culled by its
const onScreen = item.enabled && rectsIntersect(extent, bounds); // 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); let node = this.nodes.get(item.id);
if (!node) { if (!node) {
node = new Graphics(); node = new Graphics();
@@ -195,6 +244,7 @@ export abstract class WorldViewport extends Widget {
this.syncDecorations(item, onScreen, view.scale, extent); this.syncDecorations(item, onScreen, view.scale, extent);
this.syncText(item, onScreen, view.scale); this.syncText(item, onScreen, view.scale);
this.syncSprite(item, onScreen, size, shaders); this.syncSprite(item, onScreen, size, shaders);
this.syncParticles(item, onScreen);
if (!onScreen) continue; if (!onScreen) continue;
this.visibleCount++; this.visibleCount++;
drawItem(node, item, size, view.scale * scaleOf(placementOf(item.type, item.m))); 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); const sprite = this.sprites.get(id);
if (sprite) disposeSpriteMesh(sprite); if (sprite) disposeSpriteMesh(sprite);
this.sprites.delete(id); this.sprites.delete(id);
this.fields.get(id)?.destroy();
this.fields.delete(id);
node.destroy(); node.destroy();
this.nodes.delete(id); this.nodes.delete(id);
this.disposeDecorations(id); this.disposeDecorations(id);
@@ -230,9 +282,46 @@ export abstract class WorldViewport extends Widget {
if (node) this.objects.addChild(node); if (node) this.objects.addChild(node);
const text = this.texts.get(item.id); const text = this.texts.get(item.id);
if (text) this.objects.addChild(text); 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 { private syncSprite(item: DrawItem, onScreen: boolean, size: Size, shaders: ShaderSource): void {
let sprite = this.sprites.get(item.id); let sprite = this.sprites.get(item.id);
if (item.type !== 'sprite' || !onScreen) { if (item.type !== 'sprite' || !onScreen) {
+2
View File
@@ -23,6 +23,8 @@ export interface Entity {
parent: Entity | null; parent: Entity | null;
/** The animation a sprite wears and how far it is; kept out of `props`, which the world writes back. */ /** The animation a sprite wears and how far it is; kept out of `props`, which the world writes back. */
animation?: AnimationClock; animation?: AnimationClock;
/** How often an `Emit` node has restarted this particle source; out of `props` for the same reason. */
emits?: number;
} }
export interface AnimationClock { export interface AnimationClock {
+1
View File
@@ -56,6 +56,7 @@ export const Theme = {
categoryAction: 0x2f6fd6, categoryAction: 0x2f6fd6,
categoryState: 0x8e5ad6, categoryState: 0x8e5ad6,
categoryShader: 0x1f8f8f, categoryShader: 0x1f8f8f,
categoryParticles: 0xc26a1f,
portExec: 0xffffff, portExec: 0xffffff,
portNumber: 0x6fcf97, portNumber: 0x6fcf97,
portBoolean: 0xe57373, portBoolean: 0xe57373,
+1
View File
@@ -10,6 +10,7 @@ export { Button, type ButtonOptions } from './widgets/Button';
export { Dropdown } from './widgets/Dropdown'; export { Dropdown } from './widgets/Dropdown';
export { Checkbox } from './widgets/Checkbox'; export { Checkbox } from './widgets/Checkbox';
export { Panel } from './widgets/Panel'; export { Panel } from './widgets/Panel';
export { Slider } from './widgets/Slider';
export { Splitter } from './widgets/Splitter'; export { Splitter } from './widgets/Splitter';
export { ScrollContainer } from './widgets/ScrollContainer'; export { ScrollContainer } from './widgets/ScrollContainer';
export { type DropSpot, ListView, type ListItem, type PickModifiers } from './widgets/ListView'; export { type DropSpot, ListView, type ListItem, type PickModifiers } from './widgets/ListView';
+84
View File
@@ -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 });
}
}
+40
View File
@@ -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<string, { fingerprint: string; build: ParticlesBuild }>();
/** 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();
+18 -3
View File
@@ -4,7 +4,8 @@ import { treeOrder } from '../core/model/hierarchy';
import { resolvedHolder } from '../core/model/instance'; import { resolvedHolder } from '../core/model/instance';
import type { Mat } from '../core/model/matrix'; import type { Mat } from '../core/model/matrix';
import type { PropertyBag } from '../core/model/Properties'; 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 { fontFileOf } from '../core/model/font';
import { resolveTexture, type TextureRef } from '../core/model/texture'; import { resolveTexture, type TextureRef } from '../core/model/texture';
import { enabledAlongChain } from '../core/properties/enabled'; import { enabledAlongChain } from '../core/properties/enabled';
@@ -34,6 +35,10 @@ export interface DrawItem {
texture: TextureRef | null; texture: TextureRef | null;
/** The typeface file a text wears, resolved here for the same reason; `''` is the default one. */ /** The typeface file a text wears, resolved here for the same reason; `''` is the default one. */
font: string; 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 { interface DrawList {
@@ -41,6 +46,12 @@ interface DrawList {
background: number; 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 * 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 * positions, with the content of placed containers resolved in. A container has no background of
@@ -70,8 +81,10 @@ export function collectHolderItems(
props, props,
parentId: object.parentId, parentId: object.parentId,
enabled: true, 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)) : '', 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])); const byId = new Map(items.map((i) => [i.id, i]));
@@ -104,8 +117,10 @@ export function collectWorldItems(world: World): DrawList {
parentId: e.parent?.objectId ?? '', parentId: e.parent?.objectId ?? '',
enabled: true, enabled: true,
// Resolved per frame on purpose: a `Properties` node may swap `texture`, an animation runs. // 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)) : '', 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 }; return { items, background: world.backgroundColor };
} }
+179
View File
@@ -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<Particle>({
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();
}
}
+3
View File
@@ -84,6 +84,9 @@ export function drawItem(
node.clear(); node.clear();
node.hitArea = null; node.hitArea = null;
switch (item.type) { 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': case 'sprite':
node.hitArea = new Rectangle(-size.w / 2, -size.h / 2, size.w, size.h); node.hitArea = new Rectangle(-size.w / 2, -size.h / 2, size.w, size.h);
return; return;
+2 -2
View File
@@ -74,8 +74,8 @@ describe('node registry', () => {
expect(new Set(def.outputs.map((p) => p.key)).size).toBe(def.outputs.length); 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. // 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); if (def.category === 'event' || def.category === 'state') expect(def.inputs).toHaveLength(0);
// A shader node is compiled to GLSL, never run. // A shader node is compiled to GLSL and a particle node to a function: neither is ever run.
else if (def.category === 'shader') expect('emit' in def).toBe(true); else if (def.category === 'shader' || def.category === 'particles') expect('emit' in def).toBe(true);
else expect(def.run).toBeTypeOf('function'); else expect(def.run).toBeTypeOf('function');
expect(keys.every((k) => k.length > 0)).toBe(true); expect(keys.every((k) => k.length > 0)).toBe(true);
} }
+15
View File
@@ -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 }); 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 painted = fakeHandle({ width: 100, text: '' });
const pointer = (overrides: Partial<NodeContext['pointer']>): Partial<NodeContext> => ({ const pointer = (overrides: Partial<NodeContext['pointer']>): Partial<NodeContext> => ({
@@ -647,6 +649,19 @@ const CASES: Record<string, Case[]> = {
next: 'exec', 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: [ Log: [
{ {
name: 'logs its message and continues', name: 'logs its message and continues',
+335
View File
@@ -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> = {}): ParticleEnv => ({
age: 0,
ageSeconds: 0,
time: 0,
index: 0,
seed: 0,
color: 0xffffff,
params: [],
...over,
});
function run(p: ReturnType<typeof particlesEditor>, at: Partial<ParticleEnv>): 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']);
});
});
+14 -6
View File
@@ -17,6 +17,7 @@ const LABELS = {
animation: 'Animation', animation: 'Animation',
container: 'Container', container: 'Container',
list: 'List', list: 'List',
particles: 'Particles',
}; };
export async function overflow(t) { export async function overflow(t) {
@@ -63,9 +64,16 @@ export async function overflow(t) {
const wide = await json('__gm.tabRow()'); const wide = await json('__gm.tabRow()');
check( check(
'every tab button ends inside the centre column', '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 }), 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 // 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). // 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); await sleep(300);
const back = await json('__gm.tabRow()'); const back = await json('__gm.tabRow()');
check( check(
'giving the room back brings every tab into the row', 'giving the room back brings tabs into the row again, and none past the edge',
back.hidden.length === 0 && spillOf(back, await columnRight()).length === 0, back.hidden.length < narrow.hidden.length && spillOf(back, await columnRight()).length === 0,
JSON.stringify({ hidden: back.hidden }), JSON.stringify({ hidden: back.hidden, narrow: narrow.hidden }),
); );
// 14c. Every tab, because each one owns a different set of controls. // 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 evaluate(`__gm.showTab(${JSON.stringify(tab)})`);
await sleep(400); await sleep(400);
const spills = await json('__gm.textOverflows()'); 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 // 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. // 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 evaluate(`__gm.showTab(${JSON.stringify(tab)})`);
await sleep(400); await sleep(400);
const dropped = await json('__gm.labelsDropped()'); const dropped = await json('__gm.labelsDropped()');
+332
View File
@@ -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,
'',
);
}
+2
View File
@@ -33,6 +33,7 @@ import { consolePanel } from './27-console.mjs';
import { shader } from './28-shader.mjs'; import { shader } from './28-shader.mjs';
import { transform } from './29-transform.mjs'; import { transform } from './29-transform.mjs';
import { font } from './30-font.mjs'; import { font } from './30-font.mjs';
import { particles } from './31-particles.mjs';
export const STEPS = [ export const STEPS = [
hierarchy, hierarchy,
@@ -65,4 +66,5 @@ export const STEPS = [
shader, shader,
transform, transform,
font, font,
particles,
]; ];
+1
View File
@@ -18,6 +18,7 @@ export const LANES = {
console: -1100, console: -1100,
transform: -1500, transform: -1500,
font: -1900, font: -1900,
particles: -2300,
}; };
/** /**