A calculation needed in several shaders (a greyscale, a noise, an
outline) had to be built again in every one of them. A shader
function keeps it as an asset of its own, called like a behavior is
since #16.
- New asset type `shaderfunction` ("Shader function"), edited in the
Shader tab. The panel beside the graph shows no picture, the
readout counts its inputs and outputs.
- Two new nodes: `Function input` and `Function output`. Their
declared ports, numbers or colours only, are the ports of every
call. One of each per function, like Start and End.
- The call is node `ShaderCall`, named after the function. The
palette lists every function, it can be dragged in from the Assets
panel, and a double-click opens it.
- The compiler sets the function in where it is called
(`inlineFunctions`). It renames the copied nodes `<call>/<node>`
and turns an unconnected port into a constant. Passes, Time, the
Edge cut and the corner walk then see a plain graph.
- A problem inside a function is reported at the call, in the
function's words. The function's own tab checks it on its own
(`checkShaderFunction`).
- `ShaderLibrary` keys a shader on its graph and on every function it
calls, however deep, so a change reaches every caller.
- The port commands mirror a new, renamed or removed port into every
call, as for a behavior's Start and End. `NodeDef.portKinds` holds
the two ends to Number and Color.
- As agreed in the issue: no `Property`, `Color property`, Output or
Vertex output in a function, and `Time` is the caller's clock.
- Own decision: a `Pass` may stand in a function. It becomes a pass
of the calling shader, so a wide blur can be a function.
- A function calling itself, also through another, says so at the
node and is not set in. The palette does not offer such a call.
- The Shader tab showed only the compiler's problems. A call nothing
reaches was never compiled and looked healthy, so a call now also
shows its own problem there, as in a behavior.
Tests:
- Gate: 228 files, 3510 tests, CHECK EXIT 0 (+13: 11 in
tests/core/shader-functions.test.ts, 2 for the new icon)
- e2e 28: 100 checks, all passed (+3 in 28l-viii). Called and in
place both #606060/#a2a2a2, halved in the function #303030/#515151.
- Counter-check: a fingerprint without the called functions fails 1
unit test, and in the browser the change check (#606060 stays).
- The first e2e runs found two faults, both fixed: a double-click
only selected the function, and an unreached call of itself
showed no problem.
- e2e total 1728 is computed (1725 + 3).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A shader ran only inside the rectangle of what wears it: the quad
has the size of the sprite, and outside of it no fragment runs. An
outline or a glow built with Sample texture was cut off at the
border. The issue still spoke of Filter.padding, but since #77 the
shader is no filter any more, the limit is the geometry.
- The Output node has a number Padding next to Edge: so many pixels
of the picture per side the wearer draws beyond it. 0 is the
default and draws as before, 256 is the most.
- The quad grows and its UVs grow with it, so the picture keeps its
place and size. In the margin the UVs run on below 0 and above 1.
- Transparent is a fourth Edge mode: outside of the picture there
is nothing. It is cut in the fragment (texelAt), so it needs no
copy of the picture with an address mode of its own.
- Edge holds everywhere alike, also for Texture color, Texture
alpha and unconnected inputs.
- Padding above 0 with Edge on Clamp puts the hint "Padding with
Clamp smears the border" on the Output. The shader still
compiles. A chosen Clamp and the default cannot be told apart.
- All four wearers draw the margin. A sprite counts in pixels of
its frame, a text in its units, a particle source grows every
particle about its centre.
- A nine-slice sprite gets a ring around its nine fields, as wide
as so many border pixels. The fields themselves stay.
- Without a picture a pixel is one unit of the wearer's own size,
or of the particle size.
- Particles now take the Edge mode of their shader. It had no
effect on them before.
- An object wearing a padded shader is never culled, or its margin
would vanish at the edge of the screen.
- The preview in the shader tab shows the margin and leaves room
for it.
- Picking, the selection frame, the size and the collider stay with
the rectangle without the margin.
Tests:
- Gate on melektaus-atx right before this commit: 216 files,
3336 tests (3319 + 17), exit 0.
- e2e 28,30,31,37,41 on melektaus-atx: 178 checks, all pass.
e2e 1-37,47,48,58 ran before the last change (no culling with
padding): 1281 checks, all pass.
- 17 new unit tests: the compiler (padding, Transparent, the hint)
and the geometry (quad, nine-slice ring).
- Seven new e2e checks: four at the sprite in module 28, one each
for text, particles and nine-slice.
- 17 unit counter-probes all fail their tests. Three browser
counter-probes fail their checks (5, 3 and 1 of them).
- Three screenshots looked at: the sprite with Clamp and margin in
the scene, the preview in the shader tab, the text.
- The e2e sum of 1687 is counted (1680 + 7), the full suite and
the smokes did not run.
- Not seen: the hint at the node in a picture, particles and
nine-slice in a picture, the game view while playing, sprites
without a picture, vector and pixel art pictures.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A behavior run by another one had a single flow port at its Start
and at its End, so it could not branch for its caller. And it could
not be used as an event: a call needs a pulse at its flow input,
and the events of a called graph never fire. An input like
"Confirm" (Space or Enter) had to be built again in every graph.
- A declared port of a Start or an End node can be of the kind
Flow. It sits above the values and shares their names, exec stays
reserved. Only these two nodes declare one, a struct does not.
- Run behavior shows a flow port of the Start as a further flow
input. A call arriving through it leaves the Start through the
output of that name and not through exec.
- Run behavior shows a flow port of the End as a further flow
output. The call goes on through the one the End was reached by
last, and then through exec. exec fires always, as before.
- On behavior is a new event node without a flow input. The
behavior it names runs at the node for as long as the behavior
around it runs, with all of its own events and one set of
variables per node.
- Its outputs are those of the End. It fires once the inner pulse
is through, by the output the End was reached by last, exec only
for End.exec. A pulse that never reaches the End is not seen.
- The value ports of the Start are its inputs, read anew for each
inner pulse. The Start itself never fires there.
- It nests to any depth. Under a Run behavior it stays silent. A
behavior hearing itself, however far round, is not started and
the node says "A behavior cannot listen to itself".
- The palette offers every other behavior with an End once more as
"On <name>". A node naming one without End says so.
- An On node removed while the game runs ends its behavior with On
disable and On destroy. The sounds of the entity end only with
the behavior placed on it.
- Renaming and deleting a declared port follows the edges at On
behavior nodes as it does at Run behavior nodes.
- The flow outputs of the Start of a behavior placed on an object
never fire, like its exec.
- The engine prompt of the benchmark names flow ports and On
behavior.
Tests:
- Gate on melektaus-atx right before this commit: 215 files, 3319
tests (3278 + 41), exit 0.
- e2e 19 on melektaus-atx: 39 checks (+4), all pass. e2e
1-6,17-24,26,36,51: 628 checks, all pass.
- 19 unit tests in flow-ports.test.ts, 19 in on-behavior.test.ts
on a real world, the rest in the node catalog.
- Four new e2e checks in module 19: a flow port declared by mouse
at the End, On Confirm in the graph, the call leaving through the
further output while playing, Enter and Space on a real keyboard.
- 19 unit counter-probes all fail their tests. One did not at first
(a removed node kept its instance), a test was added. Two browser
counter-probes fail their checks.
- One screenshot looked at: On Confirm with exec and pressed, the
second flow pin at the calling node.
- The e2e sum of 1680 is counted (1676 + 4), the full suite and
the smokes did not run.
- Not seen in the browser: flow inputs at the Start, nesting, the
ring, values through On behavior, the palette entry. No test
holds that the sounds of the entity go on when a heard behavior
ends.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In a graph of many nodes the frame rate dropped while panning, and
more so while one node was dragged. Every pointer move rebuilt every
box, every text and every curve, and a drag also announced a changed
project, so the panels rebuilt along with it.
- Panning and zooming move the picture. GraphCanvas.applyView sets
position and scale and draws anew only the grid and the rubber
band. It used to call redraw().
- A node draws its box and makes its texts only when what they show
has changed. NodeView keeps a signature of what redraw reads and
one of what rebuildTexts reads, and returns early while they hold.
- A drag does not announce a changed project. GraphDrags writes the
model on every pointer move and asks the canvas once a frame to
follow (GraphCanvasHost.moved). state.preview() waits for the
command of the release.
- Nodes outside the picture are not drawn. inView in graphView.ts is
the rule, cull() applies it after every redraw and every change of
the view.
- The graph is a render group, and box and pins go into the batch.
The mask sits on a container above the group, on the group itself
it would be panned and zoomed along.
- The rubber band and the drop mark of a dragged behavior are drawn
on their own (drawBand), without a redraw of the graph.
Measured in the CI image with 388 nodes and 540 links, fitted to the
canvas, 240 pointer moves 16 ms apart:
- Pan, script per move: 25.2 ms before, 2.0 ms after
- Pan: 4.4 frames per second before, 18.7 after
- Drag a node: 2.7 frames per second before, 22.4 after
- The still picture: 9 frames per second before, 21 after
Not solved: the still picture of such a graph stays at 21 frames per
second in the test browser. What is left is the drawing itself, and
the frame rates are those of that browser, not of a desktop GPU.
Tests:
- Gate on melektaus-atx: 207 files, 3121 tests (3114 + 7), exit 0
- e2e 4,6,18,19,20,23,24,26,28,31,36,43,51,53 on melektaus-atx: 542
pass, 1 fail. The fail is module 51 after module 43, an order the
suite does not have, and it fails the same way without this
change. Module 51 alone: 12 checks, all pass.
- No full run and no smokes. The sum of 1660 (1656 + 4) is reckoned,
and the CI workflow is switched off.
- Seven unit tests: six for inView in tests/editor/graph-view.test.ts
and one in frame-preview.test.ts
- Four e2e checks in block 6z-xii of module 06: pan, drag, zoom in
and fit, a changed value
- Seven browser counter-probes fail 2 to 3 checks each
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a screen scaled to 125 % or 150 % the editor looked soft beside
a page the browser draws itself. The layout counts in whole CSS
pixels, and most of those fall between two device pixels there. The
GPU averages both: text loses its edges, and a 1 px line becomes a
pixel and a half of mixed colour. Now text and boxes are rounded to
device pixels, and a line is one device pixel wide.
- ui/pixelGrid.ts holds the rule. crispText() makes a text with
Pixi's roundPixels, crisp() does the same for a Graphics of
upright boxes, and hairline() is the width of one device pixel.
- Every text of the interface is made with crispText() instead of
new Text().
- crisp() only rounds where the scale is not a whole number. At
100 % and 200 % nothing changes.
- drawControlBox, drawHeaderBar and drawSectionBand draw their
lines a hairline wide and flush with the edge. The widgets that
draw lines of their own do the same.
- Two bands share exactly the line they draw, and a column still
ends on a whole pixel.
- The stage is nudged by 1/32 px where the scale is not whole. At
150 % every odd pixel is an exact half, and without the nudge one
band came out a row shorter than its neighbours.
- Curves, icons and everything of the game stay unrounded. Rounding
their points would bend them.
- The harness takes deviceScaleFactor as an option, and the hooks
deviceColumn, edgeContrast and pixelGrid read the frame as the
screen gets it.
Not in here: the subpixel smoothing a browser gives its own text.
Pixi draws a text into a transparent picture first, and there only
greyscale smoothing is possible.
Measured at 150 %, edge contrast before and after: top bar 31.5 to
47.8, the lists on the left 68.6 to 86.4, inspector 27.6 to 38.4.
At 100 % it is 46.5, 90.1 and 37.9, and the picture there is the
same as before pixel for pixel, apart from the fps display.
Tests:
- Gate on melektaus-atx: 206 files, 3114 tests (3104 + 10), exit 0
- Full run on melektaus-atx: 1655 of 1656 checks passed, smokes
24/19/21. The one that failed was a condition of the new step
62 itself: it scrolled an inspector that had nothing to scroll.
The step now makes the window lower for that reading.
- e2e 61,62 after that fix: 14 checks. The full run was not
repeated, and CI is switched off for now.
- Ten unit tests, nine in tests/ui/pixel-grid.test.ts
- Eight e2e checks in the new step 62, which runs at 150 %
- Eleven unit counter-probes fail tests
- Four browser counter-probes fail their checks. They ran before
the fix to step 62 and were not repeated for it.
- Screenshots looked at for 125 % and 150 %
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A link could only be removed through the menu of its port, and the
line itself took no input. A transition of a state machine could only
be deleted in the list of the inspector. Now the line is the handle,
in a behavior's graph and between states alike.
- A click on a line selects that link and draws a halo behind it. A
press counts within 6 screen pixels of the line, and where two cross
the one drawn last wins.
- Ctrl-click or Shift-click adds a link or takes it away. The rubber
band takes every line it crosses, into the same selection as the
nodes and boxes it caught.
- Delete or Backspace removes what is picked as one undo step. A
right-click on a line offers Hide link and Delete link.
- The inspector shows a picked link: the node and port at each end and
the type it carries, with Hide link and Delete link below. For
several it says how many.
- The arrow of a transition is picked the same way, on its name too.
Delete or a right-click with Delete transition removes it.
- The menu of a port is unchanged, Disconnect included, as answered in
the issue.
- editor/graph/edgeHit.ts holds the hit test as pure functions, and
editor/inspector/edgeSection.ts builds the section. The selection
has a new kind edge, and node and annotation carry edgeIds.
Decided here:
- A hidden link has no line and is never picked this way, also while
it is shown on loan. Its mark at the port stays its handle.
- The menu of a line speaks about the whole selection, like the menu
of a box. With nodes or boxes held alongside it reads Delete N
items.
- Hiding picked links lets go of them, since no line is left.
- A link wins over the box it crosses, and a mark wins over a link.
- A transition has no Hide entry.
- With nodes or boxes in the selection the inspector keeps showing
the node or box, not the link.
Tests:
- Gate: 201 files, 2982 tests (2967 + 15), exit 0
- e2e, full run: 1635 checks passed (1629 + 6), exit 0
- Smokes: 24 / 19 / 21, exit 0
- The full run was done for exactly this state.
- 15 unit tests in tests/editor/edge-selection.test.ts
- Six e2e checks with a real mouse, block 6z-xi in module 06 and 26e7
in module 26
- 12 unit counter-probes all fail tests, run on stein2
- Three browser counter-probes fail, the build went through each
time: no hit on the line, no halo, a band that skips lines
- Screenshots of a picked link and a picked transition looked at
before the last build, none after it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reading one field of a struct needed its own Expand node, even where
the graph already knows the struct type. A split output shows the
fields directly on the node, so a Find … Match test becomes a single
Equals and the wires keep running left to right.
- Right-clicking an output pin offers "Split into <Type> fields" when
the type is known and "Split as …" for every other struct asset.
The output is replaced by one pin per field with the field's kind.
- The type is known for typed struct variables, typed Pack/Expand,
fields of another split and item of For each, Find, Match and Item
at over a typed list (portSplits.ts).
- Right-clicking a field pin offers "Recombine". Splitting drops the
edges of the whole output, recombining drops the field edges. One
undo step brings them back.
- The split is stored as GraphNode.splits and saved with the graph.
Copy and paste carry it along.
- Field pins follow the struct asset like a typed Expand. A new field
gets a pin, a removed field loses pin and edge, a renamed field keeps
its edge. Deleting the asset keeps the last fields. The delete dialog
counts splits, so it now says "node(s)" instead of "Pack/Expand
node(s)".
- Renaming a variable carries its split and the field edges along.
- At run time the node still produces the whole value. A field pin
reads its field like Expand, a missing field reads as empty.
- Limits: outputs only, one level, behavior graphs only.
Tests:
- ./check.sh: 185 files, 2706 tests, CHECK EXIT 0
- e2e GM_STEPS=1-6,54,55: ALL PASSED, 303 checks
- New port-splits.test.ts with 12 tests. e2e 6z-vi with 3 checks
using a real mouse: split Find.item, wire item.Name into the test
and Match.item.Amount into the log (logs 7), recombine.
- Counter-checks: without field reads in the interpreter, without
rename in the split, without moving edges on rename and without
cleanup on field removal one unit test fails each. In the browser,
without field reads nothing is logged and without type inference
the split entry is missing.
- Check total in README: 1520.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Searching a list with For each needs the test result wired back into
break on the left of the loop node. The wires then run across the node
and the graph reads badly. Find … Match keeps the whole search flowing
left to right, while the test in between stays a free graph.
- Find takes exec and list and offers item and index. It sets them to
each element in turn and has no exec output of its own.
- Match reads its bool input match once per element. The first true
value continues at found with item and index of the hit. No hit or an
empty list continues at not found with an empty item and index -1.
- The pair is derived from the wiring and stored nowhere. A Match
belongs to the Find whose item or index its test is built from. Copy,
paste and undo therefore need no extra code (findBlock.ts).
- An unpaired Find or Match, two Matches on one Find and a Match fed
by two Finds show a warning on the node. At run time the flow ends at
an unpaired Find and the console says so.
- Adding Find from the palette places its Match 480 px to the right in
the same undo step.
- The graph draws a quiet frame behind Find, Match and the nodes of the
test. Literals feeding the test from outside stay outside the frame.
- The test consists of data nodes only, like the break of For each.
Tests:
- ./check.sh: 184 files, 2694 tests, CHECK EXIT 0
- e2e GM_STEPS=1-6: ALL PASSED, 272 checks
- New find-match.test.ts with 12 tests, 2 catalog cases, e2e 6z-v
with 3 checks using a real mouse.
- Counter-checks: ignoring the test fails 4 unit tests, no ambiguity
check fails 2, framing everything fails 1. In the browser, no frame
and no Match from the palette each fail their check.
- Check total in README: 1517.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copy and paste only existed for animation keys. Building a scene or a
graph meant placing every object and node again by hand, even when the
same arrangement was needed twice.
- Ctrl+C copies the selected objects with their whole subtree, or the
selected nodes with their links, groups and comments. Ctrl+V pastes
into the scene, container or graph of the visible tab.
- The copy is a snapshot in the editor's own clipboard, in memory. It
holds asset ids, so the system clipboard is not used. It is cleared
when another project opens.
- A paste lands under the mouse pointer when it is over the view, and
keeps its arrangement. Otherwise it lands one grid step beside the
original. Each further copy that still exists moves one step on, so
an undone copy does not count.
- Objects get new ids for the whole subtree. They paste into the one
selected object and keep their look under the new parent. If that
object is the original or an earlier copy, the paste lands beside it
instead of nesting deeper with every Ctrl+V.
- A placed container stays a container. Content copied out of a
container pastes as plain objects. Pasting a container into its own
template is refused.
- Nodes paste only where the palette would offer them. A second Start
or End, a State in a behavior or a behavior calling itself is left
out, and a notice names what was dropped.
- Menus: Copy and Paste in the object menu, a new Paste menu on empty
scene space, Copy in the node and box menus, Paste in the palette.
- Ctrl+V still imports a picture from the system clipboard when the
copy does not fit the tab. While objects are copied, the scene and
container tabs use Ctrl+V for them, as the timeline does for keys.
Not checked in the browser: paste in the container tab, in state
machine, shader and particle graphs, and from the hierarchy menu. The
shared code path covers them.
Tests:
- ./check.sh: 175 files, 2531 tests, CHECK EXIT 0
- e2e 51 (new): ALL PASSED, 11 checks with real keys and clicks
- 13 unit tests in tests/editor/clipboard.test.ts, one of them pins
the grid step after undo found during e2e
- 7 counter-checks all fail as expected (6 unit, 1 in the browser)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GraphCanvas had grown to 1406 lines. Wiring, dragging and the frame
views each carry their own state, so they now live in their own
classes behind a narrow host interface. Behaviour is unchanged.
- GraphCanvasHost is what the new classes see of the canvas.
- Wiring owns dragging a pin, the palette on a drop into empty space
and "Make transition".
- GraphDrags owns moving nodes and frames, including snapping.
- AnnotationLayer holds the frame views. The test hooks stay on the
canvas and read the views through the layer.
- BeadClock moves into beads.ts and can now be tested without a
browser.
- The canvas builds host, wiring and drags in its constructor because
state is a parameter property and not set during field init.
- GraphCanvas is down to 1019 lines.
Tests:
- ./check.sh: 145 files, 2192 tests, CHECK EXIT 0 (+2 for BeadClock)
- e2e 1-28,31,36: ALL PASSED, 932 checks
- New e2e check in 36k: a frame caught by the rubber band is also
drawn as selected. No check covered that before. Against a bent
build it fails with selected: false.
- Counter-checks for drop target, snapping and beads fail exactly
the expected checks.
- Editor check sum is now 1314 (CI measured 1313 at 487b3aa).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 1a3babf
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
Fit measured the nodes alone: a group drawn around them was cut off, and a graph holding nothing
but a comment framed the empty origin. It now frames both sorts together.
The rubber band ignored boxes entirely. It catches them now, into the *same* selection as the
nodes — `nodeIds` and `annotationIds` side by side — so one drag moves all of it, one Delete
removes all of it, and one undo takes it back. A box counts only when the band holds all of it,
where a node counts on touching: a group wraps the nodes it holds and therefore overlaps every
band drawn over any of them, and since it drags those nodes along, catching it by accident would
be felt at once.
A node inside a selected group is moved once, not twice — `travellers()` collects everything one
gesture carries and lets each thing appear in it a single time.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
`nodeUnder()` recomputed the node's face as `0 <= x <= w`, while the pointer events follow the
`hitArea`, which reaches `PORT_HIT_RADIUS` past the edge. An input port sits at exactly x = 0, so the
global point rounded back landed a hair outside: the drop counted as one on empty canvas, the node
palette opened and swallowed every later click – four checks of step 6 at once. `NodeView` now
answers from that same `hitArea`, and the step also checks that no palette is open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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.
Three reports plus one found on the way.
Hold only froze the *picture*: the preview computed a fake second while drawing,
the library clock kept counting underneath, so letting go jumped to "how much
would have passed by now". That is the flip side of the formula model — a thing
that is a function of the clock is not stopped, its origin is moved.
ParticleField.rebase() does that; hold(null) calls it with the second stood at.
The Hold label now mirrors the preview's state instead of its own click history,
so dragging the slider (which holds too) reads `Run`, and Restart puts a held
preview back to 0.00 s rather than leaving it on the second it stopped at.
previewProps only read defaultsFor('particles'), so the panel showed the type's
defaults while the inspector showed the asset's rows. AssetBase.props is a full
bag on a graph asset as well — asset.props now lie over the defaults, the
asset's texture is resolved instead of always drawing a white square, and the
slider's span follows its lifetime. The span is re-read in tick(), because the
rows are edited in the inspector and the tab hears nothing about it.
The preview moves from under the graph to beside it, past a draggable splitter:
a graph is read across, so width is what it can spare and height is not.
AssetTabEditor grows a body() hook for that — the base class still stacks header,
strip and canvas, a subclass may put something next to the canvas.
Found by the full run, not reported: the graph canvas's empty-state hint ran
38 px past its frame now that the preview takes 280 px away. It is cut with
truncate() like any other label.
816 checks (809 + 7).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
A shader is an asset carrying a node graph (Texture color, Output, Property,
Color, Add/Subtract/Multiply/Divide, Mix, Luminance) that compiles to the GLSL
fragment of a Pixi filter. The Shader tab reuses the Behavior canvas; a sprite
wears a shader through its Shader picker, and the rows below it are the
graph's Property nodes. Property schema, resolution and deviation take the
project because a sprite's rows now live in another asset. The built-in
registry of the prototype is gone; "Adjust colors" ships as a graph in the
fixture and the three example games.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A log layer (src/editor/console/log.ts) keeps a 500-line ring buffer and
wraps console.log/info/debug/warn/error, still calling the original. Notices
and behaviour runtime errors go straight to the layer instead of through
console, so editor tests stay quiet and errors can render red. world.log is
untouched: it is per-run structured state that runtime tests and e2e read
directly, not a text stream.
The panel sits under the visible tab, resizable with Splitter('y'), with a
CONSOLE header and a clear button. Each line carries its wall-clock time in
its own dimmed column, so locate() still finds the message text.
The taller centre area drops the graph fit zoom to 0.60, which broke six e2e
checks that assumed one screen pixel is one graph unit and that a fixed
offset from the canvas centre is empty. They now ask __gm.emptySpot(),
__gm.graph.emptySpot() and __gm.graph.zoom() instead of guessing.
locate/locateAll added the unscaled Pixi text width to the global position,
so click points in the scaled graph canvas landed too far right; they use
getBounds() now.
check.sh: 831 tests. Full e2e: ALL PASSED - 671 checks. Smokes 24/19/20.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A state machine is an asset whose graph holds one `State` node per state
(no ports, name as title), edited in a new "State machine" tab that reuses
the graph canvas: right-click places "State n", snap toggle shared with the
Behavior tab via SnapToggle. `statemachine` is a value kind like texture
(port, literal node, variable, struct field, tagged list item).
Behaviors use it through `State branch` (one exec output per state) and
`Set state` (one exec input per state; the interpreter now hands `run` the
entry port). States live per behavior instance in NodeContext.states, with
no initial state. Ports derive from the inline picker or a wired
`State machine` literal; BehaviorLookup became GraphLookup with
behavior(id) and machine(id).
The Graph tab is labelled "Behavior" (key unchanged). Unit tests
(state-machine, kinds, catalog, ports), e2e step 26 and README added.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The graph was only locked by five edit-mode guards in GraphCanvas. The
running game reads the graph by reference each frame, so a new node, edge,
changed value, disconnect or undo takes effect from the next frame. Stop
discards these edits like everything else changed in play. A new On start
node does not fire for a behavior that has already started.
During play every data output shows the value it last produced, in the
highlight colour and refreshed ten times a second. Values are kept per
graph, because node ids are only unique within one; with several
instances the most recent value wins. The Variables node reads the live
variable bag, so only its own outputs are kept, as a snapshot.
Each time an exec edge fires, a bead runs along it in 0.3 s, at most one
every 0.15 s per edge, following the same curve the edge is drawn with.
Beads run on game time and freeze while paused; the game logic is
unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A single node now lists any number of variables. Every entry is an output
that reads the current value whenever it is pulled; an entry switched to
Set is also an input and is written when the flow arrives. Exec pins only
exist while at least one entry writes, and all inputs are read before any
write, so one node can swap two variables.
Get/Set variable, Get/Set variables and Variable are gone. Old project
files are converted on load: each old node becomes a Variables node with
the same variables, edges and typed values.
A written port without a typed value now shows the zero of its kind on
the node body, matching what the inspector shows and what the run writes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Get properties / Set properties work like their variable counterparts:
one port per chosen property, an unconnected input writes its inline
value. A port chosen from the built-in list keeps that property's field
and default (height starts at 100, color is a colour field). "object" is
reserved for the fixed input, checked by the command and on load.
The property picker names the owning type ("Sprite width"), and text
fields can now carry a suggestion list; Get/Set property use it.
Double-clicking a call node shows the path of opened behaviors under the
graph header. Clicking an entry jumps back and trims the path; choosing
a behavior any other way hides it. Reopening a behavior already on the
path steps back to it instead of repeating it. The bar shows before the
graph switches, otherwise it clips the graph that was just fitted.
The "Add node..." header button is gone; nodes come from the context menu.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Split GraphCanvas (1076 -> 831 lines) into EdgeLayer and graphMenus;
the port and marker menus were the same list and are now one function.
Value specs of the inspector and the list tab share valueSpecs.ts, and
the remaining copies (compound commands, type glyphs, typed asset
lookup, name rule, play/editor world setup, marquee) are merged.
Four copies had already drifted apart; merging them fixes:
- "Disconnect N links" in the port menu is one undo step, not N
- "+ Add collider" is hidden on a placed container instance, as in
the hierarchy menu, instead of always failing
- a bool property coerced from the text "false" becomes false
- a port command on an unknown node type reports instead of crashing
Comment-only edits leave the bundle byte-identical. README corrected in
35 places (step limit, jump-run clock, suite duration, 409 body, ...).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A chevron in front of the title toggles it without touching the selection,
and right-click offers Collapse node / Expand node for the whole selection
as one undo step. A collapsed node draws its title bar alone; every link of
one side meets at a single point on it, and hidden links there share one
mark per side, since per-port marks would stack on the same pixels. It has
no ports to grab, so a link dropped on it connects nothing.
Like hiding a link this is drawing only: collapsed is saved on the node and
an expanded node leaves no trace in the project file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ein Toggle in der Graph-Kopfzeile lässt gezogene Nodes auf dem 24er-Raster
einrasten – derselben Größe, die den Hintergrund zeichnet. Einschalten bewegt
nichts; die Regel greift erst beim nächsten Zug. Bei Mehrfachauswahl richtet
sich jeder Node für sich aus, statt einem gemeinsamen Versatz zu folgen, der
die alten krummen Abstände mitschleppen würde.
Die Entscheidung liegt als reine Funktion in framing.ts, wo die übrige
Graph-Geometrie schon wohnt, und ist dort wie im Browser gepinnt.
The Outputs setting is gone. A Sequence now keeps one free output below the
last connected one: nothing plugged in is one output, connecting the spare
grows a new one, unplugging the bottom edge shrinks it back.
It counts the last *connected* port rather than the number of edges, so
unplugging a middle one leaves a gap instead of silently dropping an edge.
Removing the setting also removes a bug it caused. Typing in the Outputs field
did nothing and the arrow keys did nothing, while dragging the value worked:
onInput fires per keystroke, the preview writes into node.params, that emits
'project', and Inspector.refreshValues() sees the node's signature change --
the port list is part of it -- so it rebuilds and destroys the row being typed
in. A drag survives because it keeps receiving globalpointermove on the old
widget. refreshValues() has no focus guard where refreshLive() does; the trap
is still there for the next typed field that changes a node's shape.
Ports that depend on the edges around a node mean derivePorts needs the graph,
not just the params, so resolveDef and portSignature take it too. And a node
whose ports are derived cannot know how many it has from its type, so run is
handed the definition resolved for it.
PropertyValue was number | string | boolean | list. A struct joins it, which
costs two things the typechecker found rather than I did.
An object on a data edge used to mean a live entity, so asEntity would have
taken a struct for one and asString would have read .name off it. EntityHandle
now carries a symbol brand – a symbol so no field name can impersonate it, and
it never serializes, which suits a thing that exists only while the game runs.
It is built in exactly one place.
String(value) would have written a struct as [object Object] at 39 call sites.
They go through textOf(), which is identical for primitives.
normalizeGraph dropped everything but primitives from node params, so the List
node lost its value on every save ([3,1,4] round-tripped to undefined).
Variables were spared: they have a schema, params have none.
src/viewport/ held three classes only src/editor/ ever used, and framing.ts sat
under graph/ although the scene view wants the same bounds sum. The graph, the
texture and the container tab repeated the same header, subscription and
disposal code with two silent divergences: the graph tab said "nothing shown"
with null instead of '', and the texture tab refell back to the first asset on
a selection change but not on a project event.
These arrive together because GraphCanvas ties them: it reads framing from its
new home, hands its fold marks to folding.ts, and its '' change is what
GraphEditor must agree with.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A held key fired once and then nothing, so sliding a Tetris piece sideways or
walking a menu meant building a timer next to every key node. Two settings on
the node do it now: Repeat after and Repeat interval, both in milliseconds so
the usual values stay whole numbers (300, 50) instead of 0.3 and 0.05. A delay
of 0 keeps the node exactly as it was, an interval of 0 means every frame.
The browser's own auto-repeat is deliberately not used - Input.press has always
dropped it - because its delay and rate are an operating-system setting of the
player's, not a game's. The repeat is built from the measured hold time instead,
per node, so two nodes on one key keep their own clocks. The rule itself is a
pure function in keyRepeat.ts and is checked without a world.
Both rows stay off the node body while the delay is 0: two lines of "0" on every
key node in every graph would be noise, and the screenshot said so. NodeDef gains
hiddenParams for that; the inspector still shows them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A link can be folded away from the canvas: instead of a curve running clear
across the graph, each end grows a short stub with a mark naming the node at
the far side. The curve comes back while either of its two nodes is selected,
so following a connection is a click rather than a search, and a link carrying
a live pulse is always drawn so the trace keeps its thread.
Marks are collected per port, not per link, because several hidden links on one
port used to stack stubs, dots and labels on identical pixels. A bundle reads
"Move +1" and its dot selects every node the port feeds, as one undo step.
The whole thing is one optional flag on the edge; the interpreter never sees it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A behavior node that cannot run looked exactly like a healthy one: no
start node, no behavior picked, a deleted behavior or a self-call were
all silently skipped. The call still had to be forgiving at runtime -- a
dangling reference must not abort a game -- so the fix is diagnosis, not
an exception: describeProblem() names the one reason, the node draws it
in red under its title, and the inspector repeats it where you pick the
behavior. A missing end node is deliberately not a problem: a call is
always synchronous, and the end node only decides which values come back.
Two ways in: a behavior dragged from the asset panel lands in the graph
under the pointer, and double-clicking a call node opens the graph it
runs. Both reuse machinery that already existed -- the asset panel
already starts the drag, and list views already report double clicks.
The e2e module needed its assets at the head of the asset list: after 18
modules the list is long enough that a newly appended row scrolls out of
view, and the press then lands on the panel behind it, starting no drag
at all. That looked exactly like a rejected drop and cost two full runs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A behavior that has a Start and an End node can now be dropped into any
other graph as a node of its own. What is declared on the start node
becomes the inputs of that node, what hangs on the end node its outputs;
the port list is derived from the sub graph, not copied, so declaring a
port in one graph grows the node in the other without touching it. The
node carries the behavior's name and the palette lists behaviors by
name, so a call is one grab instead of a node plus a dropdown.
An embedded behavior has no object and therefore no place for
variables: every call starts on the asset defaults and communicates
only through its ports. That is the conservative direction - state can
still be added later, and shared state already has a home in a behavior
object in the scene. Node ids are unique per graph only, so each call
gets its own pulse cache; without that, two graphs would collide.
Renaming a port used to silently drop the value typed on the calling
node. It is carried along now, project wide, like the edges.
Three things the graph view did differently from the scene view, or not
at all:
Panning moves to the middle button, which works from anywhere – a node
under the pointer included, since NodeView now lets that one button
bubble up. Shift-drag on empty canvas pans too. The left button pulls a
rubber band instead and no longer pans.
Nodes can be selected several at a time: by band, by ctrl/shift-click,
moved together, deleted together, each as one undo step. The selection
model gains `nodeIds` alongside the objects' `objectIds`.
Fit really fits. It used to pin the zoom at 1 and merely centre, so a
graph larger than the view always left nodes hanging out, and it sized
every node with one guessed height although real ones vary with their
port count. Both sums now live in editor/graph/framing.ts as pure
functions, which is what made the defect testable at all.
The band selects any node it overlaps rather than one whose position it
contains, unlike the scene view: nodes are large rectangles, and a band
drawn visibly across one has to catch it.
Releasing an edge over empty space opened the node palette but dropped the edge
drawing, so the connection being made was invisible exactly while its target was
being chosen. The finished drag is now held in `pendingDrop` and drawn like a
running one until the palette closes, by pick or by Escape.
The e2e check for the Escape path exposed a real defect underneath: `Popup.open()`
blurs the focused widget, `close()` did not. A field inside the popup therefore kept
the keyboard bridge's DOM focus after the popup was gone, and every later Delete or
Ctrl+Z went into that invisible input. The suite caught it one check later ("Delete
removes the node and its edge" found the node still there). `close()` now blurs,
mirroring `open()`.
Verified remotely on the Windows host: 287 editor checks and 24 jump-and-run smoke
checks, no failures.
Dragging an edge now rings the port it would connect to and snaps the line
there. A drop on a node's body takes that node's first free compatible port,
or the last compatible one when none is free, replacing its link. A drop into
empty space opens the palette filtered to the node types the edge could reach
at all, and the picked node arrives wired - node and link as one undo step,
so a single undo takes back the whole gesture.
The decision of what fits what lives in core/graph/dropTarget.ts as pure
functions, tested directly; the canvas only draws the answer. The palette
takes an optional filter rather than knowing about drags.
Verified remote on the Windows box (GPU): ALL PASSED, 284 checks.
Remove comments that only restate the code, keeping the section banners
and the notes that carry a reason, a measured fact or an external
contract.
ButtonOptions.width claimed to be a fixed width but only set minWidth,
so the Graph and Texture pickers grew with the name they showed. It is
now what it promises, with a separate minWidth for the buttons that have
two forms. Narrow buttons shrink their padding, otherwise a 24px icon
button had 8px left for its glyph and truncate dropped it entirely.
__gm.labelsDropped() guards that class: the overflow guard only saw text
that was too wide, never a label truncated to nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- NumberField takes a decimals option (default 3); frame rects and the
slice dialog use 0, matching their integer model.
- Button truncates its label to the drawn width; Dropdown reserves the
chevron slot. NodeView reuses the shared truncate().
- Setting scaleMode leaves the style's shared resource id in place, so
the old GPU sampler stayed in use until a reload. Call style.update().
- Graph gets a header bar like the Texture tab: GraphEditor is now the
frame, the former editor is GraphCanvas. A selection the tab does not
concern no longer unloads the behavior.
- New e2e step 14 guards text overflow across all widgets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>