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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user