The pixel art side bar showed every tool option all the time, the
palette used only part of the column width and "Remove" could only
drop the last colour. Now each tool shows what it needs, the palette
fills the column and any colour can be removed.
- Options per tool live in toolOptions.ts. Brush shows for pencil,
eraser, line and unfilled shapes. Filled shapes shows for rectangle
and ellipse. Contiguous shows for fill. Picker has none.
- All options share one row with a fixed height, so nothing below
jumps when the tool changes.
- The palette picks its column count from the width and stretches the
swatches until the row ends flush. At 1280 px that is 11 per row
instead of 8.
- The colour field takes the space the two buttons leave, so "Remove"
is no longer cut off.
- "Remove" deletes the selected colour. Pixels of that colour turn
transparent, later indices move down by one in every image that uses
the palette, and palette plus images change in one undo step.
Transparent itself cannot be removed.
Decisions:
- Pixels of a removed colour become transparent, like erasing
(chosen by the user before the build).
Findings:
- The Filled checkbox slid away under the pointer. The brush button
sat to its left and vanished when Filled turned on, so the second
click missed. The brush button now sits to the right, and a check
clicks the same spot twice.
- One start-up FAIL "debug hook present" came back never in three
later runs. It is the known start flake.
Tests:
- ./check.sh: 141 files, 2080 tests, exit 0 (before 140 / 2070)
- 10 new unit tests in tests/editor/pixel-palette.test.ts
- GM_STEPS=48 (fresh seed): ALL PASSED, 40 checks, 4 of them new
- Counter-check: indices not shifting and brush always visible make
4 unit tests and exactly the 2 matching e2e checks fail
- Check total 1282 (1278 + 4) in README and wiki, computed
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 bbbf341, plus 6).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The layer, frame and shape lists still had their add and delete
buttons scattered below the list, each in its own layout. They now
live in the shared SortableList, so every list works the same way.
- SortableList gets an optional header with the title on the left
and a + on the right, plus an optional x per row next to the eye.
- The keepOne option hides the x on the last remaining row, so a
pixel art image and a tilemap always keep one layer.
- Pixel art layers and frames and tilemap layers use header, + and
x. Only Merge down and Duplicate stay below the lists.
- Vector art shapes get the x but no +. Add shape stays below the
list and Delete shapes stays for a multi-selection.
- The section headings in the pixel art column use the same style
as the list titles and the inspector.
Findings:
- The wait loop added to check 47i for #196 only waited for the
right half of the shape. If the texture landed between the two
reads, the left half was still blue and the check failed. It now
waits for both halves.
Tests:
- ./check.sh: 138 files, 2052 tests, exit 0.
- GM_STEPS=39,40,47,48 (fresh): ALL PASSED, 156 checks (was 154).
- GM_STEPS=47,48 (fresh, final): ALL PASSED, 86 checks.
- Two new e2e checks: x on a shape (47) and x on a frame (48). The
tilemap check in 40 now uses the x instead of Delete layer.
- Counter-check: with the x not reporting to its owner, exactly the
two new checks fail.
- Check total 1266 in README and wiki is computed (1264 + 2).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reordering shapes by dragging worked well in the vector tab. Pixel
art layers and frames and tilemap layers still used up/down buttons.
All three lists now share one widget built from the shape list.
- SortableList in the UI toolkit, with the drag rules next to it in
src/ui/sortable.ts. A row has an optional thumbnail, a name, a hint
on the right and an eye toggle. Every drop is one undo step.
- The shape list is a thin wrapper around it. shapeRows.ts is gone,
its tests moved to the new rules.
- Pixel art layers show the layer alone on the current frame, frames
show the whole frame. Up/Down, Hide, Earlier and Later are gone.
- Tilemap layers get the eye instead of the checkbox. The per-row
up, down and delete buttons are replaced by Delete layer next to
Add layer.
- A row is selected on release instead of press. Selecting rebuilt
the inspector or tilemap panel and cancelled a drag that started
on a row not yet selected.
Tests:
- ./check.sh: 138 files, 2052 tests, exit 0 (before 137 / 2051).
- GM_STEPS=39,40,47,48 (fresh seed): ALL PASSED, 154 checks.
- 5 new e2e checks. Check total 1264 in the README is computed
(1259 + 5), CI never measured 1259.
- Counter-checks bite: dropMove ignoring reversed fails a unit test,
a tilemap drop not applied fails the drag check, a layer thumbnail
showing all layers fails the thumbnail check.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A vector shape could only be filled and outlined with one flat colour.
Shading, glows and patterned surfaces had to be faked with stacks of
shapes. Every shape now has a paint type for its fill and its outline.
- Fill type: solid colour, linear gradient, radial gradient or
texture. Line type offers the same without texture.
- Colour stops are edited on a bar in the inspector, as agreed in the
issue. A click on the bar adds a stop in the colour the gradient
already has there, so adding never changes the picture. At least two
stops remain.
- The new Paint tool in the tab shows two handles for the selected
shape: axis start and end, centre and radius, or the corners of one
texture tile. "Handles: Fill / Outline" picks which paint is edited.
- Moving a whole shape moves its gradients and tile with it. Every
gesture is one undo step.
- A texture fill always tiles, because Pixi turns a non-repeating
pattern into a repeating one anyway. When switched on, one tile
covers the whole shape.
- Only uploaded textures and frames can be a fill, never a drawing.
A drawing filled with itself would rasterise itself mid-raster.
- The fingerprint of a drawing includes the texture and frame a fill
points to, so the picture redraws when the image is swapped.
Findings:
- graphics.destroy(true) also destroyed the textures of the fills.
With a texture fill it deleted the tile slice and the next redraw
crashed on reading 'scaleMode' of null. It is now destroy().
- The gradient cache destroyed old gradients itself, which gave 20
Pixi warnings "destroyed while still bound". They are now handed to
Pixi's texture GC with autoGarbageCollect switched on. The console
of module 47 is empty again, the last CI log had 0 of them.
Tests:
- ./check.sh: 135 files, 2011 tests, exit 0 (before 134 / 1986)
- npm run build: exit 0
- GM_STEPS=47, freshly seeded: ALL PASSED, 54 checks (before 44),
0 console warnings
- 25 new unit tests, 10 new e2e checks in block 47i
- Four counter-checks bite, originals restored and verified with cmp
- Check sum 1232 in README and wiki, calculated (1222 + 10)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Building a shape out of simpler ones meant drawing the result point by
point. The Vector Art tab now selects several shapes at once and
combines them with a boolean operation into one ordinary shape.
- Ctrl/Cmd+click in the tab and Ctrl or Shift+click in the shape list
add a shape to the selection. Shift stays pan in the canvas.
- A COMBINE block in the inspector offers Union, Subtract, Intersect
and Exclude. Subtract removes every upper shape from the lowest one.
- The result replaces the lowest selected shape and keeps its name,
colours, stroke and place in the order. It is one undo step.
- Curves are flattened to 0.2 canvas pixels, a circle gets about 48
points. Open lines and single shapes cannot be combined, the buttons
are off and a hint gives the reason. An empty result is rejected.
- Delete now removes every selected shape, and the selection drops
shapes that no longer exist.
- The geometry comes from clipper2-ts 2.0.1, pinned. It has no
dependencies and speaks even-odd directly. Only the engine module
is imported, the bundle grows by 55 kB instead of 110 kB.
Findings:
- clipper2-js was tried and dropped. It pulls in Angular 15 with a
high npm audit finding and no fix.
- Pixi finds holes by containment, not by even-odd depth. An island
inside a hole would be drawn as a hole again. Result rings are
emitted outer ring before its islands, and a test mirrors Pixi's
rule.
- A depth sort turned out to be dead code in a counter-check and was
removed. A pixel check in 47h stayed green without the subtract
and now checks both colours.
Tests:
- ./check.sh: 134 files, 1986 tests, exit 0 (before 133 / 1968)
- npm run build: exit 0
- GM_STEPS=47, freshly seeded: ALL PASSED, 44 checks (before 39)
- 18 new unit tests, 5 new e2e checks in block 47h
- Five counter-checks bite, originals restored and verified with cmp
- Check sum 1222 in README and wiki, calculated (1217 + 5)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
A shader could only be judged by placing it on a sprite and running
the scene. The shader tab now shows its effect next to the graph, on
the image the user picked last, without a picker to maintain.
- The preview follows the last selection that has an image: a placed
sprite with its overrides, size and tint, a sprite asset, a texture
or a frame. Clicking the shader, a node, a behavior or a scene keeps
the picture.
- Without such a selection, or when the picked image is deleted, a
built-in 64 px test image is shown. Its colour runs with u and v, a
checkerboard shows where a UV lands and the border fades for alpha.
- Each property gets a slider. The sliders belong to the preview and
never write to the project. They start at the sprite's values when
it carries this shader, otherwise at the default.
- The preview has its own clock. Hold and Restart are only enabled
when a Time node is in the graph.
- The graph | splitter | panel layout of the particles tab moves into
PreviewSplit, and both tabs use it.
- A nine-sliced frame is previewed unsliced. This is documented.
Findings:
- The slider row stayed squeezed to a few pixels when the properties
changed while the tab was hidden. The layout was triggered one level
too high. The slider check in block 28n holds this.
- The graph canvas is narrower now, 385 px at a 1280 px window, the
same as in the particles tab. Block 28b clicked at a fixed x+420 and
now uses the measured width.
Tests:
- ./check.sh: 132 files, 1952 tests (was 131 / 1941), exit 0
- npm run build: exit 0
- GM_STEPS=28,31 freshly seeded: ALL PASSED, 105 checks
- 11 new unit tests in tests/editor/shader-preview.test.ts
- 7 new e2e checks in block 28n
- Counter-probes: start values ignoring the sprite fail 1 unit test,
a preview ignoring the selection fails 2 e2e checks
- Check total 1209 in README is computed (1202 + 7), CI measures it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pen only pulled out the outgoing handle of a new point, because
the opposite handle was mirrored only if it already existed. The
first segment of every dragged point was therefore half a curve.
Beyond that bug, a point had no way to say how its handles relate.
- A vector point has an optional mode: straight, corner, smooth or
symmetric. Missing means corner. The mode is not part of the
fingerprint, because it changes no pixel, only the next drag.
- Dragging a handle follows the mode. Corner leaves the other handle
alone, smooth turns it along and keeps its length, symmetric
mirrors it exactly.
- The pen pulls out both handles mirrored and marks the point
symmetric. A plain click still sets a point without handles.
- The tab header has a "Point: ..." menu for the last clicked or set
point. That point is drawn filled. Changing its type is one undo
step. Text instead of four icons, because the UI kit has no
tooltips.
- Switching a point without handles to a curved type first grows
handles along its neighbours. Smooth and symmetric line the
handles up and keep the rough direction of the curve.
- A point inserted on a curve is smooth. Symmetric neighbours become
smooth, because the split shortens one of their handles.
- Alt-click still toggles between straight and symmetric.
Found on the way: the old Alt rounding took the point at the other
end of an open line as a neighbour, so its handles came out skewed.
Fixed and held by a unit test.
Existing points with handles now count as corner. Before, every drag
mirrored. No pixel changes, only the next drag behaves differently.
Tests:
- ./check.sh: 129 files, 1917 tests, exit 0 (before 1903)
- GM_STEPS=47, freshly seeded: ALL PASSED, 39 checks (before 32)
- Check sum 1195 in README and wiki is computed (1188 + 7), CI
measures it
- Counter-checks: pen without 'symmetric' fails 3 checks, no smooth
branch fails 1 unit test and 1 e2e check
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The snap toggle was a checkbox with the word "Snap" next to Fit, and
the vector art tab had its own "Grid" button for the same idea. Both
now share one square icon button, so snapping looks and works the
same wherever it exists.
- SnapToggle is a square toggle button with a magnet icon and no
text. It lights up blue when snapping is on, like the other toggle
buttons.
- The magnet is drawn with cubic curves instead of arcs, because the
Pixi SVG parser draws arcs as jagged edges.
- The vector art tab drops its Grid button and uses the same
SnapToggle. It still snaps to 8 px, the graph tabs to 24 px.
- SnapToggle accepts anything with setSnapping(on). The vector art
canvas method is renamed from setSnap to setSnapping for that.
- Every tab exposes the button as __gm.<tab>.snapButton(). Modules
06, 26 and 36 click through that hook instead of the label "Snap"
plus 30 px.
Tests:
- ./check.sh: 129 files, 1903 tests (was 128 / 1897)
- 3 new tests in tests/editor/snap-toggle.test.ts, 3 in the icon tests
- GM_STEPS=1-6,26,36,47 fresh: ALL PASSED, 346 checks
- GM_STEPS=47: ALL PASSED, 32 checks (was 29)
- Counter-checks: toggle not switching its target fails 2 unit
tests, the canvas ignoring the switch fails module 47
- Check total 1188 in README and wiki is computed (1184 + 4), the
full run is left to CI
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 4 of #129. Until now the inspector showed the rows of the asset
when a shape was selected, so there was no way to give a shape a colour,
an outline or a place in the drawing order without editing the project
file. The rows for a shape now sit where every other selection puts its
rows.
- SHAPES lists every shape of the asset, topmost row drawn last, the
same way a layer list reads. Selecting the asset itself shows the same
list below its own rows, with no shape selected.
- Each row paints its shape with the commands the rasterizer uses
(shapeDrawing plus paintDrawing) into a 22 px swatch, the whole canvas
scaled in, so the row also says where on the canvas the shape sits.
- The four order buttons from the issue (forward, to front, backward, to
back) sit in two rows of two, and a row can be dragged to another
place with a marker line. They are disabled when there is nothing to
move.
- VECTOR SHAPE carries name, fill, opacity and an outline checkbox, the
checkbox adding and removing the stroke instead of hiding it; line
colour and width appear with it. A dragged colour slider is one undo
step, the style rows going through previewingHandlers like every other
row.
- A line width of 0 is no stroke, which the command rejects, so the row
clamps at 0.1.
Findings:
- locate('Outline') aims beside the control, because it gives the centre
of the label text and a wider label pushes a fixed offset out of the
field. __gm.inspectorField(label) knows the centre of the control.
- A click into a text field sets the cursor and selects nothing, so a
typed hex landed appended to the old one and parseHex returned null
without anything failing - typeText only checks that the text is
contained. A Ctrl+A goes first now.
- Four buttons side by side cut "Backward" to "Backw...", which only the
screenshot showed. Two rows of two now, as in the tilemap panel.
Tests: ./check.sh 128 files, 1897 tests (was 127/1883). Full e2e suite
locally instead of only CI because the change sits in the inspector and
touches every selection: ALL PASSED - 1179 checks (was 1172), module 47
at 24 checks (was 17). Three counter-checks hold: not reversing the list
fails 1 unit test and 2 e2e checks, dropping the gap correction when
dropping a row fails 2 unit tests and the drag check, and a constant
signature kills module 47 because the outline rows never appear.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stages 1 and 2 gave the asset a model and a picture, but the only way
to put a shape into it was a test calling mutate. The tab closes that
gap: what you draw there is what the sprite in the scene carries, with
no extra step, because the fingerprint changes and the picture registry
of #185 redraws.
- The tab has a header with Select, Pen, Grid and Fit, a chequerboard
behind the canvas and the canvas itself as a frame. It paints with
the same commands as the rasterizer, paintDrawing being exported
from viewport/vectorArtPicture.ts for it, so the tab cannot drift
away from the picture.
- Pen sets points on a click and pulls the handles out on a drag, both
of them mirrored; a click on the first point closes the ring and Esc
ends an open line.
- Select drags a point, a handle or a whole shape, a click on an edge
puts a point there, a double click takes one away, and Alt turns a
corner round and back. Delete removes the selected shape.
- The rules grew inside polygonEdit.ts instead of a second module, as
the plan says, so a collider polygon and a frame outline get the
same behaviour where it applies.
- A point on a curved edge splits the curve by de Casteljau instead of
cutting it: both halves together draw exactly what was there before.
- A curved edge is walked instead of measured over its chord, or a
click on the bow of a curve would grab nothing; the same walk carries
the test for the inside of a ring.
- One gesture is one undo step. The preview writes the model directly
and the old state is put back on release before the command runs,
which is the pattern the frame outlines of #141 already use.
- state.ts gained a selection kind "shape". The inspector shows the
rows of the asset for it, the shape list with colour, opacity and
stroke being stage 4.
One counter-check was no counter-check: disabling the pen's closing
tolerance by multiplying it with 0 left the run green, and rightly so,
because the closing click lands on exactly the screen coordinate point
0 came from, so the distance is 0 and <= 0 is true. Only an
unsatisfiable condition really switched it off, and then 2 checks fail.
A second trap on the way: a false && in front of the guard breaks the
TS narrowing, npm run build bails out, and two runs went against the
old bundle.
Tests: check.sh 127 files, 1878 tests, EXIT 0 (was 126 / 1860).
Full e2e suite ALL PASSED - 1172 checks, module 47 alone 17 (was 10).
18 new unit tests in tests/editor/ring-edit.test.ts and 7 new e2e
checks in blocks 47c and 47d. Two counter-checks hold: no de
Casteljau fails 1 unit test, measuring a curve over its chord fails
another. Originals checked back with cmp and rebuilt.
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>
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>
TextureFrame carries optional `border` (left/right/top/bottom) and
`fill` (stretch | repeat); absent keys mean "not sliced", so no format
bump and undo restores the plain frame instead of four zeroes.
The sliced sprite builds its own geometry instead of the shared
UNIT_QUAD: a 4x4 grid, and for `repeat` one quad per tile carrying the
frame cell's real UVs -- tiling in the geometry, not in the sampler, so
a frame out of a sheet cannot pull its neighbour in. Above 64 tiles per
axis it stretches, to bound the buffer.
A sprite narrower than left+right scales both borders down together
until the middle is exactly 0, so the corners meet at a seam and never
overlap.
The texture editor gets four draggable border lines plus the same four
numbers in the inspector. Hit order is resize handles, then the lines,
then the frame body -- handleAt reports 'move' anywhere inside a frame,
so lines placed after it were unreachable.
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>
Typing "variable" into the node palette landed on `Run behavior`, because the
filter weighed label, type and description alike and then sorted by category.
`searchRank` in `src/ui/search.ts` now ranks a match: exact label or type, then
prefix, then label, then type, and only last a hit that lives in the
description alone. Category and alphabet order within a rank. The searchable
`Menu` uses the same function, so searching means the same thing everywhere.
The `+` menu grew to fifteen entries in declaration order. It is built in two
alphabetical groups now -- the types that go into a scene, then the ones that
stay in the library -- with a separator between them, the split `isPlaceableType`
already draws. `ASSET_TYPES` itself stays untouched: it is also the
serialisation and check order. A search flattens the groups and drops the lines.
Popups were capped at fixed heights (320 menu, 260 palette, 340 pickers) while
the canvas had room to spare. `Popup.roomFor()` measures its own chrome and
takes the window height minus a tenth at each end. Placement clamps
asymmetrically: the bottom margin is enforced, the top is only a budget, so a
menu anchored near the top stays at its button instead of being pushed off it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
Nine tabs need 631 px; at 1280x800 the centre column offers 666 px, so the tenth
tab would have spilled under the inspector - the same overflow CI caught in run 5.
TabStrip replaces the bare HStack: it fits the longest prefix that stays inside
the budget, always appends the active tab, and offers the rest behind a fixed
28 px button. The button's width is fixed on purpose - deriving it from the
number of hidden tabs would make width depend on the count and the count on the
width.
The rule itself is a pure function (tabRow.ts), so it is checked without a
browser. setActive() calls relayout() itself because Widget.resize only lays out
on a changed size, and switching tabs does not change any size.
Adding a tab now needs no space arithmetic - only a line in TAB_ORDER,
TAB_LABELS and panes, all three Record<CentreTab, ...>, so forgetting one is a
type error.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An upload sends its file name next to the raw bytes, and the server keeps
it in a sidecar next to the content-addressed file. Both pickers caption
every tile and row with it, and an asset made there is named after the
file instead of "Texture" / "Font". Last upload of the same content wins.
The API's own set_error_handler ignores error_reporting(), so @-silenced
warnings still throw: a missing sidecar took the whole listing down with a
500. Existence is checked instead of suppressed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A font asset holds a file and nothing else, the twin of a texture: the server keeps it
content-addressed under data/fonts/{sha256}.{ext}, with the format read from the file's own
four-byte signature (finfo reports fonts inconsistently across libmagic versions). A text
wears one through its ordinary Font property, so overrides, reset, undo and the Properties
node come for free; no font means the editor's own typeface.
The picker lists everything the server holds and renders each row in the face it offers.
The family name is gm-font-<hash16>, never the name inside the file, so two "Regular" cannot
collide. WorldViewport.syncText is the single place that draws it, which covers scene, game
preview and play at once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A sprite's picture is now a Mesh (unit quad, sibling of its Graphics node, which keeps
only the hit area); a worn shader replaces Pixi's fragment in that draw, so there is no
intermediate texture and no extra render pass. Measured on the HD 630: 400 shaded
sprites hold 60 fps (was 34), 800 hold 56 (was 20).
Two Pixi facts the code leans on: a mesh's texture is bound only for Pixi's own shader,
so GraphShader.wear hands it over; and MeshPipe caches whether a mesh batches and
re-checks it only in a frame without structure changes, so switching a shader on or
off calls mesh.unload().
The console panel mirrors console.group/groupCollapsed/groupEnd and fills the %s %d
%o %c directives, so Pixi's deprecation warning shows its head, not only its stack.
E2E 12 guards against the addChild-on-Graphics warning, 28 counts shaders instead of
filters.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
One rule for every asset type: a click on an asset row selects it, a
double-click (or Enter, or creating it via +) opens its tab. Selection
alone still switches tabs only for nodes, frames and template objects.
- state.openAsset(id) = select + 'open' event; the editor maps the asset
type to its tab via TAB_OF_ASSET. The two special clauses for state
machine and shader assets in the selection handler are gone.
- Creating via + and importing a texture from the picker open the tab;
paste/drop of an image only selects.
- 3 unit tests (event order, re-open of a selected asset, selection never
fires open), 14 e2e checks in step 4.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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>
- `.gitignore` said `index.html` unanchored, which also caught the Vite source page;
a fresh clone could neither `dev` nor `build`. Anchored to `/index.html`, page added.
- Removed GM_REMOTE (tools/remote/gm-browser.ps1 and the ssh/scp branch in browser.mjs):
the Windows test machine is gone and the only flaky check that lived on it is closed (#37).
- Removed `npm run preview`, undocumented and unused.
- Comments: wrong number in collider.test.ts (250 → 200), the "declarations used to…"
history in listItems.ts and friends, the 24 px/28 contradiction, section banners in
nodes/index.ts, node-catalog.test.ts and api/index.php, name paraphrases, and the stale
step numbers in tools/e2e/11-…23- from the old global count.
- README: asset types include `animation`, kind order Number/Text/Boolean, tagged colour
and texture items, 6/5 kinds, the `Texture` node, `./build.sh`, PROJECT_FORMAT_VERSION,
and the file tree (kinds.ts, listItems.ts, propertyPorts.ts, Breadcrumb.ts, beads.ts,
tools/assets/).
- tests/helpers.ts takes the duplicated `findObject` and `sceneWithCamera`.
Proof: ./check.sh 797 tests, full e2e ALL PASSED 633 checks against this bundle.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A kind like colour or texture used to be spelled out in five parallel
lists, its port type, its empty value, its glyph, its label and its list
tag - 22 literal places for texture alone. core/properties/kinds.ts now
holds one row per kind and the rest is derived from it, so adding a kind
is one row. The five literal nodes collapse into literals.ts, the six
kind menus into one helper.
Number, Text, Boolean is now the one order everywhere; the variable and
port menus used to list Boolean before Text.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- `Texture` and `Color` can be picked as a variable kind, a struct field
kind and a declared port kind; a new `Texture` node hands a picture id
into the graph. Declared, a texture is its bare id – the `$texture` tag
stays a list-only thing.
- The `String` node is labelled "Text" like its kind; the type id stays.
- Whole-number inputs (`Item at`, `Set item`, `Sub list`, `Fill`,
`Repeat`, `Substring`, `To text`) show no decimals (`int()` in shared.ts).
- All server projects are reseeded, so `legacyNodes.ts`,
`migrateDeclaredLists` and the `ownerId` fallback go, with their tests.
- Note `reseed-because-of-list` moved to done/; new note `refactor-types`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A list no longer says what its items are. ListDef.items, ListDef.fields,
ListFieldDef.items and SetListItemsCommand are gone; the single seam is
src/core/properties/listItems.ts. JSON already tells number, text, boolean
and struct apart, so only two kinds need a tag: {"$color": n} and the new
{"$texture": id}. Field names cannot start with "$", so a tag never
collides with a struct field. On a data link a texture item arrives as
its bare id; the graph has no images.
List tab: the mark in front of each row is the kind picker, "+ Item"
asks for the kind, both are ordinary value edits (one undo step). A list
inside a struct field only offers the flat kinds because the depth rule
forbids a struct item there (ListAccess.kinds); the menu used to offer
it and silently do nothing.
Legacy lists that declared items: 'color' are tagged on load.
Actor demo: the four walk animations and four standing frames live in
two list variables of texture items indexed by facing (Item at), so the
graph can be rebuilt in the editor. The stand frames get their grid ids
back; 44 nodes instead of 46.
Also: the user's edits to on-start-during-play.md and the new note
reseed-because-of-list.md.
Verified: check.sh 794 tests, full e2e 631 checks, smokes 24/19/20.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An animation asset is a list of pictures (whole textures or frames) with
frames per second and Loop/Once/Ping-pong playback. A sprite wears it
through its texture property, so the picker, instance overrides and the
Properties node work unchanged; there is no dedicated play node.
A different animation starts from its first frame, the same one set again
runs on, so a graph may choose Run or Idle every frame. The clock lives on
the entity, not in props, which the world writes back as overrides. The
AnimationSystem ticks before behaviors, and On animation finished fires
once when a Once animation reaches its end.
The Animation tab plays a preview above a frame strip: reorder by dragging,
Delete, duplicate, + Frames, pause/resume, and wheel zoom about the pointer
with pan and Fit. Scene and Container tabs show the first frame; edit mode
has no clock. Right-clicking a texture creates an animation from its frames.
Button now measures its label with the bold font it draws in, which cut
the Animation tab and red dialog buttons short.
Co-Authored-By: Claude Opus 5 <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>
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>