Three small things asked for in one issue. A scene came up wherever
the view happened to stand, and the inspector carried a button and a
list that are better reached somewhere else.
- A scene comes up framed as if Fit had been clicked: after a
project is loaded and when another scene is opened. From then on
the view belongs to the user.
- An empty scene has nothing to frame and stands at the origin, one
pixel per unit. A scene the running game loads is not framed anew.
- The Container tab is left out. It keeps the view of each container
as before (#171).
- The Unpack button and the two lines under it are gone from the
inspector of a placed container. Unpack container stays in the
right-click menu of the hierarchy and of the scene view.
- The corners of a polygon collider are no row in the inspector any
more. They are edited on the outline in the scene view. The new
schema option noRow keeps a property out of the inspector while it
stays a property.
Tests:
- Gate on melektaus-atx: 219 files, 3401 tests, CHECK EXIT 0.
- One new unit test in tests/core/polygon.test.ts: the corners are
a property of the collider and no row.
- Three new e2e checks: a loaded project is framed like Fit (module
03), an opened scene is (04), and after a real reload a click on
Fit moves nothing (09).
- Module 09 unpacks through the right-click menu now and checks that
the inspector offers no button.
- Full suite on melektaus-atx: 1695 checks, all pass. Smokes 24, 19
and 21, all pass.
- No counter-probe evaluated, and nothing looked at after the build.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two bodies could not be connected: no door on a hinge, no lamp on
a rope, no wheel on an axle. This is the first step of the issue,
the four joints without a motor. The slider and the motors follow
in a second step under the same issue.
- Joint is a new asset type, placed as a child of the object it
holds, like a collider or a rigidbody. "Add joint…" in the
object menu makes one or places one the project has. An object
takes as many as it needs.
- Where the joint sits is the point it holds its object at. A
hinge and a fixed joint have that one point only. A spring and a
rope end at a connected anchor on the other object, counted in
that object's own units, or in the scene's without one.
- The rows follow the type: length, stiffness and damping for a
spring, length for a rope, an angle limit for a hinge. Length 0
takes the distance the two ends were placed at. The limit counts
from how the two were placed.
- The connected object is an object reference in the schema of the
type. Pasting, containers and removing treat it as they treat
every reference since #258.
- shownFor is a new schema option: a row is shown while an enum of
the same bag holds one of the named values.
- The object needs a rigidbody, its own or one further up. Without
one the joint holds nothing and the console says so once.
- A connected object without a rigidbody is a moving anchor: the
joint has a kinematic body of its own that follows it.
- A connected object destroyed while playing makes the joint let go
with a hint. Removed in the editor the reference empties, so the
joint holds on to the scene. The issue asked for "inactive" there.
- "Add joint" ties two objects while the game runs, from a joint
asset as the template. Destroy on the joint lets go, there is no
Break joint node. Destroy with keep children takes the joints
along with the object.
- The scene view draws a line from each joint to what it holds on
to and rings the held point. A selected spring or rope has a
round handle on its far end, and dragging it is one undo step.
- Joints hold in Play only. The editor run simulates nothing
(#148), and the inspector of a placed joint says so.
- Rapier counts a revolute joint's limits absolutely, not from the
start, so the placed angle is added. Measured with a probe.
- The + menu has 25 types now and no longer fits under its button
on a canvas 800 high. It scrolls there, and e2e 04 scrolls to
the last entry instead of expecting it in view.
Tests:
- Gate on melektaus-atx for this state: 218 files, 3390 tests
(3336 + 54), exit 0.
- e2e 1-16,24,39-46,51,52,57,58 on melektaus-atx: 831 checks, all
pass. Module 41 alone: 25 checks (21 + 4).
- 54 new unit tests: 40 in joints.test.ts (rope, spring, pendulum,
limit, fixed, the other end, the node, containers, paste), 6 for
the line the scene view draws, 4 catalog cases of the node. The
remaining 4 come from tests that make a case per asset type.
- Four new e2e checks in module 41: Add joint from the menu, the
rope to the hook with its line and handle, dragging the handle
with one undo, the bob hanging in Play next to one that falls.
- e2e 04, 40 and 46 count the new type in their lists.
- 21 of 22 unit counter-probes fail their tests. The other one is
equivalent: Rapier ignores removing a joint that left with its
body, the guard stays.
- Three browser counter-probes fail their checks (2, 1 and 2).
- One screenshot looked at: icon, line, handle and the rope rows.
- The e2e sum of 1691 is counted (1687 + 4, 1687 measured by CI
on e066613). The full suite and the smokes did not run.
- Not seen in the browser: spring, hinge with limit and fixed
joint, the node Add joint, a connected object that moves, joints
inside a container, the released game. Unit tests cover them
but the last. Limits under a mirrored parent are not handled.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the WSL runner every __gm.pixelAt costs about 7 ms, three times as
much as on stein2. Each call renders the whole stage into a fresh
render texture and stalls until the GPU hands one pixel back. pixelRow
and several checks read hundreds of pixels that way. Module 05 spent
36.8 of its 40 s on these reads, module 04 11.3 of 20 s.
- stagePixels in src/ui/render.ts renders once around all points and
reads them in one go. Only whole pixels at resolution 1 take that
path. Anything else stays point by point.
- The hook __gm.pixels(points) exposes it, and pixelRow uses it.
- The loops in e2e 04, 05, 14 and 44 read their grids in one call.
- The old comment in pixelRow said a wider frame comes back resampled.
That is not true. The colours match exactly, so the comment is gone.
Measured on the RTX (d3d12) and on stein2 (Intel, Vulkan): diff 0 at
10 680 points on each GPU. One screen row takes 4 to 18 ms instead of
2.4 to 9.9 s. The six affected modules need 32 s instead of 139 s
under WSL, faster than on stein2.
The rest of the gap is in Chromium's GPU process. perf puts three
quarters of its time in the drivers (NVIDIA D3D12, Mesa d3d12, dxg).
Four browser and Mesa settings were measured, none helped.
Tests:
- ./check.sh on melektaus-atx: 195 files, 2827 tests, CHECK EXIT 0 (+4)
- e2e 1-16,30,37,44,58 under WSL: ALL PASSED, 587 checks
- counter-check: a wrong index in stagePixels fails the unit test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An inventory grid could not scroll, because nothing could cut a child
off at an edge. A game also had no way to hear the mouse wheel. Both
are needed for a scrolling list, a dialog text or a window with more
content than room.
- A new object type Clip area has width and height and turns, scales
and mirrors with its parent. The size gizmo drags width and height.
- Everything below a clip area, on every level, is drawn only inside
its rectangle, in the Scene view as in the game. Nested areas cut to
their intersection. The rule works on a convex polygon, so a turned
area cuts right as well.
- On click, Is hovered and On mouse wheel do not hit a cut-off part.
The Scene view does not pick it either.
- In the Scene view the area is a dashed frame that is grabbed only at
its edge, so a click inside lands on the content. The eye under
Shape hides the frame. In the game the area is invisible.
- While an object below an area or one of its parents is selected, the
Scene view shows the cut-off part at 30 % opacity and it can be
grabbed there. The rule lives in core/properties/clip.ts.
- The Properties node knows the type Clip area.
- The new event node On mouse wheel fires by the rule of On click. Its
delta counts in notches, positive is down, and a frame's turns
arrive as one value. It also gives x and y.
Limits:
- Only sprites and texts show the faded rest. Tilemaps and particles
are cut and can be grabbed when open, but are not shown faded.
- Masks by texture shape are out, as noted in the issue.
Tests:
- ./check.sh: 2773 tests, CHECK EXIT 0.
- Full e2e run (local): ALL PASSED, 1549 checks, measured.
- tests/core/clip-areas.test.ts: 12 tests for the cut rule, scene and
game, save and load, hits, the wheel with and without owner, the
notch conversion and the open rule.
- e2e 58, 5 checks with real mouse and wheel: the scene cuts (74 of 74
red inside, 0 outside), a click on the cut part picks nothing, the
selected rest is faded and grabbable, the game cuts, one notch
logs 1.
- e2e 04 and 46 count the object types and the eye rows. They now
include Clip area.
- Counter-checks: 6 in unit tests and 3 in the browser, all fail as
expected. The originals were restored and checked with cmp.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A struct used to declare its fields wherever it stood. The same shape
in two variables meant typing it twice, and Pack/Expand copied it once
without staying in sync. A Struct asset now holds the shape in one
place, and every struct that picks it as its type follows it.
- New asset type "Struct" (not placeable, group Structs, own icon).
The inspector shows its fields with name, kind and default value,
and "Open" shows them in the variables tab.
- Struct variables and struct fields get a Type row: own fields, any
Struct asset, or "Make struct type". With a type the fields come
from the asset and cannot be added, renamed or removed there. The
values stay per variable, overrides on placed objects still work.
- Pack and Expand get the setting Type. With a type their ports are
the fields of the asset and read-only in the inspector.
- A change to the asset reaches every typed place in one undo step
(syncStructTypes). A new field arrives with its default. A removed
field goes along with its port, edge and typed value. A renamed
field keeps its value in variables, overrides and on ports with
edges, also through nested types.
- Undo for these edits records plain assignments (Recorder), because
the reach of a change is not known in advance.
- Cycles such as Loot in Loot, also through another type, are refused
and not offered in the picker.
- Deleting the asset names how many places use it. Afterwards each
place keeps its fields as its own.
- Loading reshapes a stale copy in a project file to its type.
- "Make struct type" creates an asset from an existing struct and
links the place to it.
Differs from the plan:
- A changed default value applies to new fields and newly typed
places only. Existing values stay.
- Removing a single field shows no warning. Undo brings it back.
Typed lists are left for #254. Lists stay dynamic for now.
Found by e2e: after deleting or renaming the type the inspector did
not rebuild and kept showing the old name. Fixed, the check was red
before.
Tests:
- ./check.sh: 182 files, 2661 tests, CHECK EXIT 0
- GM_STEPS=1,4,21,22,54: ALL PASSED, 176 checks
- 14 unit tests in tests/core/struct-types.test.ts
- New e2e module 54 with 14 checks, real mouse. Module 04 counts
23 asset types.
- Counter-probes: without the rename pass 4 unit tests fail, without
reshaping values 8, without the cycle rule 1. In the browser,
without spreading changes and with + Field despite a type, 3
checks fail.
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>
New assets could only be added through the + button. A right-click
on a category heading or on the empty space below the rows now opens
the same menu at the pointer.
- ListView gets the callback onListMenu. It fires on a right-click
on empty space or on a heading. Only the asset panel has headings,
so other lists are not affected.
- AssetPanel opens its + menu from there.
- A right-click on a heading does not fold or unfold the category.
A right-click on empty space clears the selection like a left-click.
- A right-click on an asset still opens the menu of that asset.
- The empty list now says "No assets - press + or right-click".
Tests:
- ./check.sh: 178 files, 2573 tests, CHECK EXIT 0
- e2e 1-4: ALL PASSED, 134 checks
- 3 new checks in e2e 04 with real right-clicks: menu on the
"Scenes" heading (21 types, heading stays folded), menu on empty
space, and "Behavior" from that menu creates one, undone again.
- Counter-probe: without the menu both right-click checks fail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The theme asks for Source Sans 3 at weight 500, but fonts.ts only
loaded 400, 600 and 700. The browser synthesised the 500 text. The
issue comment also asked for the symbols the font lacks to become SVG
icons. A scan of src/ against the font's cmap found 20 such characters
in 30 places.
- fonts.ts loads source-sans-3-latin-500-normal.woff2 as well.
- Menus draw their ticks and the "current" dot as icons. The dot is a
new MenuItem.current field and no longer part of the label text.
- Inspector action buttons (reset, apply, fit width/height) are
icons. The menu arrow behind them is the drawn Dropdown chevron.
- Kind marks come from a table in markIcons.ts. Picture kinds use an
icon, # and Aa stay text because the font has them.
- Scene marks in the asset panel, sound play/stop and the transition
arrows are icons too.
- Apply is an icon although the font has the arrow. It sits next to
the reset icon and would look different as a glyph.
- A new Icon widget draws on redraw like Button, so Node tests need no
DOMParser.
- Tests find icons through the SVG id. The new hook
__gm.locateIcons(id) and the harness helpers findIcon/clickIcon
replace locate() on the old characters.
Findings:
- Weight 700 is loaded but the theme never uses it. Left as is.
- The old scene-mark check in module 35 accepted any play glyph on
screen. It now checks the mark on the start scene's row.
- Module 44 needs the textures from module 37 and fails without it.
Tests:
- ./check.sh: 145 files, 2187 tests, exit 0 (before 144 / 2143).
- New tests/ui/fonts.test.ts: every theme weight is loaded, and every
character in src/ exists in every shipped face. The cmap reader
matches fontTools (231 characters).
- Icon tests cover the 19 new files. Each kind mark is either an
icon or text, never both.
- e2e 1-16,22,30,34,35,43,44: 586 green, 0 FAIL, then 44 stopped
for lack of module 37. e2e 1-16,37,44,47,48: 618 green.
e2e 1-14 on the final build: 454 green.
- Two new e2e checks: loaded weights in 14, a reset icon pixel in 04.
- Counter-probes all fail as expected: 500 removed, a play glyph put
back, the struct icon removed (one unit test each). In the browser,
500 removed and reset icon hidden fail exactly the two new checks.
- Editor-suite check sum 1312 (1310 measured by CI at 056a622, +2).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI has been red since 66d6aaf (tasks 221 and 223, two failures each).
Both failures come from the tests added with #130, not from the
editor.
- Module 04 still expected 19 asset types in the + menu. Pixel art
and Palette make it 21.
- Module 48 died with "reading 'assets'". The password change in
module 45 ends every other session of the e2e account, including
the one the tools had cached, so api() got a 401. api() now drops
the cached session and signs in once more on a 401.
Tests:
- GM_STEPS=4,45,48 (fresh seed): ALL PASSED, 102 checks.
- Counter-check: without the retry module 48 dies with exactly the
CI error.
Co-Authored-By: Claude Opus 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.
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>
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>
- `.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>
The inspector already marked an asset value that differs from its schema
default, but the Assets panel gave no hint, so a changed asset looked
like an untouched one until you opened it.
The rule sits next to the existing objectDeviates as a pure function:
assetDeviates asks whether any marked property differs, markedAssetSchema
says which rows the inspector marks at all. buildAssetSection now builds
its rows from that same list, replacing the behavior/rest split it used
to carry, so the list cannot claim a deviation the inspector then fails
to show.
A behavior's variables are deliberately not in it. Their default is
declared on the variable itself, which means the asset value *is* that
default; counting them would paint the fixture's Mover gold while the
inspector beside it marks nothing.
Pixi only fills `detail` on the `click` event it synthesises; on
`pointerdown` it is always 0, so every `e.detail === 2` branch was dead
code. Asset rows, hierarchy folding and select-all in text fields never
responded to a double-click.
A shared click counter in src/ui/clicks.ts replaces the three readings of
`e.detail`. A finished double-click ends the run, so two in a row count
2 and 2 rather than 3 and 4.
The harness could only send `clickCount: 1`, which is why no test caught
this: every "activate" check went through Enter. It can send real
double-clicks now, and five checks drive the three paths separately.
The context menu entry is gone; dragging replaces it entirely. The drop
target is the view under the pointer, not the active tab, so the same
gesture works for scenes and for container templates.
While dragging, an outline shows the footprint the object will have plus
a crosshair on its origin, turning red when the drop would be refused.
Preview and command share one function (placementRejection), so the
outline cannot promise something the drop then rejects.
A left-press used to select the list row immediately. Since the container
tab follows the selection, it switched to the very container being dragged
and every drop would have nested it inside itself. The list now selects on
release without a drag: picking something up is not the same as choosing it.
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>
Images are uploaded to a content-addressed blob endpoint and stored as
data/textures/<sha256>.<ext>; the project only keeps the file name. The
sprite points at a texture asset through an ordinary property of the new
kind `asset`, so instance overrides, "Apply to container", "Reset to
asset", undo and `Set property` work without any extra code. The new kind
also replaces the ad-hoc asset picker the graph nodes faked as an enum.
Import goes through + -> Texture, drag & drop onto the canvas or Ctrl+V.
Images wider than 2048px are downscaled client-side and re-encoded to
WebP if needed, because nginx caps uploads at 1MB; an image that already
fits is uploaded byte for byte so pixel art is never resampled.
The sprite colour default changes from red to white, since colour tints
the texture multiplicatively and a tint of white shows an image as it is.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tooling:
- Prettier as the single formatting authority (110 columns, single quotes,
measured against the existing style); Markdown excluded because table-cell
padding turns the README glossary into 1000-character lines.
- Type-aware ESLint ending in eslint-config-prettier, so a rule always means
"this is a bug", never "this looks different".
- .editorconfig and an `npm run check` that runs typecheck, lint, format and
tests in one go.
Findings fixed on the first run: three more duplicate imports, four
String(unknown) calls (parameter types narrowed instead of casts), two dead
initialisers, two redundant casts, a console.log for a real error. Two
warnings were false here and are disabled with a reason in place.
Tests 132 -> 269:
- node-catalog.test.ts covers every node with a run(), with a guard that fails
when a new node has no row.
- behavior-events.test.ts covers the ten event nodes through a running World,
checking timing rather than existence.
- Input's state machine (press/release/releaseAll) is reachable without DOM
events now; the DOM handlers are thin adapters.
Compare fell back to == without a configured operator although the schema
default is <; aligned.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>