A particle is a function of its age, not a thing that is stepped (#73)
game-maker / suite (push) Failing after 4m22s
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:
1 parent
f83c568999
commit
0f9e1611d1
39 files changed
+2386
-32
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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() };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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' };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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] ?? '');
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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') {
|
||||||
|
|||||||
+21
-2
@@ -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,7 +249,9 @@ export class Editor implements PlayHost {
|
|||||||
? 'statemachine'
|
? 'statemachine'
|
||||||
: state.selectedShaderId
|
: state.selectedShaderId
|
||||||
? 'shader'
|
? 'shader'
|
||||||
: 'graph';
|
: state.selectedParticlesId
|
||||||
|
? 'particles'
|
||||||
|
: 'graph';
|
||||||
if (this.tab !== tab) this.showTab(tab);
|
if (this.tab !== tab) this.showTab(tab);
|
||||||
}
|
}
|
||||||
if (s?.kind === 'frame' && this.tab !== 'texture') this.showTab('texture');
|
if (s?.kind === 'frame' && this.tab !== 'texture') this.showTab('texture');
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: {} });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 };
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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()');
|
||||||
|
|||||||
@@ -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,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
];
|
];
|
||||||
@@ -18,6 +18,7 @@ export const LANES = {
|
|||||||
console: -1100,
|
console: -1100,
|
||||||
transform: -1500,
|
transform: -1500,
|
||||||
font: -1900,
|
font: -1900,
|
||||||
|
particles: -2300,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in new issue
Block a user