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

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

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

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

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

No files matched your search

+45 -1
View File
@@ -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];
}
+5
View File
@@ -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);
}
+103
View File
@@ -0,0 +1,103 @@
import type { Graph, GraphNode } from '../graph/Graph';
import { type PropertyBag, textOf } from '../model/Properties';
import { type FlatDef, isValidFieldName } from './PropertySchema';
/** The node of a particle graph that declares a parameter: a row on every source placed from the asset. */
export const PARTICLE_PROPERTY = 'ParticleProperty';
export const PROPERTY_NAME_PARAM = 'name';
export const PROPERTY_DEFAULT_PARAM = 'default';
export const PROPERTY_MIN_PARAM = 'min';
export const PROPERTY_MAX_PARAM = 'max';
/** The rows a particle source has of its own, before the ones its graph declares. */
export const EMITTING_KEY = 'emitting';
export const MODE_KEY = 'mode';
export const RATE_KEY = 'rate';
export const BURST_COUNT_KEY = 'count';
export const LIFETIME_KEY = 'lifetime';
export const MAX_PARTICLES_KEY = 'maxParticles';
export const PARTICLE_SIZE_KEY = 'size';
export const PARTICLE_TEXTURE_KEY = 'texture';
export const PARTICLE_COLOR_KEY = 'color';
export const MOVE_WITH_SOURCE_KEY = 'moveWithSource';
export type EmitMode = 'continuous' | 'burst';
export const MODE_OPTIONS: { value: EmitMode; label: string }[] = [
{ value: 'continuous', label: 'Continuous' },
{ value: 'burst', label: 'Burst' },
];
export function emitModeOf(props: PropertyBag): EmitMode {
return textOf(props[MODE_KEY] ?? '') === 'burst' ? 'burst' : 'continuous';
}
export function isParticlePropertyNode(node: GraphNode): boolean {
return node.type === PARTICLE_PROPERTY;
}
export function propertyNameOf(node: GraphNode): string {
return textOf(node.params[PROPERTY_NAME_PARAM] ?? '');
}
/**
* A parameter's name is a property key on the source, so it has to be an identifier – and not one of
* the source's own rows, which would put two rows of that name in one inspector.
*/
export function isValidParticlePropertyName(name: string): boolean {
return isValidFieldName(name) && !RESERVED_KEYS.has(name);
}
const RESERVED_KEYS: ReadonlySet<string> = new Set([
EMITTING_KEY,
MODE_KEY,
RATE_KEY,
BURST_COUNT_KEY,
LIFETIME_KEY,
MAX_PARTICLES_KEY,
PARTICLE_SIZE_KEY,
PARTICLE_TEXTURE_KEY,
PARTICLE_COLOR_KEY,
MOVE_WITH_SOURCE_KEY,
]);
/**
* The parameters a particle graph declares, in node order: one number row per `Property` node with
* a usable name. A second node of the same name declares nothing new – both read the one value.
*/
export function particleParamDefs(graph: Graph): FlatDef<'number'>[] {
const rows: FlatDef<'number'>[] = [];
const seen = new Set<string>();
for (const node of graph.nodes) {
if (!isParticlePropertyNode(node)) continue;
const name = propertyNameOf(node);
if (!isValidParticlePropertyName(name) || seen.has(name)) continue;
seen.add(name);
rows.push(propertyRow(name, node.params));
}
return rows;
}
function propertyRow(name: string, params: PropertyBag): FlatDef<'number'> {
const min = numberParam(params, PROPERTY_MIN_PARAM, 0);
const max = numberParam(params, PROPERTY_MAX_PARAM, 1);
const row: FlatDef<'number'> = {
key: name,
label: name,
kind: 'number',
default: numberParam(params, PROPERTY_DEFAULT_PARAM, 0),
min,
step: niceStep(max > min ? max - min : 1),
};
// A max at or below the min would pin the value; treated as "no max" instead.
return max > min ? { ...row, max } : row;
}
function numberParam(params: PropertyBag, key: string, fallback: number): number {
const n = Number(params[key]);
return Number.isFinite(n) ? n : fallback;
}
function niceStep(range: number): number {
return 10 ** Math.floor(Math.log10(range)) / 20;
}