320d3e27e1378a9bc7b68a390cc77ed9fd861616
97
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
320d3e27e1 |
Add a Lerp node that blends from a to b with an ease (#246)
Every animation in a behavior had to build its interpolation from Subtract, Multiply and Add, once per axis. The gold pickup in "Garden of Sacrifices" needed about 30 nodes for that alone. A single Lerp node with an ease makes those chains one node each. - Lerp has the inputs a (default 0), b (1) and t (0) and the output result. It sits in the data category next to Clamp. - The Ease setting offers Linear, In, Out and In-out, all quadratic. The node row shows it as "Ease: Out". - t is held in 0..1. A timer that overshoots in the last frame still lands exactly on b. Tests: - ./check.sh: 178 files, 2583 tests, CHECK EXIT 0 - 7 new cases in the node catalog test. Without the clamp on t, 2 of them fail. - e2e 6z-iii (2 checks): typing "lerp" in the palette adds a linear Lerp, and picking "Out" in the inspector reaches the node and its row. GM_STEPS=1-6: ALL PASSED, 267 checks. - README check sum is 1459, computed, not counted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
0c5ac32b59 |
Let structs and lists nest to any depth (#239)
Structs and lists were capped at two fixed levels (list of structs, struct with a list field). Anything deeper was dropped silently on load, in node settings and when written to a variable. The cap came from an early design choice, not from a technical need, so it is lifted completely instead of adding a third fixed level. - One kind list NESTED_KINDS replaces ListItemKind and StructFieldKind. The flat special cases in listItems.ts are gone, every value goes through one recursive path. - A struct inside a struct is declared, like a top-level struct. Values coming in over an edge are brought into that shape. Struct items in lists still take their fields from the value. - New valuePath.ts addresses any nested spot by keys and indices. ListSlot.field is now ListSlot.path. - Field commands (add, rename, remove) take the path to the struct. They change declaration, own value and all overrides in one undo step, as before on the top level. - Inspector: "+ Field" offers Struct and List on every level. A nested struct is a header row with its own "+ Field", its fields are indented one step further. - List tab: a list item shows "n items" and opens one level deeper. The title shows the path, e.g. "Listy · nums · [3]". - Pack in Pack now writes a struct into a struct field instead of text. Objects are kept at any depth and stripped at every exit. - A list edit in a struct field without an existing object override now starts from the shown value instead of an empty struct. - Old projects load unchanged. Save, load and save again gives the same result. Tests: - ./check.sh: 172 files, 2498 tests, CHECK EXIT 0 - e2e 21 + 22: ALL PASSED, 90 checks (new 21d-iii, 22f-ii, 21d and 22f updated) - 20 new unit tests in nested-values.test.ts and nested-list-slot.test.ts, three tests that pinned the old cap turned around - 6 counter-probes, each fails 1 to 8 tests, originals restored and checked with cmp - Not covered: a nested struct inside a struct item in the list tab has no e2e click Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
483c34bb5c |
Let Destroy keep the children and hand them back as a list
Destroying a container used to take everything below it along. Now Destroy can remove just the object itself and pass its direct children on, so a behavior can keep working with what was inside. - New input keep children (bool, default off). When on, only the object goes. Its direct children move up to the object's parent, or to the top level if there is none. Grandchildren stay where they are. - New output children (object list) with the direct children that were kept. It is empty when keep children is off. - The children keep their place on screen. carriedTransform folds the destroyed object's position, rotation and scale into their own, the same as reparenting in the editor. - Behaviors and the rigidbody of the object are always destroyed with it and are not in the list. Moved up they would steer or simulate the parent. - World remembers which children it reparented, and syncToModel writes their new parent into the scene. A reload of the model keeps it. - Limit, read from the code only: in the editor run the contents of a placed container are not baked. Moved children there last for the run only. Tests: - ./check.sh: 170 files, 2478 tests, CHECK EXIT 0 - e2e 34 (the module with a Destroy node): ALL PASSED, 21 checks - world-sync: world matrix of child and grandchild unchanged under a rotated and scaled parent, parent survives syncFromModel, top level case, already destroyed object gives [] - destroy-keep-children: real graph Destroy(Owner) -> Log(children) logs Lid, Label without the grandchild Nail - 3 counter-probes, all caught: no world transform (1 test fails), no parent in the scene (1), behaviors and rigidbody moved up (2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
7979b8ff19 |
Let structs and lists hold objects across behavior runs (#238)
An object could only live in a dedicated object variable, kept in slots
beside the variable bag. Struct fields and list items could not hold
one, so a spawned object packed into a struct or list was lost. This
replaces the slots with one mechanism for all three places.
- "No object" has one resting form everywhere: {"$object": ""}. A
running game can tell an object slot from an empty text that way.
- During play the object handle lives directly in the value, also
nested in structs and lists. objectSlots.ts and the special paths for
Keep and Properties are gone.
- liveObjects.ts strips objects to (null) at every exit: overrides the
editor run writes back to the scene, saved values of persistent
Variables assets, live values in the inspector, and the world
snapshot of the test hooks (objects as names there).
- A destroyed object reads as (null) through isAlive, alone, in a
struct field or as a list item.
- "+ Field" and "+ Item" offer Object. The inspector and the list tab
show a fixed (null) row without input.
- Lists from Raycast all stay objects when written to a variable.
Before they turned into names.
- A struct that only changes by an object writes no override. A list
that grows by an object writes [(null)], its length really changed.
Found on the way: CI at
|
||
|
|
98211a8d54 |
Let a variable hold an object across behavior runs
A spawned object was only reachable in the run that spawned it. The Spawn output could not go into a variable because "object" was a port-only kind, so the next run had no way back to the instance. - "Object" is now a variable kind. The reference lives for the run only. The file stores the declaration and an empty value. - The live reference sits in slots beside the variable bag (graph/objectSlots.ts), not in it. The bag is the behavior's props, and syncToModel would have written an object into the scene. - A destroyed object, or one from a restarted or left scene, reads as nothing. A dead reference is dropped from its slot. - Keep (#229) hands the slots of kept variables on to the next call of a sub-behavior. - Properties of kind Behavior reads and writes another behavior's object variable through the same slots. - The inspector shows "(null)" for it and offers no input. - A persistent Variables asset with an object variable shows a warning under "Persistent". Saved values never keep the object, every run starts it at (null). Objects inside lists and struct fields are left out on purpose. Emptiness is checked with Equals against an empty b. A node "Is valid" does not exist yet. The test helper graphEditor().variables() now also reads the kinds of a Variables asset a node points to. Tests: - ./check.sh: 169 files, 2463 tests, CHECK EXIT 0 - e2e 6 + 23: ALL PASSED, 146 checks (new 23k, 2 checks) - e2e 32: ALL PASSED, 18 checks (new 32f-ii) - 10 unit tests in tests/core/object-variable.test.ts, one of them runs a persistent asset over two runs - 5 counter-probes (value in bag, isAlive always true, no Keep hand-over, Properties without slots, object in saved values) each fail their test - e2e check total 1406, computed only Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
923baf2557 |
Let Spawn take an asset over an edge and find a child by name (#237)
An asset could only be picked as a setting on the Spawn node. It could not be wired in, held in a variable or put into a list. Finding an object below the own one needed a scene-wide search by name. - "Asset" is now a kind like Animation or Sound: port, variable, list item and a new "Asset" literal node, with its own cube icon. - Spawn and Is asset have an `asset` input. It uses the key of the old setting, so a saved asset reads as the value of the input and older projects keep spawning without a migration. - New node "Child by name" searches below an object level by level. A child wins over a grandchild of the same name. Nothing found or no object gives null. Tests: - ./check.sh: 168 files, 2453 tests, CHECK EXIT 0. - 9 new unit tests in tests/core/asset-kind.test.ts, catalog cases for Spawn, Is asset, Child by name and the literal. - e2e 6z-ii (2 checks): node rows and a real drag of the literal onto `asset`. e2e 6: 119 checks passed. Spawn checks in 3 and 9 passed. - Counter-checks: depth-first search fails 1, Spawn reading only the old setting fails 3, runtime ignoring the parent fails 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
1d18862ed6 |
Give unconnected node inputs the default the node shows (#234)
A new node handed null to every unconnected input that had no port default, even a Bool. The inspector showed false, 0, "" or an empty list at the same time, so what the node read was not what the editor said. 97 of 135 inputs of the built-in nodes were affected. - inlineValue() falls back to the default the inspector shows. One place covers every node, also future ones and saved projects. - A default list is handed out as a fresh copy per read, so a node that edits its input in place cannot change the shared default. - The node shows the value it reads: an empty text or list as (empty), an object or struct input without a link as (null). Live values while playing use the same words. - A key input without a key keeps showing a dash, like the key field in the inspector. - Visible changes in old projects: To text without input gives "" instead of "0", Select and First not empty give "" instead of null. Tests: - 8 new unit tests in tests/core/inline-defaults.test.ts, among them one over all built-in nodes that no typed-in input reads null. - The e2e check in module 6 reads the rows of an unlinked Name of node. The check sum stays the same. - ./check.sh: 167 files, 2434 tests, exit 0. e2e 6: 117 checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
21d7149756 |
Let a nested behavior without a start node run from On start (#230)
A behavior built as a plain behavior, with On start and no Behavior start, did nothing when another behavior called it through Run behavior. The node only showed a warning. Such a behavior should run as a call too, without the author having to rebuild its entry. - New callEntries() in nested.ts returns the Behavior start node or, when there is none, every On start node. callProblem uses it too. - GraphInterpreter.call() follows all entries one after the other, in graph order, in the same frame and with the same variables. - Behavior end still returns what reaches it. A call through On start has no inputs, because only the start node declares them. - It is a fallback only. With a Behavior start present, On start nodes stay out of the call. - The node warning now reads "No start or On start node - does nothing" and only shows when neither exists. - Keep and local state machines behave as before. Tests: - ./check.sh: 163 files, 2396 tests, CHECK EXIT 0 (before 2392). - 4 new unit tests in tests/core/nested-behavior.test.ts: the flow after On start runs, the return value arrives, several On start nodes run in graph order, a start node keeps On start out. - e2e 19: ALL PASSED, 35 checks. Only the expected warning text changed, the check sum stays at 1385. - Counter-checks: without the fallback 3 tests fail. With only the first On start exactly the order test fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
454d202109 |
Let each variable of a behavior choose whether calls keep it (#229)
The first version of #229 put a Keep state switch on the Run behavior node. The user wanted it where the variable is declared instead. Whether a value lives on belongs to the variable, not to every call site. - The node switch keepState is gone again. RunBehavior, CallResult and nested.ts are back to what they were before #229. - BehaviorAsset.keptVariables lists the kept names. It lives on the asset and not in PropertyDef, because that is the schema of every inspector row. - A variable added from now on starts with Keep on. Variables in existing projects start with Keep off, so old projects run unchanged. - SetVariableKeptCommand flips the switch as one undo step. Add, remove and rename carry the list along. Loading drops names that are not declared. - The interpreter keeps only the listed variables, per Run behavior node and per object as before. The others start at their defaults. - Local state machines in a called behavior start empty on every call again, as before #229. Global and scene machines are unchanged. - The inspector shows a Keep button in each variable row of a behavior. Its state is part of the asset signature, so a click redraws the row. Without that the button kept its old look until the inspector was reopened. Found: the project Garden of Sacrifices still has keepState = true on the call Player -> Character Movement. It is ignored now. Direction there predates the switch, so Keep is off until set by hand. Tests: - ./check.sh: 163 files, 2392 tests, CHECK EXIT 0 (before 2388) - tests/core/keep-state.test.ts rewritten, 15 tests - e2e 19: ALL PASSED, 35 checks. 19c-ii now clicks the Keep button of a new variable: on when added, off by a click, on again, off after undo. - Counter-checks, each fails its own test: keep list ignored (2), states kept (1), new variable not kept (8), rename drops the list (1), no Keep button (e2e 19c-ii). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
7c70245c79 |
Let a nested behavior keep its variables between calls (#229)
A Run behavior call started with fresh variables every time, so a nested behavior could not count, remember or keep a local state machine across calls. A new "Keep state" switch on the node lets the callee resume where its last call left off. It is off by default, so existing projects run unchanged. - RunBehavior gets the boolean parameter keepState and its node description explains it. - GraphInterpreter keeps the state in keptCalls, keyed by the call path, the chain of Run behavior nodes. Two nodes calling the same behavior each keep their own state. - One interpreter exists per behavior instance, so every object gets its own state without further code. - Local state machines of the callee are kept like its variables. - resumedVariables() matches kept values against today's declarations. A new variable starts at its default, a removed one drops out, and one whose kind changed starts over. - The kept state lives as long as the object in the run. It is not written to save games. Limit: a callee input named keepState collides with the switch. The same already holds for the name behavior, so it is not caught. Tests: - ./check.sh: 163 files, 2388 tests, CHECK EXIT 0 (before 2377) - 10 new unit tests in tests/core/keep-state.test.ts and 1 new node catalog case. - e2e 19: ALL PASSED, 35 checks, with the new check 19c-ii for the checkbox, its click and undo. - Counter probes: ignoring keepState fails 5 tests, state per behavior instead of per node fails 1, dropping the outer path fails 1, skipping the kind check fails 1, and without the node parameter the e2e check fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
5dbac3a8df |
Add a Select node that picks one of two values by a condition
Choosing between two values meant a Branch with the whole action twice behind it, for example two Play animation nodes that differ only in the animation. Every later change had to be made in both. Select picks the value, so the action stays single. - Select is a data node with the inputs condition, false and true and the output value. It passes on "true" if the condition holds, else "false". An unconnected condition counts as false, and an empty picked input gives null. - The ports are named false and true, not a and b, so nobody has to guess which input is which. The order false, true follows Unreal's Select and Blender's Switch. - false, true and value share one type through `sharedType`, like First not empty. With two Animation literals plugged in, value takes their type and fits Play animation directly. - Both inputs are always evaluated. Data nodes have no side effects, so that does not matter. Tests: - ./check.sh: 160 files, 2363 tests, CHECK EXIT 0 (before 2356). - 5 cases in tests/core/node-catalog.test.ts and 2 in tests/core/shared-port-type.test.ts. - Counter checks: with the inputs swapped all 5 catalog cases fail, without sharedType exactly the type test fails. - No e2e change, the sum stays at 1380. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
5bf1ee7a4e |
Let Equals match a number against a typed number like 130 (#224)
The float tolerance from the last commit never reached the case it was built for. The inputs of Equals are of type any, and their inline field is a text field. A typed 130 therefore arrives as the string "130", and the tolerance only applied when both sides were real numbers. The node fell back to comparing "129.99999999999997" with "130" as text. - A number against a text that parses as a number now uses the same 1e-9 rule as two numbers. - An empty text does not count as 0. - Two texts are still compared as text, so "1.0" and "1" stay different. - Compare was not affected, its inputs are real number fields. Tests: - 3 new cases in node-catalog.test.ts: 129.99999999999997 vs "130" is equal, 0 vs "" is not, "1.0" vs "1" is not. - Counter-check: with the old rule (two real numbers only) exactly the "130" case fails. Original restored and compared with cmp. - ./check.sh: 156 files, 2314 tests, CHECK EXIT 0 (before 2311). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
9d6a0aebd3 |
Let Compare and Equals treat float noise as equal numbers
A graph that checks "time - late == 6" after On timer (#224) failed, because the difference comes out as 5.999999999999972. Both values are sums of measured frame times, and they round along different paths. The only workaround was a Round node in front of every comparison. - New helper nearlyEqual in values.ts. Two numbers count as equal when |a - b| <= 1e-9 * max(1, |a|, |b|). The tolerance grows with the size of the values, so it holds for hours of summed dt. - Compare uses the rule for all six operators. "<" and ">" only hold when the values are not nearly equal, "<=" and ">=" also hold when they are. So "==" and ">" are never true at the same time. - Equals uses the same rule when both inputs are real numbers. Objects still compare by identity, everything else as text. The node description says so. - The tolerance is fixed, there is no parameter on the node. Physics values come from float32 in Rapier and are not covered on purpose. - The log display is unchanged and still prints the raw number. Tests: - ./check.sh: 156 files, 2311 tests, CHECK EXIT 0 (before 2300). - 11 new cases in node-catalog.test.ts, including the value from the log against 6, growth with magnitude, 6.001 != 6 and 1e-6 > 0. - Counter-check exact compare: exactly the 8 noise cases fail. - Counter-check "<"/">" without the exclusion: exactly 2 cases fail. - No e2e change, the check sum stays at 1374. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
6fecb2e841 |
Tell On timer how late its frame is, so time minus late is exact (#224)
A timer can only fire on a frame. It fires on the first frame past each due time, so Time.time at that moment is the due time plus up to one frame. On a display that is not exactly 60 Hz this delay creeps up by a millisecond every few seconds and falls back to zero once it reaches a whole frame. The issue saw 82.009 after 82 ticks. The timer itself does not drift, it keeps the remainder when it fires. - On timer gets a number output late. It is what is left of the timer's clock after taking one interval, so time - late is exactly the due time. - When one long frame catches up several ticks, each gets its own late. The first one was due longest ago and is the latest. Tests: - New unit test in behavior-events: late 0.125 after a frame past 1.0 s, 0 on the dot, 1.5 and 0.5 for two ticks caught up in one frame. - Counter-check: late taken before the interval is subtracted fails exactly that test. Original restored with cmp. - ./check.sh: 156 files, 2300 tests, CHECK EXIT 0 (was 2299). - No e2e change. The e2e modules only wire the exec of On timer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
d444b27fae |
Let behaviors read and set tilemap cells while the game runs (#135)
A behavior could move sprites around but had no way to look at or
change the tilemap it was standing on. Doors, breakable blocks and
"is there ground ahead?" checks all need that.
- New nodes Tile at, Set tile, World to cell and Cell to world. The
conversion honours position, rotation, scale and mirroring of the
placed map. A small Tile literal node lets a graph compare what it
read against a fixed tile.
- The layer is picked by name from all tilemap layers in the project
through a new field kind. "First layer" takes the map's first one.
Because layer is an input, a text can also be wired in.
- Each placement keeps only its edited cells in liveTilemap.ts,
layered over the asset's cells. A second placement of the same map
stays untouched, and the asset itself is never written.
- A read sees a Set tile from earlier in the same flow. Drawing picks
up the edit at once and neighbouring autotile edges adapt.
- Solids see the edits straight away, so a Move in the same frame
already hits the new cell. Rapier rebuilds its shapes on the next
step.
- After Stop the map is back to what was painted. The editor run shows
the edit in the scene view while it lasts and does not touch the
project.
- An unwired tilemap input means no map, like Get position. Cells
outside the grid are refused and done reports false.
Tests:
- ./check.sh: 155 files, 2271 tests, CHECK EXIT 0 (before 2245).
- tests/core/tile-access.test.ts (14), one physics test for a floor
set at runtime, catalog entries for the five nodes.
- e2e 39,40: all passed, 86 checks (before 81), five new in 40s.
- Counter-checks: collision ignoring edits fails 3 unit tests, the
solids cache missing same-tick edits fails only the "at once" test,
drawing ignoring edits fails the 2 drawing checks, the editor run
not reporting a new tile fails only its check.
- 40s uses a still tile. The animated fixture tile redraws every frame
and hid the last counter-check at first.
- Check total 1351 + 5 = 1356, computed. CI confirmed 1351 at
|
||
|
|
056a622524 |
Refactoring round 6: shared sheet canvas, fewer copies (#85)
Recurring cleanup round. Behaviour is unchanged. Duplicated code in
src went from 27 clones with 299 lines to 6 clones with 56 lines
(jscpd, the two identical OFL licence texts not counted).
- SheetCanvas is the new base of the texture, pixel art and vector art
canvases. It holds background, mask, hint, panning, wheel zoom, fit,
view save and restore and the screen mapping. Pixel art keeps its
whole-pixel panning and fixed zoom steps.
- mixColor in src/core/util/color.ts replaces three copies in
animation, gradient and particles. It clamps t like the particle one.
- fingerprint now lives once in generated.ts, next to the revision
contract from #185.
- MoveCommand next to RenameCommand is the base of the three reorder
commands for tile pictures, tilemap layers and vector shapes.
- The two raycast nodes share inputs, settings and filter via rayOf.
- canvasRect() lives once in AssetTabEditor.
- Removed unused code: findPixelArt, isDrawableShape,
GeneratedPictures.has and SnapToggle.snapping. Tests now check the
same things through equivalent paths.
- 38 values used only in their own file lost their export.
- Three comments that told history now describe the current state.
The bundle stayed md5-identical for those edits.
- README lists vector art, pixel art and palette, and links the two
wiki pages.
Findings:
- PaintTilesCommand.changes is meant to prevent empty undo steps but
has not been called since #128. The rectangle tool therefore records
a no-op undo step when it paints the same tile over itself. Left as
is, because fixing it changes behaviour.
- No test covered middle-click panning in any sheet tab before. 13f
and 48a now check it.
- The default values of the ray inputs were untested. Length 1000
could become 10 without a red test. tests/core/raycast.test.ts now
covers it.
- CI measured 1308 editor checks at
|
||
|
|
d7c0b7cfdb |
Variables and state machines can reset with every scene (#164)
A shared variable or machine used to live either per object or for the
whole run. Levels need a third lifetime: shared like global, but built
fresh from the declarations whenever a scene is loaded.
- New scope `scene` for Variables assets and state machines. A missing
field keeps the old meaning: `global` for assets, `local` for
machines, so existing projects do not change.
- Returning to a scene that was visited before is fresh as well. There
is no memory per scene.
- A `scene` machine announces its start state again after a scene swap,
so `On state entered` in the new scene hears it. A global machine
stays quiet because it never left its state.
- The rule lives in two pure functions in `sharedState.ts`
(`carriedForward`, `restartedAssets`). BehaviorSystem only calls them.
- The inspector shows a Scope dropdown on Variables assets and a third
step on the machine dropdown, with a note on what `scene` means.
Decisions:
- `scene` and `persistent` exclude each other (variant A). The choice
dialog was cancelled, so this is an assumption to revisit. A
scene-scoped asset hides the Persistent switch, and switching to
`scene` drops the flag (undo restores it). The guard sits in
`isPersistent` itself, so no path can save values the scope throws
away anyway.
Findings:
- `clickText('Scene')` hits the Scene tab, not the menu entry. One e2e
check was red because of that, and its partner was falsely green
because it compared `undefined` with `undefined`. Both use `clickLast`
now.
Tests:
- ./check.sh: 144 files, 2142 tests, CHECK EXIT 0 (before 143 / 2133)
- 9 new unit tests in tests/core/scene-scope.test.ts
- e2e 32 fresh: ALL PASSED, 17 checks
- e2e 1-3,17,18,26,32,35 fresh: ALL PASSED, 213 checks
- 3 new e2e checks (35: scene-scoped asset resets on swap, 32h: dropdown,
note, hidden Persistent switch)
- Counter-checks bite: ignoring `restarted` fails 2 unit tests and the
new check in 35, the old announcement rule fails 1, dropping the scope
guard in `isPersistent` fails 1. Originals restored, cmp IDENTICAL.
- Check sum 1307 (1304 + 3) is computed. Last CI measurement: 1293.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
d7a2521ca7 |
A port can take its type from what is plugged into it (#207)
Until now every port of "First not empty" was grey and typeless, so the
editor could not tell you what actually travels through the node. The
output is now typed as soon as both inputs agree, and every any-input is
painted in the colour of its own edge. Nothing changes at runtime.
- A NodeDef can declare which of its ports carry the same value
(`sharedType: { inputs, outputs }`). The rule itself lives as a pure
function `agreedType` in `src/core/graph/portTypes.ts`.
- Only the output inherits. It becomes the shared type when every input
of the group is connected and they all carry the same type, otherwise
it stays `any`.
- The inputs stay `any` on purpose. `canConnect` checks against the port
type and a busy input is replaced by the new edge, so a typed `a`
could never be re-plugged with a text without disconnecting first.
- Chains do not propagate. When looking up what feeds an input, a node
of the same kind reports the `any` it is registered with, so two of
them in a row stay untyped and a data cycle cannot ask itself in a
circle.
- An edge that the new type makes invalid stays where it is and turns
red. `EdgeLayer` already paints a mismatching edge with `danger`,
nothing is disconnected on its own.
- New `PortDef.paintType`: colour only, never truth. It is set in
`drawnDef` and read in `NodeView.redraw`, so `canConnect`, the
interpreter and the compiler keep seeing `any`. Every connected
any-input is painted, not just members of a `sharedType` group; ghost
ports keep their hollow red look.
- The pin now matches the edge that ends in it, because both take the
colour from the resolved output type of the neighbour. Mixed sources
therefore show two colours, which explains why the output stays grey.
- New hooks `__gm.graph.portTypes(id)`, `__gm.graph.edgeColor(id)` and
`__gm.themeColor(name)`. `portTypes` reports type and paint colour
separately so the type checks do not silently become colour checks.
Findings:
- `resolveDef` only appends derived ports to the registered ones, so a
fixed port cannot be retyped that way -- it would appear twice. The
shared type is therefore resolved before the ports are built.
- Answer 5 of the issue (inline field follows the type) has no case left
under variant A: an unconnected input stays `any` and keeps its text
field.
- `Item at`, `Join` and `Equals` do not profit: `Item at` has no
any-input, the other two have fixed outputs. The next candidates are
the shader arithmetic nodes, which were left alone.
Tests:
- ./check.sh -> 143 files, 2127 tests, exit 0 (was 142 / 2113)
- GM_STEPS=1-6 fresh -> ALL PASSED, 245 checks (was 242), 9 of them new
- 14 new unit tests in tests/core/shared-port-type.test.ts
- 9 new e2e checks in block 6z, one of them a pixel check straight on
the pin via pixelAt + themeColor
- Counter-probes all bite: filtering unconnected inputs out of
`agreedType` fails 3 unit tests, ignoring `inherit` fails 1, killing
the derivation in the bundle fails exactly the 2 expected e2e checks,
and never setting `paintType` fails 4 unit tests plus the 3 pin
checks (#bdbdbd instead of #e6a8ff / #6fcf97)
- Screenshot read: `a` pink, `b` green, output `value` grey, edges in
the same colours
- Check total 1302 (1299 + 3), 1293 was measured by CI at
|
||
|
|
1a3babf9c5 |
First not empty gets a setting for what counts as empty (#206)
The node treated 0, false and an empty text alike, which is right for a missing behavior parameter but wrong the moment a real 0 travels through the graph. A setting now says how much counts as empty, so the same node works for both cases. - The new parameter "Empty means" has three steps: "Any empty value" (the default, the behaviour so far), "Nothing there (0 and false count)" and "Only a missing value". - The default keeps every existing graph as it is, including the common setup where an unset animation parameter arrives as an empty text. - The rule lives in isEmptyValue(value, mode) in values.ts; the node only passes the step through. An unknown setting behaves like the default, the same way Compare does. - The node description is shorter because the old one was cut off in the inspector. Findings: - In the "Nothing there" step a struct is empty only when its fields are empty under the same step, so a position (0, 0) stays a real value. Tests: - ./check.sh: 142 files, 2113 tests, exit 0 (before 2104). - 9 new cases in the node catalog table: the edge cases per step (0, false, empty text, empty list, position (0, 0), unconnected a) plus an unknown setting. - Two counterproofs hold: letting "Nothing there" act like "Any" drops the 3 cases with 0, false and the position; removing the "Only a missing value" step drops the 2 cases with the empty text and list. - Seen in the browser: the node shows "Empty means: Any empty value" and the inspector dropdown lists all three steps. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d98c2898b1 |
A new node takes the first of two values that is not empty (#205)
A graph that reads an optional value needed a chain of Equals and
Branch just to fall back on a default. The new node does that in one
step: it passes a through unless a is empty, in which case b comes
out. Both ports are any, so it works for numbers, text, lists,
structs and objects alike.
- `First not empty` sits in the data category with two any inputs
`a` and `b` and one any output `value`. Are both empty, b comes
out, so the second input doubles as the default.
- `isEmptyValue` in values.ts holds the rule as a pure function:
nothing connected, 0, an empty text, false, an empty list, and a
struct whose fields are all empty.
- The struct case is what covers a position: behavior graphs have no
vector type of their own, so (0, 0) arrives as a struct. `{x: 0,
y: 2}` therefore counts as set, `{x: 0, y: 0}` as empty.
Findings:
- A real 0 or false on a is passed over. That follows from counting
0 as empty and is written down in the issue.
- The output stays any. Ports that inherit their type from an input
do not exist in the graph; `Item at` has the same shape.
Tests:
- ./check.sh: 142 files, 2104 tests, exit 0 (was 2094).
- 10 new catalog cases: a set, a unconnected, 0, "", false, an empty
list, a position (0, 0), a struct with one field set, an object,
and both empty.
- Counter-check: with structs never empty and 0 a real value exactly
those two cases fail, the rest stays green. Originals restored and
compared with cmp, then rebuilt.
- No e2e check: single data nodes have no e2e coverage here, the
palette fills itself from BUILT_IN_NODES.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
41772e53f7 |
Variables assets can keep their values from one run to the next (#166)
A game needs a high score or an unlocked level to survive the end of
a run. As agreed in the issue there are no save or load nodes. A
Variables asset gets a switch instead, and the existing Variables
nodes read and write the values as before.
- A "Persistent" checkbox in the Variables inspector. Toggling it is
one undo step, and absent means not persistent.
- The behavior system loads the saved values at start and writes them
after every frame that changed one, so a closed tab loses nothing.
- Values live in localStorage per project, not in the project file.
Another browser or person starts fresh.
- Play and "Run in editor" save apart, so trying a behavior in the
editor leaves the Play save alone.
- Saved values are read through the current declaration. A removed
variable drops out, a new one starts from its default, and a changed
kind is converted.
- "Clear saved values" in the inspector clears this asset in both
areas. The main menu gets "Clear saved data" for the whole project.
Clearing takes effect from the next run on.
Decisions:
- The switch covers the whole asset, not single variables. Mixing
both means two assets.
- A changed default in the inspector shows only after the save is
cleared. That is what persistent means.
Findings:
- The first e2e run died at "debug hook present" right after a Vulkan
error from Chrome. The second run passed, so it was a GPU flake at
start-up.
Tests:
- ./check.sh: 140 files, 2070 tests, exit 0 (before 138 / 2055).
- 15 new unit tests in tests/core/saved-values.test.ts and
tests/editor/saves.test.ts.
- GM_STEPS=32,46 fresh: ALL PASSED, 24 checks. Six new checks in
module 32 go through the UI. Module 46 knows the new menu entry.
- Counter-checks both fail as expected. Loading switched off: 2 unit
tests and 2 e2e checks fail. Editor run writing into the Play area:
1 e2e check fails.
- Check total 1278 (1272 measured by CI at
|
||
|
|
3427f84a55 |
A game can pause itself and set its own time scale (#167)
Until now only the editor toolbar could pause a running game. A pause menu inside the game had no way to stop the world while its own graph kept running, and there was no slow motion. - Five new nodes: Pause game, Resume game, Is game paused, Set time scale and Time scale. Pause and resume take effect on the next frame. - A game pause stops world time, physics, animations, particles and shader time. Only behaviors with the new "Run while paused" switch keep running. - Set time scale 0 freezes the world but graphs keep running, as agreed in the issue. - Play and Stop reset the time scale to 1, Load scene keeps it. The pause belongs to the scene, so Load scene and Restart unpause. - The toolbar pause stays separate and also freezes the menu. When the game pauses itself the status line reads "The game paused itself". - While paused, collision events are not reported, sounds keep playing, and the editor run ignores both nodes with a log line. Findings: - The first status line was longer than any before and got cut with an ellipsis. It is shorter now. - The e2e harness did not know the key "p". It does now. Tests: - ./check.sh: 133 files, 1968 tests, exit 0 (before 132 / 1952) - npm run build: exit 0 - GM_STEPS=18 fresh: ALL PASSED, 33 checks (before 26) - 16 new unit tests: 8 catalog cases, game-pause.test.ts, editor run - 7 new e2e checks in block 18i, driven by real keys in the game - Six counter-checks all bite: behavior filter, collision lock, world time, editor run, clock reset and the game clock in the browser - Check total 1217 in README is computed (1210 + 7), CI measures it Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ffdf0a8ddb |
The new node "Raycast all" answers with every collider along the ray instead of only the nearest one: it carries the same inputs and settings as Raycast (Ignore owner, Hit triggers, Layers from #158) plus "max hits" where 0 means all, and it hands back parallel lists sorted by distance - objects, colliders, points x/y, normals x/y, distances - next to count, so index i of every list speaks about the same hit; a collider counts once no matter how many convex parts its shape is built from, while a tilemap counts per cell it is hit in, and the filter sits in Solids.raycastAll like the one of #158, the one path the editor run and Play both take, so all 12 new unit tests run through describe.each against both systems; the node needed the value system to learn something it could not do - a graph list carried numbers, text, booleans and bundles but no object handles, asList turned an object into its name, so objects/colliders would have been lists of names, which loses the object when two carry the same name, and collider children usually do; Value now knows a travelling list that may hold handles and "Item at" gives such an item out as an object, ready for Destroy, Apply force or Name of, while the single door into a stored file - writeTyped/asTypeOf - runs the list through the new storedList() so every object becomes its name again and nothing living reaches a project file, which is its own unit test over JSON.stringify; three list nodes follow the handles: "Index of" compares objects by identity because a name is not the object of that name, "Set item" and "Fill" write an object as an object, and "Join text" prints the names instead of the innards; this decision was mine to make because the question ran into the MCP timeout - the alternative was a list of names plus "Object by name", which is the smaller change but lossy; 4 new e2e checks in the new block 43e (shield and floor in one go reading 2 and "145, 280", the second object answering to its name, which only a real object in a list can do, "max hits" 1 stopping at the nearest, and the node showing the row) and 18 new unit tests, 12 in raycast.test.ts, 4 on the value system and 2 in the node catalog; two counter-checks hold: not counting tilemap cells one by one drops 2 unit tests, and answering with the nearest hit only drops module 43 with 2 FAILs; one finding on the way - the node read "maxHits" while every other input carries a spoken label, because int() took the key as its label, so it has a third parameter now, and the e2e check caught that, no unit test did (#157)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0fdde6cb1e |
The Raycast node filters by collision layers: the node gained a "Layers" setting, a tickbox per collision layer of the project - the same field kind the collider uses for "Collides with" - and the ray only hits what sits on a ticked layer, tilemaps counting as colliders; the default is every layer, and a graph without the param answers exactly as before, which is its own unit test; the check is one-sided on purpose, unlike the symmetric rule of #152: a ray is not a collider, so it never reads the collidesWith of the thing it hits - a collider that meets nothing is still seen by a ray - because who a ray may look at is the ray's business, and a unit test holds that case; the filter sits in Solids.raycast in front of the intersection test, which is the one path the editor run and Play both take, so every unit test runs through describe.each against both systems; NodeView did not know the field kinds layer/layers, so the node would have read "Layers: 65533" - it now shows the name of the layer, or "All layers"; 5 new e2e checks in the new block 43d (a shield on its own layer between the caster and the floor stops the ray, the "Layers" row on the node, the tickbox menu with the project's layers, the ray reaching 280 instead of 145 units once the shield's layer is unticked, and the mask read as a name) and 6 new unit tests, three against each system; two counter-checks hold: disarming the filter line drops 6 unit tests and the unticking check in module 43, and switching the formatting off makes the node check fail with the raw bits; two findings from measuring came out of it and stand in the wiki and the notes - __gm.rays() only measures while the Scene tab is in front, so a node selection blinds it and the block measures through a Log node on distance instead, and a running behavior instance keeps the graph it started with, so the editor run casts with the old mask until the instance is built anew (#158)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
25ec7879a0 |
Several transitions between the same pair of states: make transition onto a state that is already connected in that direction adds another transition, which gets its own name right away (Transition 2, Transition 3, skipping names that already leave the state), so two unnamed transitions between one pair never exist; renaming is refused when another transition between the same pair in the same direction already carries the name, the empty name included, while equal names to other targets are only warned about as before; the behavior graph keeps Already connected; self transitions may repeat too, each further loop drawn as a ring around the previous one with the preview showing the next ring, and transition labels that would cover another one slide along their own edge until they are free (pure function freeLabelSpot); test hook transitionLabelAt also returns width and height, e2e 26e6 (#162)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
010b6a7f6c |
A state can transition to itself: make transition and a second click on the same state connects a self transition, taking it fires On state exited and then On state entered every time, for local and global machines, while Set state into the current state stays silent as before; only states may connect to themselves, every other node is still refused, one self transition per state; the loop is drawn over the middle of the top edge with its arrow pointing down and its name at the apex, the preview already shows it while armed, and the inspector lists it once under Transitions out; new test hook transitionLabelAt, e2e 26e5 (#163)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6a31f5e0ad |
One-way platforms, raycast and collision stay with contact data: a collider can be one-way with a side it blocks from (turning with its object, drawn thick in the scene view) and a tile can be one-way from above, one-way cells never merge with solid tiles, a contact blocks only when its normal is within 45 degrees of the blocking side, the new node Ignore one-way lets an object drop through for a while, and it all holds for Move and for dynamic bodies in Rapier, where the decision is made before each step because the hook may not touch the engine (#155); the new Raycast node answers hit, object, collider, point, normal and distance in our own f64 geometry, the same in editor run and play, and the editor run draws each ray for 0.25 s (#153); On collision stay gains an interval with its own clock per pair, and stay and enter gain the contact point next to normal and impulse (#149); fixes that every Rapier collider of a tilemap had the shape of its last cell
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1b5e386b60 |
Move with collision reacts along the contact normal: it travels to the surface, stays flush and slides the rest of the move along it with Coulomb friction (the mean of both colliders, a tile counts as 0.5), a circle now rolls over an edge instead of catching on it, pushed dynamic bodies move off along the normal, Move gains the outputs hit, normal x and normal y, an object without its own collider moves freely, and editor run and play share the same path through Solids; contact point and normal come from our own f64 geometry (contactBetween), because Rapier's castShape lands 3e-6 px inside the wall in f32 (#150)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0ff3c8b742 |
Physics stage 1b: six new nodes act on dynamic bodies in play (Apply force over the frame, independent of the frame rate, Apply impulse, Set velocity, Set angular velocity, Get velocity, Set gravity for the rest of the run), a node on an object without a dynamic body and moving a dynamic body directly each log a hint once per object, and Move with collision now pushes dynamic bodies with a mass-dependent push while the mover stays flush; Move keeps its own move-and-slide instead of Rapier's character controller, which measurably snags on seams between colliders (#147)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
78899b1ace |
Physics stage 1a runs on Rapier: a new Rigidbody child object (body type, mass, gravity scale, damping, fixed rotation, CCD) makes its parent fall, bounce and come to rest in play, colliders gain friction, bounciness and density, the scene gains gravity (0, 980), the new PhysicsSystem steps at a fixed 60 Hz with interpolation in world coordinates and treats a tilemap as static ground, On collision enter gains normal and impulse, play waits for Rapier and does not start without it, and the editor run keeps the old CollisionSystem; the existing collision tests run against both systems unchanged (#146)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
195fc94e08 |
Animations blend out on Stop and at the end of Once (reset), and Play can restart a running animation (#106)
Stop animation gets a blend (s) port that fades the animation back to what lies underneath. Play animation gets blend out (s) for Once (reset) and a restart port that starts a running, paused or fading animation from frame 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8a377d9239 |
Play animation's blend is given in seconds, not in frames of the new animation (#106)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
04dc254b3d |
Play animation can blend into the new animation over a number of frames (#106)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
89c46c30a0 |
An animation is previewed above its timeline: the root at the playhead, drawn but never written (#99)
The Animation tab shows the root asset over the timeline, with every row's value at the playhead written onto a copy of it – the sprite, text or container placed at the origin as a scene would place it, a scene as itself. Rows reach the root through that placed object and its children through the object's instance edits, the route a placed container's content takes, so the ids match what the runtime gives them. The runtime player and the preview evaluate a row through one function (trackWriteAt), relative rows included; past the end of a once-reset pass the preview shows the starting values, as the player would. Play, pause and the ruler drive the preview through the timeline's playhead; a drag pans, the wheel zooms, Centre frames it. The document is never touched and undo never hears of scrubbing. On animation finished no longer describes itself as hearing "a sprite of the owner". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
4ed2edd0f0 |
An animation is a value a graph can pick, and playing a running one makes it win again (#97)
Play, Pause and Stop animation take the animation on a port instead of a fixed setting, fed by the new Animation literal or a list item tagged `$animation`. Playing an animation already running keeps its frame but counts it as started last, so turning back to a walk that never stopped shows that walk. The actor demo walks through one Play animation over `ItemAt(walk, facing)` again and stops every animation to stand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
193378e6a9 |
An animation is a timeline of rows over one asset, played on an object rather than worn as a picture (#97)
Part (a) of the animation rework: model, commands, saving, runtime and nodes. - AnimationAsset holds a root (sprite, text, container or scene) and rows; a row keys one property of the root or of an object inside it, keys sit on whole frames. - The last key decides the length (its frame counts as a frame of its own). The playhead runs between frames, so numbers and colors blend every tick even at 10 fps; everything else holds. - Playback gains `once-reset`, which puts back what was there before the start. A row can be relative, adding to where its target stood. - Play / Pause / Stop animation act on an object; each instance plays on its own, the later start wins on a shared property, and a behavior writing the same property still writes last. - A sprite no longer wears an animation through `texture`. "Create animation from texture" builds a sprite plus a texture row keyed frame by frame instead. - The Animation tab shows a placeholder until the timeline editor (part b) lands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d3563897d8 |
Two compilers that walk a graph the same way walk it through one emitter (#85)
Refactoring round two: the particle and shader compilers share emitGraph.ts (cycle detection, duplicate names, emit order) and keep only their rules. FontLibrary and TextureLibrary sit on FileLibrary (base URL, loading/failed bookkeeping, listeners); SoundLibrary keeps its own contract. The three Create*Command share ImportFileCommand, the three renames share RenameCommand - class names stay. bandBounds() in framing.ts and portNameField() in portSection.ts replace in-file clones, and three comments that told history now state the rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cc6db5f350 |
Three stores, three importers and three pickers that differ only in their type are one of each (#85)
Refactoring round one: HttpFontStore/HttpSoundStore/HttpTextureStore collapse into a generic HttpFileStore over a FileStore contract; fontImport/soundImport/textureImport share assetImport with an ImportKind per sort; ImagePicker/FontPicker/SoundPicker sit on FilePicker and describe only their row. The group/comment drag logic leaves GraphCanvas for annotationDrag.ts (with its own unit test), declaredRows.ts replaces the duplicated row build in particles.ts and shaders.ts, and dead code in font/sound/compile/emission/transform/actions is gone. README: asset list, check count 958, the two-job CI, the Scenes wiki page. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
b9f1a9d319 |
A graph that grew past one screen needs somewhere to say what a part of it is for (#38)
game-maker / suite (push) Successful in 6m13s
The graph could hold any number of nodes and said nothing about which of them belonged together. Groups and comments are two separate things, as decided in the issue: a group is a titled box that carries the nodes it encloses, a comment is free text that carries nothing. Both live as optional arrays on Graph, not as registry nodes. A comment that were a real node would run through the interpreter, show up in the drop palettes and be compiled into a shader — the wrong place for a piece of text. As arrays on Graph they exist in all four graph kinds at once, and a document without them means exactly "no groups", so no format bump. Membership is computed when the box is grabbed, never stored: the center of a node inside the box travels with it, and a node dragged out is simply out. A box inside a box travels with the outer one, its own nodes included. A group is grabbed by its title bar only — its body stays transparent to the pointer, or a rubber band could never be drawn across the nodes it holds. Deleting a box deletes the box; the nodes stay where they are, because a box that silently took ten nodes with it would be the most expensive surprise in the feature. The color is a free picker, and the title color follows from it: relative luminance per WCAG, threshold at 0.179 where the two contrast ratios meet — not 0.5, which would pick white on mid grey where black reads better. Resizing snaps the far edge to the grid, not the width, so the corner that is not being dragged stays put. The minimum size rises in whole steps for that reason: 80 is no multiple of 24, so a box squeezed to the stop would have sat off-grid in exactly the mode meant to keep it on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1135205d69 |
A machine that arrives as a value can still be commanded, so a name may name its state (#58)
game-maker / suite (push) Successful in 6m12s
Set state and State branch derived their ports from the graph, so a machine that came out of a variable or a list had no ports at all: nothing to set, nowhere to branch. It could be passed around but not operated. stateByName() resolves id first, then name; a name two states carry answers with nothing, the same rule transitionTo already follows for two transitions of one name — without something in the graph that decides between them, any pick would be a guess. Set state by name takes the machine and a text, and has a second exec output not found, so a typo is not swallowed in silence. State branch gains a derived else that fires whenever no state output matches — no state set, a state of another machine, or a machine that arrives as a value and therefore has no state outputs at all. A flow that enters State branch now always leaves it somewhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1e9d21cd5d |
A game that is more than one scene needs a way from one to the next, so the graph can load one (#39)
game-maker / suite (push) Failing after 11m6s
Load scene sets a flag on the World like Stop and Restart do; the swap happens after the tick, and the last wish of a tick wins. Exactly one thing crosses over: the SharedState, so global variables and global machines keep what they know while everything local is built anew. Loading the scene that is already running rebuilds it, which is what "reset the level" means when the score must survive. A run starts either from the start scene (a new project field, set from the asset panel and marked with an arrow in the list) or from whatever scene is open. The toggle next to Play says which question is asked and remembers the answer in localStorage: it is a way of working, not part of the game. Sounds no longer end because a scene ends. A voice belongs to the behavior that started it and stops right after On destroy, so the scene swap is no longer a special case at all. For the enemy that plays its explosion and destroys itself in the same breath there is now a detached input on Play sound. The scene selector stays usable while a run plays, because a double click in the asset panel already did the same thing unguarded. It moves what the editor looks at, not what the run plays. |
||
|
|
5671a38044 |
A game that stays silent is only half a game, so a sound is an asset like any other (#45)
game-maker / suite (push) Successful in 12m1s
Sounds follow the road fonts already paved: the file goes to the server content-addressed under its sha256, the project keeps a named asset that points at it, and nothing is ever re-encoded. The format is read from the file's own signature, not from what the browser claims - the same .m4a arrives as audio/mp4, audio/x-m4a or as nothing at all. Playing is two nodes. "Play sound" takes the asset and may raise or lower its volume; every call is its own voice, so two jumps in a row are two jumps heard. Its loop input is a choice of three - from the asset, on, off - because a checkbox has to spend one of its two positions on "not set" and would lose a case: an asset that loops could then never be played once. Below the graph core stays browser-free: NodeContext hands out an asset id, BehaviorSystem resolves it against the project and folds the asset's own volume and loop into the call, and only viewport/SoundLibrary knows about Web Audio. A run without a sink plays nothing instead of throwing. The CI browser is muted, so the ear is no witness: the debug hook counts started and stopped voices, and step 34 reads that counter. |
||
|
|
7babf88636 |
A state change is something the graph can react to, so the machine announces it (#53, #54, #55, #57)
game-maker / suite (push) Successful in 10m58s
Four issues that all reach for the same line: how the current state is read and written. Splitting them into four commits would mean four states of the tree that do not compile, so they arrive together. #55 start state — the machine asset carries an optional `start` holding a State node's id. Right-click a state to set or remove it, a ring in the node's title says which one it is. A dead id (the state was deleted) reads as "no start", so nothing has to clean up after a deletion. #53 entered/exited — `On state entered` and `On state exited`, one exec output per state. Every change now goes through a single `setState` that reports it; BehaviorSystem delivers it after the exec flow that caused it, `exited` of the old before `entered` of the new, round after round until the queue is empty, with a cap that stops a ping-pong graph with an endless-loop error. A local machine tells the instance that switched, a global one tells every instance holding a matching event node. The start state fires `entered` too: per instance for a local machine, once per run for a global one. #57 reading — `Current state` with `name` for showing and `id` for comparing, `Is in state` with one boolean per state. Neither has a `run`, so `read` learned `def` and `lookup`, and the catalog test knows this third kind of node. #54 seeing it — during a run the state machine tab marks every state some instance is holding faintly, and the one held by the selected scene object's instance strongly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8143130ed2 |
A shared value belongs to no behavior, so it becomes an asset of its own (#91)
game-maker / suite (push) Failing after 8m59s
This reverses the design of the previous commit. `PropertyDef.scope` is gone without replacement: a behavior variable is an object property again, without exception, and sharing is something you point at rather than something you declare twice. A **Variables asset** is declarations and nothing else -- no graph, no position, never placed, no tab of its own (`TAB_OF_ASSET` is already `Partial<>`, so that costs no line). A `Variables` node names one through its new `variablesAsset` setting, empty meaning the behavior's own variables as before, and then reads and writes one value per key for the whole run. Its property list follows the chosen source, which was the actual request: a value another graph declared is now offerable, where before every participant had to declare the name itself. That deletes more than it adds (-72 lines net): `globalConflicts` and its run-stopping kind clash, `SetVariableScopeCommand`, the scope picker, both object-inspector filters and the "shared by the whole run" note on the placed object. A typo is no longer a silent second variable -- naming an asset is picking from a list. And two Variables assets may carry the same key without meeting, because the asset is the key, not the name. The one shape it copies is the one that already existed: `Set state` picks its machine the same way. Lifetime is unchanged. The bag is built at `BehaviorSystem.start()` from the declarations and never written back, so playing in the editor cannot change the project -- which is the whole reason a counter must not be an object property (`World.syncToModel` would `commit()` it, and the coin counter would stand at 7 for good). Full symmetry across that gap, without mirroring anything. Two named wires: `Inspector.liveVariables` shows the values as they stand while a run is on and falls back to the declarations when it ends, and `adoptDeclarations()` takes a key into the run *only where the document changed it since the last sync*. Without that condition any unrelated project edit would snap a running counter back to its starting value; a counter-test bends exactly that and falls over. Nothing was ever cached, incidentally: `GraphInterpreter` only pulse-caches nodes without a `read`, and the variable node has one. The wire was missing. The state machine `Scope` row is now an ordinary `enum` row through `createFieldRow`. It was hand-built from an `HStack` with a ghost dropdown and began ~34px left of every value above it; the explanations beside `Local` and `Global` are gone from the menu, the sentence below the row keeps saying it. Machine scope is otherwise untouched -- `StateMachineAsset.scope`, `SetMachineScopeCommand`, `stateBag()` and its persistence all stand, so #59 stays closed as built and `Scope` as a type now carries machines alone. Six counter-proofs, each biting at its own guard. One of them did not bite at first: the suggestion list was computed twice, in `nodeSection` and `portSection`, so only the browser test covered the request itself. It is one named function now, `variableChoices`, with a unit test of its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d1fcfe8b81 |
A shared value belongs to the run, so a declaration carries its scope (#91)
A variable or a state machine declared `global` keeps one value for the whole run instead of one per behavior instance. The scope sits on the declaration -- `PropertyDef.scope` and `StateMachineAsset.scope`, both optional, absence meaning `local` -- and `local` is never written, so every project made before this saves back unchanged. **The name is the key.** Two behaviors declaring a global `score` mean one variable; a behavior declaring `score` locally keeps its own. Kinds that disagree about one global name stop the run before it starts, with the two behaviors named; starting values that disagree are a warning on the behavior that declares it, and the first declaration wins (`globalConflicts`). The runtime needs no redirection layer. A global variable falls out of `schemaForObject`, so it never reaches `entity.props` -- and then the whole rule is "whoever declares the name takes their own value" (`graph/sharedState.ts`). The `Variables` node is untouched and stays transparent: switching a variable to global breaks no existing graph, because `GraphInterpreter.readAll` builds a fresh object per read anyway and only asks `port.key in all`. That same cut answers the inspector. A shared value has no owner, so the placed object shows no row for it and can hold no override -- `SetVariableScopeCommand` takes the deviations that were there and undo gives them back. Both places say it in words rather than leaving a glyph to be guessed: the declaration explains the mark below the list, and the placed object names the variable it no longer holds instead of claiming the behavior declares none -- which is what it said before, untruthfully. Screenshots of the e2e run turned that up; no check had been looking at it. `SharedState` hangs off `NodeContext`, is built in `BehaviorSystem.start()` from the *declarations* rather than from any instance (a global variable exists before its behavior is placed), and is thrown away with the run: nothing is written back, not even in the editor run. `GraphInterpreter.call` passes it on through `...ctx` while nested calls keep getting fresh local variables, which is why `program.defaults` is stripped of global keys. Two traps this hit. `defSignature`/`assetSignature` have to carry the scope or the inspector does not rebuild after the switch -- and a state machine has no rows at all, so its scope is appended to the signature by hand. And the object inspector does not go through `schemaForObject` for behaviors, it reads `asset.variables` directly; filtering in one place was not enough, and no unit test covered that path. The e2e caught it (`global: true` where false belonged), and the rule now lives in one named function, `objectRows`. Scopes `object` and `scene` are deliberately not built. The object case already has a route (`Object by name` -> `Properties`), and a run knows exactly one scene, so `scene` would be indistinguishable from `global` until #39 exists. Closes #59: a machine's scope is a word on the machine asset, not a setting per `Set state` node, so the same machine cannot live in two places at once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
68c92daaa6 |
A transition carries a name, so a state needs no visible port (#52)
game-maker / suite (push) Successful in 10m22s
A state keeps its `in`/`out` exec pins in the model and the editor never draws
them. That is the whole trick: connecting, undo, deleting a state and saving all
keep working untouched, because `normalizeGraph` filters nodes and never edges,
and no edge ever checks its port key against a NodeDef. Only two places change --
`NodeView.redraw` stops drawing the row, and `portAt` refuses to hand one out, so
an invisible pin cannot be grabbed either.
Transitions are drawn by right-clicking a state, `Make transition`, then clicking
the target. The armed mode takes the same shape as a live drag, so the existing
preview draws it with no second code path, and it dies with Escape, a click on
empty canvas or a switch to another asset.
Removing the pins removed the only handle a transition had: edges have no
hit-test anywhere, and the pin right-click was the sole route to Disconnect.
The inspector's TRANSITIONS OUT / TRANSITIONS IN lists are therefore not a
convenience, they are the delete path.
`GraphEdge` gained an optional `label`, which `normalizeGraph` has to pass
through explicitly or a saved name is dropped on load. `State transition`
replaces `Next state`: it takes the machine on an input port and the name from a
picker, and resolves against the *current* state. Names are deliberately not
unique across a machine -- two states may both have an `alarm` -- so a node named
`alarm` moves whichever state has one, and an empty name follows the single
unnamed edge.
The geometry moved to a Pixi-free `transitions.ts`: a gap to the box border, and
a fan that puts a pair of opposite edges beside each other instead of on top of
each other. Keeping it as plain maths is what let a screenshot bug become a unit
test -- an earlier pin-to-pin line ran under the target node and took its
arrowhead with it, because `nodeLayer` sits above `edgeLayer`.
One limit stands and is not fixed here: `ConnectCommand` rejects a second edge
between the same two states ("Already connected"), because every transition
hangs off the same two ports. With names, `A --alarm--> B` beside
`A --seen--> B` is a sensible pair, so this is worth its own issue.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
a041322a0e |
An edge to a vanished port needs a handle, so the port stays as a ghost (#60)
game-maker / suite (push) Successful in 10m7s
Four ways erase a derived port and take no edge with them: deleting a state, deleting the `State machine` literal, deleting `Behavior start`/`end`, deleting the asset. The common cause is `RemoveNodeCommand` clearing only its own graph, so the fix sits in `drawnDef` (graph/nested.ts) and touches no delete site. A right-click on an edge goes through `view.portAt` only — there is no curve hit-test. Without a port row such an edge is not merely invisible, it is unreachable, so the ghost row is the handle Disconnect hangs on. `resolveDef` stays untouched: it is the truth for interpreter, compiler, ConnectCommand and dropTarget, so nothing runs or connects through a ghost without a second guard. |
||
|
|
0f9e1611d1 |
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. |
||
|
|
016e83c9b5 |
Transform: scale, rotation and mirror as properties, world transform as a matrix chain, oriented colliders (#76)
game-maker / suite (push) Successful in 9m5s
Four new rows on sprite, text, collider and container (block TRANSFORM): scale, rotation in degrees clockwise, mirrorX, mirrorY. As properties they get override/reset/apply, loading of old projects, write-back after play and the Properties node for free; camera and behavior have none. The hierarchy composes T*R*S per level (composeAlongChain replaces sumAlongChain), so children rotate and scale with their parent. Reparenting and grouping keep the look (carriedTransform). Drawing goes through placeNode, the gizmo is a polygon, cull and objectRect use the AABB, drags are computed in the parent's system. Colliders are oriented boxes: SAT for box/box, box-circle in the box frame; exitDistance replaces axisSeparation/axisCorrection, moveAndSlide moves along the object's own axes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
3da0ef1be2 |
Shader graph: alpha input on Output, Texture alpha node (#70)
game-maker / suite (push) Successful in 7m55s
Output gains a number input `alpha`; unconnected it reads the texel's alpha, like `color` reads the texel. The epilogue premultiplies with the resolved alpha. New `Texture alpha` node exposes texel.a for maths. PortDef.inline=false keeps the alpha port without an inline field: every number port is inline by default, and a typed default of 0 would have made every shaded sprite invisible. The compiler's texel fallback is typed: colour ports get rgb, number ports get texel.a. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |