The rows of the animation timeline stood in the order they were keyed.
With several objects and properties the list was hard to read, and a
value could only be changed by picking a key and using the inspector.
The Play button also kept saying Pause after scrubbing had paused.
- Each object gets a header row with its name, indented by depth. The
groups follow the scene hierarchy, the root first. Rows whose object
is missing go to the end.
- Under an object the rows follow the order in which it lists its
properties: X, Y, Z, then the order of the inspector. Only the view
is sorted, the animation itself is unchanged and there is no undo
step. After a move in the hierarchy the order is right by itself.
- The row head shows the same field as the inspector with the value at
the playhead. Without keys it shows the value of the object, for a
relative row the offset.
- A change in that field sets or changes the key at the playhead,
selects it and is one undo step. A running playback pauses first.
- The Play/Pause button follows every change of the playing state, so
scrubbing that pauses shows Play again.
Tests:
- Unit animation + timeline-view: 100 passed, 3 new.
- e2e 25 on melektaus-atx: ALL PASSED, 34 checks, 3 new (groups,
order and values at frame 4, a typed 55 becomes a selected key on
frame 4, Play then a click on the ruler shows Play and pauses).
- Gate on melektaus-atx: exit 0, 297 files, 4561 tests.
- tsc, eslint, Prettier clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The built-in eases cover the usual shapes, but a game often wants its
own: a coin that bounces twice, a door that overshoots and settles.
A new asset type holds such a curve, and keys and the Lerp node can
pick it instead of a built-in ease.
- New asset type "curve": points like in Vector art, x is t and y is
the value. A new curve is the straight line from (0, 0) to (1, 1).
- One pure evaluation in src/core/model/curve.ts serves the timeline
preview, the running game and the Lerp node.
- The curve always has one value per t. The end points stay at t 0
and 1, points cannot pass each other, and a handle that reaches past
its neighbour is shortened along its direction. Loading a hand-edited
file applies the same rules.
- Curve tab: click on the line to insert a point that keeps the shape,
click on free space to put one there, drag points and handles,
double-click or Delete removes, Alt-click toggles round and sharp.
Every step is one undo step.
- Presets: all 30 built-in eases plus Linear, fitted with adaptive
subdivision. Each stays within 0.4 % of the built-in ease.
- Preview: a dot runs along the curve once a second, a second dot
shows only the value. The Preview button stops it.
- Animation keys get "Curve" under Easing with a curve picker. Lerp
gets "Curve" under Ease, and its result may overshoot b.
- A missing or unpicked curve runs linear. The inspector says so, the
node shows it as a problem, and deleting a used curve warns with the
number of keys and Lerp nodes.
Tests:
- ./check.sh: 180 files, 2612 tests, CHECK EXIT 0
- GM_STEPS=1-6,25,53: ALL PASSED, 309 checks
- New e2e module 53 with 13 checks, all with the real mouse. Expected
values come from its own Bezier evaluation.
- Counter-checks bite: without Bezier evaluation 4 unit tests fail,
without handle clamping 2. In the browser, a key that cannot find its
curve, a click that sets no point and a handle drag that moves the
point all turn checks red.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A red filled row in a context menu shouted louder than the action
deserves. The entry now looks like every other entry and only its
text is red, so the menu stays calm and the warning is still there.
- Menu: danger entries are ghost buttons with the new text style
danger. On hover they get the same grey fill as all other entries.
- Theme: new text style danger in the normal font size. The existing
warning style is small and did not fit a menu row.
- Button: two getters expose the drawn text colour and fill, so a
test can measure the look instead of reading the danger flag.
- Test hook __gm.menuLooks() lists both per menu entry.
- Confirm dialogs keep their filled red Delete button. That is a
separate widget and was not part of the request.
Tests:
- ./check.sh: 172 files, 2498 tests, CHECK EXIT 0
- e2e 25: ALL PASSED, 31 checks. New check in 25g opens the menu
with a real right click: Delete row has text #e0553f and no fill,
Insert frames keeps the normal text colour.
- Counter-check: with the old styling the check fails with text
#ffffff on fill #e0553f. Original restored per cmp and rebuilt.
- Not checked: other menus (graph, assets) have no own check. They
run through the same Menu code.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The context menu of the timeline is where the other row and key
actions live. A separate button in the inspector was easy to miss and
sat apart from them.
- The timeline menu gets "Delete row" as a danger entry after a
separator. It deletes the row under the pointer, whether the click
hits the row header or a cell. Below the last row or on the ruler
it is disabled.
- A right-click on a row header now also selects that row, like a
right-click on a key selects the key. A right-click in an empty
cell keeps the selection so chosen keys stay available for Copy.
- The "Delete row" button in the "Timeline row" inspector is gone.
"Delete keys" for a multi-key selection stays.
Tests:
- ./check.sh: 172 files, 2498 tests, CHECK EXIT 0
- e2e 25: ALL PASSED, 30 checks. New check in 25g deletes a row via
a real right-click and restores it with all three keys via Undo.
- No counter-probe. No check covers the right-click into a cell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The timeline offered X and Y as position rows but never Z. The list
of position rows was simply written without it when animations came
in with #97. A swinging weapon could therefore not go behind its
character.
- Z is the third position row, after X and Y, on every placed root
or object. A scene and a behavior still offer no position.
- Play and preview already read and write any position row by name,
so the one entry in POSITION_KEYS is all the code change.
- The draw order is sorted on every render, and play writes z back
into the scene each frame. A keyed Z therefore takes effect at
once. A blended 4.5 sits above 4 and below 5.
Tests:
- ./check.sh: 165 files, 2416 tests, CHECK EXIT 0.
- 3 new unit tests in tests/core/animation.test.ts: Z is offered
after X and Y and not on a behavior or scene, a running game moves
the object from behind to in front of another one, and play,
scene write-back and preview agree on z.
- e2e 25: ALL PASSED, 29 checks. The new check reads X, Y, Z as the
first rows of the row dialog. Check sum 1396 -> 1397.
- Counter-check: without z in the list all 3 new unit tests fail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A key could only ease quadratically, which makes quick moves like a
sword swing feel soft. Animators know the Penner curves from CSS,
GSAP and Unity, so a key now offers all of them.
- Ten curves (Sine, Quad, Cubic, Quart, Quint, Expo, Circ, Back,
Elastic, Bounce), each in, out and in-out, next to Linear and Step.
- The inspector shows two fields instead of one long list: Easing,
and Direction only when a curve is picked. Switching the curve
keeps the direction. Coming from Linear or Step starts at in-out.
- Old projects keep working. ease-in/out/in-out now read as Quad and
are still saved under that name. New curves save as cubic-out,
back-in-out and so on.
- Back and Elastic overshoot on purpose. A number property with min
or max is clamped in play and preview, so a Back in from scale 0
does not flip the sprite. trackWriteAt now takes the property
definition, and animatedKind* is renamed to animatedDef*.
- The timeline marker still shows only the direction.
- In-out is built for every curve from its in-curve and its mirror.
easings.net uses own constants for Back and Elastic in-out, so our
Back in-out winds up about 5 % instead of 10 %.
Tests:
- ./check.sh: 165 files, 2413 tests, CHECK EXIT 0.
- 12 new unit tests in tests/core/easing-curves.test.ts: round trip
of all 30 values, start/end/midpoint of every curve, reference
values, only Back and Elastic leave 0..1, clamping in play and
preview, unknown values are rejected.
- e2e 25: ALL PASSED, 28 checks. New 25b-ii (3 checks) clicks both
fields and undoes the four choices. Check sum 1393 -> 1396.
- Counter-checks, each fails its test: no clamping (2), Quad saved
as quad-* (2), mirrored in-out wrong (2), curve lookup with `in`
instead of Object.hasOwn (1), no Direction field (e2e 25).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
The suite spent 88% of its step time asleep: 564 s of steps, 496 s of
them in sleep(n) - fixed spans estimated by hand and then copied from
step to step. That is why it was slow, and why it flickered.
The signal is afterFrames(count, timeout) in ui/render.ts, exposed as
__gm.afterFrames(). It wakes the loop itself, because an editor that
draws nothing would never redeem a promise of a frame, and it gives up
after a timeout instead of hanging the run. settle() in the harness
waits two drawn frames (~32 ms) instead of 150-300 ms and falls back to
sleep(150) when the hook is missing (an old build): silently not
waiting would make every later check a coin toss.
Every action goes through it now - click, doubleClick, wheel, drag,
dragAsset, press, typeText, select, mutate, undo, redo - and with that
about 550 sleep() calls behind those actions became redundant, helpers
that are nothing but a mutate in a coat included. What a frame cannot
answer got its own condition instead: waitUntil(expr), play()/stop()
waiting on __gm.state.mode rather than on 300 ms, saved() for the
autosave (the two hand-written polling loops sampled at 300 ms for
something that arrives after a second), and showTab() as a real tool -
47 places called evaluate("__gm.showTab(...)") raw, and 24 of them
waited for nothing at all and read the old panel.
564 s -> 243 s of step time, the waiting share 88% -> 76%. Three kinds
of wait were kept, and each one bit when it was taken away: loading
bytes (textures, fonts), game time that a repaint cannot stand in for
(time scale, shader and particle clocks, the live values a running
graph carries), and the inertia the scroll step measures on purpose.
Two things the new speed uncovered, both real: 25-animation read the
frame menu unfiltered and had frozen an incomplete list (the CI failure
on c990dbc), and 33-render-on-change measured idleness while the
autosave of the step before was still painting - it waits for
saveStatus to leave dirty/saving now, so it measures that nothing
happens any more instead of that it waited long enough.
Particle age and shader time each read performance.now() off their own
library, and the world got the frame's delta straight from the ticker. Three
clocks, so a global time scale would have been three multiplications and a
pause three separate freezes.
core/time.ts holds one clock instead: it accumulates dt x scale per frame
rather than reading the wall clock, and Editor.tick hands the very step it
took to the play session, the editor run and the animation preview. Delta
time, particle age, shader time and that preview are the same quantity now,
so the scale is a single multiplication and a pause is that scale at 0 —
nothing underneath needs rebasing, because the clock itself stands still.
The Math.min(dt, 0.1) caps in PlaySession and EditorRun are gone: the cap
(MAX_FRAME) now sits on the raw frame before scaling, and capping twice would
make the world run slower than the particles at scale 2.
A pause gates the scale rather than overwriting it — effectiveScale() answers
0 while paused, and for anything that is not a positive finite number, while
gameClock.scale keeps its value. A slider later survives a pause that way;
the issue asks for none yet.
The clock goes back to zero at both ends of a run, so a run is reproducible
from second 0 and the scene view looks freshly loaded after Stop. Resetting
only on Stop would not do it: the editor clock runs on afterwards, and the
next Play would start at "seconds since the last Stop".
The trap in that: ParticleField keeps its origin as an absolute second of
this clock, so after a reset it lies in the future and every particle would
age backwards. One line in sync() starts such a field over, which covers the
scene, the game and the tab preview at once — and fixes an older bug of the
same shape, where releasing a particleTime() freeze left the preview empty
for good.
Found while measuring that: ParticlePreview stood at second 0.5 by computing
the clock reading that would mean 0.5 since the field started. Over 200000
random origins the round trip misses the last bit in 18.8% of them and shows
one particle too few in 9.4%, because floor(0.5 * 20) comes out 9. The check
had been flaky since it was written. The panel hands the field the age itself
now (ParticleField.hold), and the faked clock is gone.
833 checks (824 + 9), 931 unit tests (921 + 10).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI run 35 was red: the suite died after step 15 with `widget with text
"Container" not found`. Nothing in the editor broke — the tenth tab (#73) pushed
Container behind the `»` button at the test window's 1280 px, exactly the case
#84 was built for, and three steps still clicked the tab name directly.
openTab(t, tab) in tools/e2e/lib/scaffold.mjs asks tabRow() whether the tab is
hidden and goes through the menu when it is, so the fix lives in one place rather
than in every step that opens a tab. The LABELS record moves there as TAB_LABELS,
since both the helper and 14-overflow need it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A frame drawn straight from the sheet showed a hairline of the neighbouring
cell along any edge that fell inside a screen pixel: with antialias on, a
partly covered pixel is still evaluated at its centre, past the edge. On top
of that Pixi rewrites a Graphics fill texture from clamp-to-edge to repeat at
draw time (generateTextureFillMatrix.mjs), so the top row of a sheet read its
bottom row.
TextureLibrary.get(ref) now cuts the rectangle out of the loaded sheet with an
OffscreenCanvas on first use and uploads the cut (key file|x,y,w,h, kept for
the session). Address mode is mirror-repeat: Pixi only rewrites clamp-to-edge,
and within a texel of the border mirror behaves like clamp, for linear as well
as nearest. Loaded sheets get it too, since whole pictures wrapped at their own
borders the same way. viewport/textureFill.ts and its test are gone.
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>
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>