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
@@ -41,6 +41,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
|
||||
return { ...base, type: 'shader', graph: createEmptyGraph() };
|
||||
case 'font':
|
||||
return { ...base, type: 'font', file: '' };
|
||||
case 'particles':
|
||||
return { ...base, type: 'particles', graph: createEmptyGraph() };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@ export interface EntityHandle {
|
||||
z: number;
|
||||
props: PropertyBag;
|
||||
parent: EntityHandle | null;
|
||||
/**
|
||||
* Counts the Emit pulses a particle source has been given. Kept off `props` for the reason the
|
||||
* animation clock is: what the world writes back to the model must not grow an override per frame.
|
||||
*/
|
||||
emits?: number;
|
||||
}
|
||||
|
||||
/** Position, rotation, scale and mirror of every ancestor applied, nearest last. */
|
||||
|
||||
@@ -19,10 +19,11 @@ export interface PortDef {
|
||||
}
|
||||
|
||||
/**
|
||||
* `state` and `shader` are the categories no behavior graph offers: their nodes are the states of
|
||||
* a state machine and the GLSL of a shader asset.
|
||||
* `state`, `shader` and `particles` are the categories no behavior graph offers: their nodes are the
|
||||
* states of a state machine, the GLSL of a shader asset and the motion of one particle.
|
||||
*/
|
||||
export type NodeCategory = 'event' | 'flow' | 'data' | 'variable' | 'action' | 'state' | 'shader';
|
||||
export type NodeCategory =
|
||||
'event' | 'flow' | 'data' | 'variable' | 'action' | 'state' | 'shader' | 'particles';
|
||||
|
||||
/**
|
||||
* How a loop node asks for one of its exec outputs to be run repeatedly: `port` carries the body,
|
||||
|
||||
@@ -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 { Destroy } from './action/Destroy';
|
||||
import { Emit } from './action/Emit';
|
||||
import { Log } from './action/Log';
|
||||
import { Move } from './action/Move';
|
||||
import { RestartGame } from './action/RestartGame';
|
||||
@@ -88,6 +89,7 @@ import { RunBehavior } from './flow/RunBehavior';
|
||||
import { Sequence } from './flow/Sequence';
|
||||
import { SetState } from './flow/SetState';
|
||||
import { StateBranch } from './flow/StateBranch';
|
||||
import { PARTICLE_NODES } from '../../particles/nodes';
|
||||
import { SHADER_NODES } from '../../shader/nodes';
|
||||
import { State } from '../stateMachine';
|
||||
import { Variables } from './variable/Variables';
|
||||
@@ -182,13 +184,15 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
SetPosition,
|
||||
Spawn,
|
||||
Destroy,
|
||||
Emit,
|
||||
Log,
|
||||
StopGame,
|
||||
RestartGame,
|
||||
];
|
||||
|
||||
for (const def of BUILT_IN_NODES) registerNode(def);
|
||||
// Registered so a machine's or a shader's graph loads and draws, but not built in: no behavior
|
||||
// graph offers them.
|
||||
// Registered so a machine's, a shader's or a particle graph loads and draws, but not built in: no
|
||||
// behavior graph offers them.
|
||||
registerNode(State);
|
||||
for (const def of SHADER_NODES) registerNode(def);
|
||||
for (const def of PARTICLE_NODES) registerNode(def);
|
||||
+16
-3
@@ -110,6 +110,16 @@ export interface FontAsset extends AssetBase {
|
||||
file: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The graph is the motion of one particle: every output of its `Output` node is a function of the
|
||||
* particle's age and its own random number (see `core/particles/compile.ts`). A placed object is
|
||||
* the source that emits them; how many and for how long are its properties.
|
||||
*/
|
||||
export interface ParticlesAsset extends AssetBase {
|
||||
type: 'particles';
|
||||
graph: Graph;
|
||||
}
|
||||
|
||||
export type Asset =
|
||||
| SpriteAsset
|
||||
| CameraAsset
|
||||
@@ -122,13 +132,16 @@ export type Asset =
|
||||
| AnimationAsset
|
||||
| StateMachineAsset
|
||||
| ShaderAsset
|
||||
| FontAsset;
|
||||
| FontAsset
|
||||
| ParticlesAsset;
|
||||
|
||||
/** The assets edited as a node graph – what the graph canvas and the graph commands work on. */
|
||||
export type GraphAsset = BehaviorAsset | StateMachineAsset | ShaderAsset;
|
||||
export type GraphAsset = BehaviorAsset | StateMachineAsset | ShaderAsset | ParticlesAsset;
|
||||
|
||||
const GRAPH_TYPES: ReadonlySet<AssetType> = new Set(['behavior', 'statemachine', 'shader', 'particles']);
|
||||
|
||||
export function isGraphAsset(asset: Asset): asset is GraphAsset {
|
||||
return asset.type === 'behavior' || asset.type === 'statemachine' || asset.type === 'shader';
|
||||
return GRAPH_TYPES.has(asset.type);
|
||||
}
|
||||
|
||||
export function isSceneAsset(asset: Asset): asset is SceneAsset {
|
||||
|
||||
@@ -11,6 +11,7 @@ export const ASSET_TYPES = [
|
||||
'statemachine',
|
||||
'shader',
|
||||
'font',
|
||||
'particles',
|
||||
] as const;
|
||||
|
||||
export type AssetType = (typeof ASSET_TYPES)[number];
|
||||
@@ -28,6 +29,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
|
||||
statemachine: 'State machine',
|
||||
shader: 'Shader',
|
||||
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 { flatListOf, listItemOf } from './listItems';
|
||||
import { FONT_KEY } from './fonts';
|
||||
import {
|
||||
BURST_COUNT_KEY,
|
||||
EMITTING_KEY,
|
||||
LIFETIME_KEY,
|
||||
MAX_PARTICLES_KEY,
|
||||
MODE_KEY,
|
||||
MODE_OPTIONS,
|
||||
MOVE_WITH_SOURCE_KEY,
|
||||
PARTICLE_COLOR_KEY,
|
||||
PARTICLE_SIZE_KEY,
|
||||
PARTICLE_TEXTURE_KEY,
|
||||
particleParamDefs,
|
||||
RATE_KEY,
|
||||
} from './particles';
|
||||
import { SHADER_KEY, shaderOf, shaderParamDefs } from './shaders';
|
||||
|
||||
/**
|
||||
@@ -109,7 +123,13 @@ export const TRANSFORM_PROPS: PropertySchema = [
|
||||
];
|
||||
|
||||
/** Cameras and behaviors are placed by the chain but have no look of their own to turn or size. */
|
||||
const TRANSFORM_TYPES: ReadonlySet<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 {
|
||||
return TRANSFORM_TYPES.has(type);
|
||||
@@ -192,6 +212,28 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
shader: [],
|
||||
// The typeface itself is intrinsic data on the asset (see FontAsset), not a property.
|
||||
font: [],
|
||||
// How many particles and for how long; what one of them does is the asset's graph, whose Property
|
||||
// nodes add their rows after these – see `schemaForBag`.
|
||||
particles: [
|
||||
{ key: EMITTING_KEY, label: 'Emitting', kind: 'boolean', default: true },
|
||||
{ key: MODE_KEY, label: 'Mode', kind: 'enum', default: 'continuous', options: MODE_OPTIONS },
|
||||
{ key: RATE_KEY, label: 'Rate', kind: 'number', default: 20, min: 0, step: 1 },
|
||||
{ key: BURST_COUNT_KEY, label: 'Burst count', kind: 'number', default: 20, min: 0, step: 1, decimals: 0 },
|
||||
{ key: LIFETIME_KEY, label: 'Lifetime', kind: 'number', default: 1, min: 0.01, step: 0.1 },
|
||||
{
|
||||
key: MAX_PARTICLES_KEY,
|
||||
label: 'Max particles',
|
||||
kind: 'number',
|
||||
default: 500,
|
||||
min: 1,
|
||||
step: 50,
|
||||
decimals: 0,
|
||||
},
|
||||
{ key: PARTICLE_SIZE_KEY, label: 'Size', kind: 'number', default: 16, min: 0.1, step: 1 },
|
||||
{ key: PARTICLE_TEXTURE_KEY, label: 'Texture', kind: 'texture', default: '' },
|
||||
{ key: PARTICLE_COLOR_KEY, label: 'Color', kind: 'color', default: 0xffffff },
|
||||
{ key: MOVE_WITH_SOURCE_KEY, label: 'Move with source', kind: 'boolean', default: false },
|
||||
],
|
||||
collider: [
|
||||
{
|
||||
key: 'shape',
|
||||
@@ -260,6 +302,8 @@ export function schemaForObject(project: Project, asset: Asset, object: SceneObj
|
||||
function schemaForBag(project: Project, asset: Asset, bag: PropertyBag): PropertySchema {
|
||||
if (asset.type === 'behavior') return [...FULL_SCHEMAS.behavior, ...asset.variables];
|
||||
if (asset.type === 'sprite') return [...FULL_SCHEMAS.sprite, ...shaderParamRows(project, shaderOf(bag))];
|
||||
// Unlike a sprite's shader, a source's graph is its own asset's, so no id has to be looked up.
|
||||
if (asset.type === 'particles') return [...FULL_SCHEMAS.particles, ...particleParamDefs(asset.graph)];
|
||||
return FULL_SCHEMAS[asset.type];
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ const TEXT_DEFAULTS = defaultsFor('text');
|
||||
/** Symbol sizes in world units. */
|
||||
export const BEHAVIOR_SYMBOL = 28;
|
||||
export const CONTAINER_SYMBOL = 24;
|
||||
export const PARTICLES_SYMBOL = 24;
|
||||
|
||||
/** Rough width of one character relative to the font size, for a plain sans. */
|
||||
const CHAR_WIDTH = 0.55;
|
||||
@@ -59,6 +60,10 @@ export function footprintOf(type: AssetType, props: PropertyBag): { w: number; h
|
||||
return { w: BEHAVIOR_SYMBOL, h: BEHAVIOR_SYMBOL };
|
||||
case 'container':
|
||||
return { w: CONTAINER_SYMBOL, h: CONTAINER_SYMBOL };
|
||||
// The particles themselves are drawn wherever the graph puts them; what can be grabbed and
|
||||
// framed is the source, which is a point with a handle around it.
|
||||
case 'particles':
|
||||
return { w: PARTICLES_SYMBOL, h: PARTICLES_SYMBOL };
|
||||
default:
|
||||
return cameraFrame(props);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
STATE,
|
||||
} from '../graph';
|
||||
import { isParticleNodeDef } from '../particles/nodes';
|
||||
import { isShaderNodeDef } from '../shader/nodes';
|
||||
import type { Asset, TextureFrame } from '../model/Asset';
|
||||
import { isAssetType } from '../model/AssetTypes';
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
type StructFieldDef,
|
||||
variableDef,
|
||||
} from '../properties/PropertySchema';
|
||||
import { particleParamDefs } from '../properties/particles';
|
||||
import { SHADER_KEY, shaderParamDefs } from '../properties/shaders';
|
||||
|
||||
/**
|
||||
@@ -64,6 +66,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
const rawProps = new Map<string, Record<string, unknown>>();
|
||||
const shaderGraphs = new Map<string, Graph>();
|
||||
const shaderRows = new Map<string, PropertyDef[]>();
|
||||
const particleGraphs = new Map<string, Graph>();
|
||||
for (const a of rawAssets) {
|
||||
if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue;
|
||||
rawProps.set(a.id, asRecord(a.props));
|
||||
@@ -71,6 +74,12 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
const vars = normalizeVariables(a.variables);
|
||||
variables.set(a.id, vars);
|
||||
schemas.set(a.id, [...COMMON_PROPS, ...vars]);
|
||||
} else if (a.type === 'particles') {
|
||||
// A source's rows end with its own graph's parameters, so the graph is needed before the
|
||||
// overrides of the objects placed from it can be coerced.
|
||||
const graph = normalizeGraph(a.graph, isParticleNodeDef);
|
||||
particleGraphs.set(a.id, graph);
|
||||
schemas.set(a.id, [...schemaFor('particles'), ...particleParamDefs(graph)]);
|
||||
} else {
|
||||
if (a.type === 'shader') {
|
||||
const graph = normalizeGraph(a.graph, isShaderNodeDef);
|
||||
@@ -115,6 +124,8 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
assets.push({ id, type, name, props, graph: normalizeGraph(a.graph, (def) => def.type === STATE) });
|
||||
} else if (type === 'shader') {
|
||||
assets.push({ id, type, name, props, graph: shaderGraphs.get(id) ?? createEmptyGraph() });
|
||||
} else if (type === 'particles') {
|
||||
assets.push({ id, type, name, props, graph: particleGraphs.get(id) ?? createEmptyGraph() });
|
||||
} else if (type === 'texture') {
|
||||
assets.push({ id, type, name, props, ...normalizeTexture(a, takenIds) });
|
||||
} else if (type === 'font') {
|
||||
|
||||
Reference in new issue
Block a user