diff --git a/Architecture.md b/Architecture.md index 6a6e9b2..697c5e0 100644 --- a/Architecture.md +++ b/Architecture.md @@ -51,12 +51,16 @@ src/core/commands/ undoable commands, CommandStack, SetBagValueCommand base, src/core/graph/ graph model, NodeRegistry/NodeContext/values, interpreter, dropTarget, nested (behaviors as nodes), stateMachine (the State node, a machine's states, the ports they become), variablePorts, propertyPorts, lookup (what a node may look up: behaviors, state machines), keyRepeat; nodes/{events,flow,data,action,variable}/.ts +src/core/particles/ nodes (the catalogue), compile (a graph → one function per output), + emission (which particles are alive at a second — a pure rule, no state) src/core/store/ serialize (untrusted JSON → Project), http (one API call), ProjectStore/HttpProjectStore, TextureStore/HttpTextureStore, FontStore/HttpFontStore src/runtime/ World, Entity, System interface, Input tracker, CollisionSystem, AnimationSystem, BehaviorSystem src/ui/ widget toolkit (generic, knows nothing about assets) src/viewport/ view (transforms), items (what to draw), symbols (how to draw it), - TextureLibrary, FontLibrary, activeCamera — no editor concepts, so tests reach it freely + TextureLibrary, FontLibrary, ShaderLibrary, ParticleLibrary, + particles (one ParticleContainer per source), activeCamera — no editor concepts, + so tests reach it freely src/editor/ App → Editor (layout + ticker), PlaySession (the run), ProjectSession (load/create), EditorRun (run in editor), state, actions, shortcuts, debugHook, autosave, fileImport (drop/paste) → textureImport / fontImport, @@ -75,6 +79,8 @@ src/editor/texture/ TextureEditor (header + picker), TextureCanvas (picture + ImagePicker + imageGrid (the "+ → Texture" dialog), checker (the transparency board) src/editor/animation/ AnimationEditor (header + picker), AnimationCanvas (preview + frame strip), strip (its geometry) src/editor/statemachine/ StateMachineEditor (header + picker, on the graph canvas) +src/editor/particles/ ParticlesEditor (graph canvas above a preview strip, splitter between them), + ParticlePreview (the strip: the asset's own particles at the type's defaults) src/editor/list/ ListEditor (a row per item), slot (which list is being edited, and how it is written back) src/editor/console/ log (ring buffer, argument formatting, the console.* capture), ConsolePanel (the tail under the tabs) api/ PHP backend: index.php (routing), lib/{Store,TextureStore,FontStore,OriginalName,Response}.php @@ -92,6 +98,11 @@ property is one schema line; a new node is one file **plus a row in the table in `tests/core/node-catalog.test.ts`**, which fails if a node has no case; a canvas that pans or zooms puts **everything** the view moves into one masked container, not just the picture — an outline, a name plate or a checkerboard left outside it is drawn over the panel next door. +**A rule that produces something over time is written as a function of the clock, not stepped +per frame** — `core/particles/emission.ts` says which particles are alive at second *t* and +`core/particles/compile.ts` turns a graph into what one of them looks like at its age, so a field +of 500 particles is reproducible, holds still for a check, and needs no state beyond where its +source stood when each one was born. Both are pure and tested without a browser. Comments state contracts and reasons, not what the code does: a `/** */` on every export whose name does not say it all, `//` only for a decision or gotcha. diff --git a/Concepts.md b/Concepts.md index 50b3cf9..d0ae321 100644 --- a/Concepts.md +++ b/Concepts.md @@ -1,10 +1,11 @@ | Term | Meaning | |---|---| -| **Asset** | Named thing with a type (`scene`, `camera`, `sprite`, `text`, `behavior`, `collider`, `container`, `texture`, `animation`, `statemachine`, `shader`, `font`) and typed properties. Assets have **no position**. | +| **Asset** | Named thing with a type (`scene`, `camera`, `sprite`, `text`, `behavior`, `collider`, `container`, `texture`, `animation`, `statemachine`, `shader`, `font`, `particles`) and typed properties. Assets have **no position**. | | **Texture** | An uploaded image. Scenes, textures, animations, state machines, shaders and fonts are the six types that cannot be placed: a texture has no position, it is *worn* by a sprite through the ordinary property `texture`. See [Textures](Textures). | | **Font** | An uploaded typeface, worn by a text through the ordinary property `font`; no font means the editor's own. Uploaded and picked exactly like a texture, and stored the same way. See [Fonts](Fonts). | | **State machine** | A named set of **states**, edited as nodes in its own tab. A behavior puts it into a state with `Set state` and branches on it with `State branch`; each behavior instance keeps its own current state. See [State machines](State-machines). | | **Shader** | A node graph that becomes the GLSL a sprite is drawn with, edited in its own tab. A sprite wears it through `shader`; the numbers the graph declares are rows below that dropdown. See [Shaders](Shaders). | +| **Particles** | A node graph that says what *one* particle is as a function of its age and of a random number drawn once for it — no forces, no simulation. Unlike the other graph assets it **is** placeable: the object made from it is a source, whose rows say how many go out and for how long they live. Because a particle is a formula, the same second always draws the same picture and the tab's slider can jump to any of them exactly. See [Particles](Particles). | | **Text** | An asset that draws a line of text (`text`, `font`, `fontSize`, `color`, `align`) — titles, HUD readouts, button labels. Its content is an ordinary property, so a `Properties` node of type Text with `text` switched to Set changes it while playing. | | **Enabled** | Every placeable asset has the switch `enabled` (default on). A disabled object is not drawn, does not collide and its behavior does not run — **and neither does anything parented below it**. Switching one instance off is a normal override, which makes a container a *screen*: switch it on to show it, off to hide it. From a graph, `Properties` with `enabled` switched to Set does it, and `Is enabled` asks (including the parents); a behavior hears the switch as `On enable` / `On disable`. | | **Scene object** | An asset placed into a scene: a link to the asset + a **position** (`x`, `y`, `z`) + sparse **overrides** of the asset's properties + an optional **parent** (another object of the scene). Drawn objects also carry **transform** rows (scale, rotation, mirror) — properties like any other, and they cascade to children the way a position does (see [Scene editing](Scene-editing)). Scenes cannot be placed. | @@ -35,7 +36,7 @@ no movement): everything that happens is defined by behavior graphs. scene, Undo/Redo, Play/Pause, and on the right the notice line, the save status and the frame rate. The **Assets** panel's `+` creates an asset and opens its tab. A click on a row only selects (the inspector shows it, no tab moves); a **double-click** opens the tab the asset lives -in — Scene, Behavior, State machine, Shader, Texture, Animation or Container — and on a scene +in — Scene, Behavior, State machine, Shader, Particles, Texture, Animation or Container — and on a scene also makes it the active one. `Enter` does the same once the list has keyboard focus (`Tab`). A sprite, camera, text or collider has no tab of its own, so a double-click on one does nothing. Right-clicking a row offers *Set as active scene*, *Create sprite from texture*, *Create animation diff --git a/Development.md b/Development.md index f8e9883..b942b25 100644 --- a/Development.md +++ b/Development.md @@ -12,7 +12,7 @@ npm run check # typecheck + lint + format:check + tests, the gate CI runs # code is lost (tail's is reported), so a red run reads as green npm run build # writes dist/ and regenerates index.html ./build.sh # the same under `set -o pipefail`, for callers that pipe the output -node tools/seed-e2e.mjs && node tools/e2e.mjs # 790 editor checks, screenshots in /tmp/gm-e2e-*.png +node tools/seed-e2e.mjs && node tools/e2e.mjs # 809 editor checks, screenshots in /tmp/gm-e2e-*.png GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 — a subset while working on one step ./e2e.sh start [GM_STEPS] # the same run detached, see below ./e2e.sh status # live progress, or the last run's result @@ -63,7 +63,7 @@ to touch it. The e2e run is a list of steps in `tools/e2e/` (hierarchy, inspector, play, assets, drawing, graph, parenting, colliders, containers, text/enabled, menus, textures, frames, overflow, aspect, container tab, run-in-editor, pause, nested behaviors, key repeat, list tab, struct variable, -variable nodes, property nodes, animations, state machines, console, shaders, transforms, fonts) that build on each other's +variable nodes, property nodes, animations, state machines, console, shaders, transforms, fonts, particles) that build on each other's scene state and share the CDP driver in `tools/lib/harness.mjs` — `press`, `undo`, `play`, `object(id)`, `world()`, `mutate(js)` and friends, so a check reads like the interaction it exercises. @@ -151,4 +151,4 @@ widths to find click points. The **render device is not in this repo** — `/dev and a 1 GB `/dev/shm` come from `container.options` in the runner's own `config.yaml`, which applies to every job on that runner. Without them chromium falls back to llvmpipe, which gets WebGL 1.0 only. Measured in the job container: `ANGLE (Intel, Vulkan 1.3.230, HD Graphics 630)`, -WebGL 2.0, and the same 790 checks as here. +WebGL 2.0, and the same 809 checks as here. diff --git a/Particles.md b/Particles.md new file mode 100644 index 0000000..bbff675 --- /dev/null +++ b/Particles.md @@ -0,0 +1,112 @@ +A **particle system** is an asset: a node graph that says what *one* particle is — where it sits, +how big it is, how it is turned, what colour it has — as a function of its **age** and of a random +number drawn once for it. Unlike a texture or a shader it **can be placed**: an object made from it +is a *source*, and its rows say how many particles go out and for how long they live. + +Nothing is simulated. A particle is not pushed by forces frame after frame; it is a formula +evaluated at whatever second is being drawn. Three things follow from that, and they are the reason +the model was chosen: + +- **The same second always looks the same.** Two runs at the same frame rate, or a different one, + draw the same picture — the field is a function of the clock, not of how many frames have gone by. +- **Any second can be shown at once.** The preview strip in the tab has a slider: drag it to 2 s and + the particles stand where they are at 2 s, exactly, with nothing simulated on the way there. +- **Collision is out, for good.** "Sparks bounce off the ground", wind, drag mid-flight — none of + these can be written as a function of age, and no later addition will change that. + +## The Particles tab + +`+` in the **Assets** panel makes a particle asset and opens the **Particles** tab on it (a +double-click on an existing one does the same; a click only selects). The upper part is the same +canvas the Behavior and Shader tabs have: right-click places a node, drag moves, drag from a port +links, `Snap` snaps to the 24 px grid, `Fit` frames the graph, undo takes every step back. The +readout on the right says `N parameters` while the graph compiles, otherwise why it does not +(`No Output node`, a bad parameter name, `Feeds itself`). A node that keeps the graph from +compiling shows the reason on its body and a red frame; a source whose graph does not compile emits +nothing. + +Below it, the **preview** strip emits the asset's own particles from its middle, with the type's +default rows (20 a second, one second each, a plain 16 px square). `Hold` stops the clock where it +is and turns into `Run`; the slider next to it jumps to any second in the first three lifetimes; +`Restart` begins the stream again at particle 0. The strip deliberately draws plain squares: which +picture a source throws belongs to the placed object, not to the asset the tab is showing. + +## The nodes + +Every value is a **number**. A colour is a number too — packed `0xRRGGBB`, as everywhere else in the +project — which is why blending two of them needs *Mix color* rather than *Mix*: mixing the packed +numbers of red and blue would run through green. + +| Node | Ports | What it does | +|---|---|---| +| **Age** | out `age` | How far through its life the particle is: 0 at birth, 1 when it dies. The axis most graphs are drawn along. | +| **Age in seconds** | out `seconds` | The same in seconds — the *t* of a formula of motion. | +| **Time** | out `seconds` | Seconds since the source started emitting. The same for every particle alive, so it drifts them together rather than apart. | +| **Index** | out `index` | Which particle this is, counting up from the first the source emitted: a fan or a ring instead of a scatter. | +| **Random** | in `min`, `max` · out `value` | A number drawn once per particle and kept for its whole life. **Two Random nodes draw differently**, so the direction and the speed of one particle scatter apart instead of being the same number. | +| **Gravity** | in `strength` · out `x`, `y` | How far a steady pull has moved the particle by now: `strength * age * age / 2`, down positive. Add it to the offset. | +| **Start velocity** | in `angle`, `speed` · out `x`, `y` | How far a constant speed has carried it: angle in degrees (0 right, 90 down), speed in pixels per second. | +| **Output** | in `x`, `y`, `scaleX`, `scaleY`, `rotation`, `color`, `alpha` | What the particle is drawn as, all of it relative to the source. One per graph. An input left unconnected stays what a particle without a graph would be: at the source, full size, upright, the source's own colour, opaque. | +| **Property** | out `value` | Declares a number the *source* sets: `Name` is a row on every object placed from this asset, `Default`, `Min`, `Max` are its range. One word (letters, digits, `_`), unique in the graph, and not the name of one of the source's own rows. | +| **Number**, **Color** | out `value` / `color` | Constants, typed or picked on the node. | +| **Add**, **Subtract**, **Multiply**, **Divide** | in `a`, `b` · out `result` | Arithmetic. Division by zero reads as 0 — infinity would draw as nothing at all. | +| **Min**, **Max**, **Clamp** | · out `result` | The smaller, the larger, and a value held between two bounds. | +| **Mix** | in `a`, `b`, `t` · out `result` | Blends two numbers: `t` = 0 is `a`, 1 is `b`, beyond that it carries on. | +| **Mix color** | in `a`, `b` (colour), `t` · out `color` | The same for colours, channel by channel — a flame fading to smoke. | +| **Sin**, **Cos** | in `value` · out `result` | Of an **angle in degrees**, one full wave every 360. Degrees, not radians, because `rotation` and *Start velocity* are in degrees too. | +| **Sqrt** | in `value` · out `result` | Rises fast and then slows — how smoke drifts up. Below 0 it is 0. | +| **Abs**, **Floor**, **Fract** | in `value` · out `result` | Distance from 0; rounded down; the part after the decimal point (a rising value becomes a sawtooth). | +| **Step** | in `edge`, `value` · out `result` | 0 below the edge, 1 from it on: a hard cut. | +| **Pow** | in `value`, `exponent` · out `result` | To a power: 2 squares, 0.5 is the square root. Below 0 the value counts as 0. | + +## The source + +A placed source carries a position and the transform rows every drawn object has (scale, rotation, +mirror), so hanging one on a moving ship turns the whole spray with it. Its own rows are: + +| Row | Default | What it means | +|---|---|---| +| **Emitting** | on | Whether new particles go out. Switched off, the ones in flight live out their lives — unlike `Enabled`, which takes them away at once along with everything parented below. | +| **Mode** | Continuous | *Continuous* is a steady stream; *Burst* throws the whole count at once. | +| **Rate** | 20 | Particles per second, for a continuous source. | +| **Burst count** | 20 | How many go out per burst. | +| **Lifetime** | 1 s | How long one particle lives — **the scale that gives `Age` its meaning**. | +| **Max particles** | 500 | The most alive at once; past it the oldest are dropped. | +| **Size** | 16 | The particle's size in pixels, which the graph's `scaleX`/`scaleY` multiply. | +| **Texture** | none | The picture each particle is drawn with. Without one it is a plain square. | +| **Color** | white | The tint, and what an unconnected `color` output reads. | +| **Move with source** | off | Off: a particle stays where it was born, so a moving source leaves a trail (rocket smoke). On: it hangs on the source and travels with it (sparks on a carried torch). The difference shows only while the source moves. | + +Below them come the rows the asset's graph declares, one per `Property` node — so two fountains of +the same asset can spray at different strengths, exactly the way two sprites wearing the same shader +can. Overriding one of them on a placed object is an ordinary override, with reset and apply. + +A **behavior** can start a source over with the `Emit` node (its `source` input names which one): in *Burst* +mode that is another burst, in *Continuous* mode the stream begins again at particle 0. + +## Recipes + +**Rain.** `Age in seconds → Multiply(b: 400) → Output.y`, and a `Random(-300, 300)` into +`Output.x` to spread the drops across the screen. Three nodes. + +**A spark fountain.** `Random(0, 360) → Start velocity.angle`, `Random(100, 300) → .speed`, its +`x`/`y` into two `Add`s together with a `Gravity(800)`, and `Age → Subtract(a: 1) → Output.alpha` +to fade them. This is where the formula model asks its price: gravity has to be written as the +offset it has produced (`g * t * t / 2`), not as a force — which is what the *Gravity* node is for. + +**Smoke.** `Age → Sqrt → Multiply(b: -60) → Output.y` — rising and slowing without a damping +constant anyone has to guess. Add `Time → Multiply(b: 180) → Sin → Multiply(b: 20) → Output.x` for +drift, `Age → Add(b: 1) → Output.scaleX`/`scaleY` to let it grow, and `Mix color` from a warm grey +to a cold one over `Age`. + +## What it cannot do + +- **No collision, no wind, no drag.** See the top of this page: they are not functions of age. +- **A source is culled by nothing.** Its particles may be anywhere on screen, so the source itself is + never skipped for being out of frame — with many sources in one scene that costs. +- **Particle time is the editor's clock, not the game's.** Pausing the game does not freeze a spray. +- **The preview shows the asset, not an instance**: the type's default rows and a plain square, never + the texture or the rate a particular placed source was given. +- **A source with `Emitting` off draws nothing at all** once the last particle has died, so it + disappears from the scene view; it is still in the hierarchy panel, and selecting it there shows + its handle. diff --git a/_Sidebar.md b/_Sidebar.md index ab12454..86cb6c8 100644 --- a/_Sidebar.md +++ b/_Sidebar.md @@ -9,6 +9,7 @@ - [Animations](Animations) - [State machines](State-machines) - [Shaders](Shaders) +- [Particles](Particles) - [Containers](Containers) - [Keyboard](Keyboard)