At 125% Windows scaling the game view drew 100% as 1.25 screen pixels
per camera pixel, so pixel art showed texels of uneven width even at
the round steps. The steps now count device pixels instead of CSS
pixels.
- gameScaleFactor, cameraFactor and cameraView take the device pixel
ratio. 100% is one screen pixel per camera pixel, 200% two.
- Auto picks the largest step that fits in device pixels. Fit reports
its factor in device pixels too, e.g. 156% at 125% scaling.
- Fixed steps start the frame on a whole device pixel.
- The game view reads the ratio from the renderer resolution.
Limits: main.ts caps the resolution at 2, so scaling above 200% is
stretched by the browser again. A changed ratio at runtime only counts
after a reload, as before.
Tests:
- 2 unit tests at 125%: 100% is one device pixel on a whole pixel,
auto and fit count device pixels.
- Counter-check: without the conversion the 100% test fails.
- check.sh: 156 files, CHECK EXIT 0 on the combined working tree.
- No e2e: the harness runs at devicePixelRatio 1, same math as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The game view always stretched the camera picture to fit the pane.
Pixel art came out blurred at odd factors, and there was no way to
see the game at its real size. The view now has a header with a
scale drop-down, and 100 % is the default.
- The header of the new GamePane offers Auto, Fit, 25 %, 50 %,
100 %, 200 % and 400 %. Auto and Fit show the factor they land
on, e.g. "Auto · 100%", and follow the pane when it resizes.
- 100 % means one screen pixel per pixel of the camera resolution
(width x height). The camera zoom scales the world on top of
that, so a zoomed camera still fills the same frame.
- Auto takes the largest fixed step that fits the whole picture
and falls back to 25 % when nothing fits. Fit keeps the old
behaviour.
- A picture larger than the pane is cropped around the camera
centre. Fixed steps start on a whole pixel so pixel art stays
sharp at 100 %.
- The choice is kept in the browser like "Play starts". Nothing
is written into the project.
Found on the way: the first draft applied the step to the world
instead of the camera resolution. The camera zoom then had no
effect at 100 %, which the existing check 12c caught (zoom 1.37
measured as scale 1).
The CI smokes were not run locally, because that would reseed
projects the user works in. All three smoke cameras are 640x360
and fit the 670x383 harness view at 100 %.
Tests:
- ./check.sh: 156 files, 2298 tests, CHECK EXIT 0 (before 2290).
- 8 unit tests in tests/viewport/view.test.ts for whole-pixel
steps, cropping, Auto and Fit with fallback, and zoomed cameras.
- 7 e2e checks in 03g. e2e 1-17,28,30,38-40: ALL PASSED, 727
checks.
- Counter-checks: no cropping, no rounding, no factor reported to
the header, and no zoom share each fail their own tests.
- Check total: 1367 (CI at c1a702b) + 7 = 1374, computed only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The scene view drew its symbols from its own SVGs, each in a type
colour and some on a plate. They looked heavier than the panel icons
and were hard to read on busy ground. Symbols of different objects
also sat on top of each other whenever their positions met.
- Every symbol is now the panel icon of its kind, one file for both
places. The scene-only SVGs in src/viewport/icons are gone.
- Symbols are white in a thin dark outline with no plate behind them.
haloSvg widens each shape of the file by its own stroke and draws
that silhouette dark under the icon, so no second file is needed.
- A butt stroke end would leave its end without outline. The halo caps
such strokes square, and the rigidbody arrow shaft is now a filled
rect so its top edge is outlined exactly.
- Symbols are 18 world units across, stay upright and are clickable.
A click selects the object, a drag moves it.
- symbolLayout places every symbol on its object's position. Symbols
that overlap stand on a ring round the middle of their positions,
evenly spaced one icon apart. A ring that reaches into another
symbol takes it in until nothing overlaps. Ownership plays no part.
- Names sit beside their symbol, on the outside of a ring. Selection
frames and parent links follow the symbol.
- A collider icon no longer shows trigger or invalid state. The
outline colour of the shape still does.
- A lone collider sits on its sprite's middle again. A press there on
an unselected sprite drags the collider, so e2e 02 and 16 now grab
the sprite beside its middle. Hook __gm.container.objectRect helps
16 find that spot.
Tests:
- check.sh: 2245 tests, CHECK EXIT 0 (+8 for layout and halo)
- e2e 1-16,31,39-43: 670 PASS, 2 FAIL. Both were sampling too
strictly for the thinner, smaller icons. 5c looked for the exact
outline colour, which the thin outline only reaches blended at this
zoom. 31n started the ring scan one pixel past the smaller ring.
Both are fixed, and e2e 5,31 then passed with 48 checks.
- 5c checks white with an outline and none of the old colours, 41b
checks the ring positions and that a click on a symbol selects its
object, 31n checks the now white particle ring.
- Counter-checks: without the halo only 5c fails, also with the looser
outline match. Without the layout the ring and click checks in 41b
fail. Originals restored by cmp.
- The e2e check total stays 1351.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Colliders were the only placed part without a symbol in the scene
view. A collider that sits exactly on its sprite was hard to find and
could only be picked by its outline.
- The symbol is the box with four corner handles from the asset
panel. It stays upright and has a fixed size in world units.
- colliderColor() holds the colour rule of the outline (green,
trigger colour, red for a shape that collides with nothing). The
outline and the symbol share it.
- The symbol sits 28 units left of the collider centre. The sprite
centre stays free for grabbing the sprite, and a rigidbody symbol
on the same spot is not covered.
- The symbol has its own 26x26 hit area and the outline's handler.
A click selects the collider and a drag moves it. sceneStack counts
it as a hit under the pointer.
- The hook __gm.sceneIcon(id) also returns x, y, color, screen and
clickable.
Findings:
- With the clickable symbol on the sprite centre, four checks in 01
and 09 dragged the collider instead of the crate. That is why the
symbol moved beside the centre.
Tests:
- ./check.sh: 153 files, 2237 tests, CHECK EXIT 0 (was 152 / 2234).
- New unit test for colliderColor. svg-icons.test.ts lists the
collider symbol.
- e2e 41b: 3 new checks (beside the centre without and with a
rigidbody, click selects the collider).
- e2e 1-16,39-43: ALL PASSED, 635 checks. e2e 41 alone: 20 checks.
- Counter-probes: no symbol fails both placement checks. A symbol
without hit area fails only the click check.
- Check total 1348 + 3 = 1351, computed, not yet measured by CI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Recurring cleanup round. Behaviour is unchanged. Duplicated code in
src went from 27 clones with 299 lines to 6 clones with 56 lines
(jscpd, the two identical OFL licence texts not counted).
- SheetCanvas is the new base of the texture, pixel art and vector art
canvases. It holds background, mask, hint, panning, wheel zoom, fit,
view save and restore and the screen mapping. Pixel art keeps its
whole-pixel panning and fixed zoom steps.
- mixColor in src/core/util/color.ts replaces three copies in
animation, gradient and particles. It clamps t like the particle one.
- fingerprint now lives once in generated.ts, next to the revision
contract from #185.
- MoveCommand next to RenameCommand is the base of the three reorder
commands for tile pictures, tilemap layers and vector shapes.
- The two raycast nodes share inputs, settings and filter via rayOf.
- canvasRect() lives once in AssetTabEditor.
- Removed unused code: findPixelArt, isDrawableShape,
GeneratedPictures.has and SnapToggle.snapping. Tests now check the
same things through equivalent paths.
- 38 values used only in their own file lost their export.
- Three comments that told history now describe the current state.
The bundle stayed md5-identical for those edits.
- README lists vector art, pixel art and palette, and links the two
wiki pages.
Findings:
- PaintTilesCommand.changes is meant to prevent empty undo steps but
has not been called since #128. The rectangle tool therefore records
a no-op undo step when it paints the same tile over itself. Left as
is, because fixing it changes behaviour.
- No test covered middle-click panning in any sheet tab before. 13f
and 48a now check it.
- The default values of the ray inputs were untested. Length 1000
could become 10 without a red test. tests/core/raycast.test.ts now
covers it.
- CI measured 1308 editor checks at d7c0b7c, not the 1307 counted for
#164. The sum is now 1310 (1308 + 2), computed.
Tests:
- ./check.sh: 144 files, 2143 tests, exit 0 (before 2142).
- e2e 1-16,37,39-43,47,48 fresh: ALL PASSED, 729 checks.
- e2e 1-13,47,48 with the new checks: ALL PASSED, 518 checks.
- Counter-checks, each restored via cp and cmp: halved panning fails
both new pan checks; unclamped mix fails 1 test, no blue channel 3;
coarser hash 3; broken reorder undo 2; triggers setting forced on 2;
bent ray length 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The object menu lived only on the hierarchy rows. Now a right-click in
the scene view opens the same menu for the object under the cursor.
- The menu moves out of HierarchyPanel into src/editor/objectMenu.ts.
Hierarchy and scene view call the same function, so their entries
cannot drift apart. Only the hierarchy adds "Expand all" and
"Collapse all".
- Which object the menu is for follows the press rule from #203. A
selected object under the cursor, even a hidden one, keeps the
selection and the menu acts on all of it. Otherwise the topmost
object is selected first.
- The rule is the pure function menuTargetOf in src/viewport/pick.ts,
next to pressOn.
- Empty space, including an empty tilemap cell, opens no menu.
- The container tab gets the same, because ContainerCanvas extends the
scene view.
Tests:
- ./check.sh: 142 files, 2094 tests, exit 0 (was 2090)
- e2e 39,40 fresh: ALL PASSED, 80 checks
- e2e 1-16,39,40 fresh: ALL PASSED, 565 checks
- New block 40r (3 checks): menu on a hidden selected crate, topmost
object without a selection, no menu on an empty cell.
- Counter-check: a right-click that always takes the topmost object
fails 2 unit tests and the hidden-crate e2e check.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pixi's own hit test decided what a click in the scene view selected.
A tilemap caught clicks over its whole area, even on empty cells and
over sprites sorted above one of its layers. A press always grabbed
the topmost object, so a hidden selected object could not be dragged,
and there was no way to reach an object underneath another one.
- New pure rule in src/viewport/pick.ts. It lists the objects under
the cursor front to back, in the order they are drawn.
- A tilemap counts only where it has a tile. Its depth there is the Z
of the highest layer with a tile at that spot. An empty cell is
empty space, so a click clears the selection or starts a marquee.
- Pressing on any selected object, hidden ones included, drags the
selection. Otherwise the press selects the topmost object and drags
it, as before.
- A click without drag on a selected object selects the next one
underneath. After the lowest it starts again at the top.
- New debug hook __gm.sceneStack(x, y) returns that list.
Decisions:
- With a paint tool the selected tilemap paints anywhere on its grid,
even under a sprite. With empty cells as empty space an empty cell
could not be painted otherwise.
- Icons such as colliders and cameras stay in front. A second click
now reaches the sprite under a collider.
Tests:
- ./check.sh: 142 files, 2090 tests, exit 0 (was 141 / 2080)
- 10 new unit tests in tests/viewport/pick.test.ts
- GM_STEPS=39,40: ALL PASSED, 77 checks
- GM_STEPS=1-16,29,39-43: ALL PASSED, 626 checks
- 4 new checks in block 40q: sprite over a lower layer, empty cell,
cycling crate, map, crate, dragging the hidden selected crate.
- Counter test: whole-area tilemap hits and always-topmost presses
fail 4 unit tests and all 4 new checks.
- Check total 1290 (1286 + 4), computed, not yet counted by CI.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
After signing out and back in, a texture fill stayed in its base
colour until something else redrew the vector tab. The texture
library is a singleton that outlives the editor. Four widgets
subscribed to it and never unsubscribed. After a new sign-in the
first loaded texture called a destroyed canvas, which threw and
stopped the loop before the vector tab heard about the image.
- TextureCanvas, TileCanvas, ShaderPreview and WorldViewport now
unsubscribe in destroy(). This is the pattern FontPreview and
VectorArtCanvas already use. WorldViewport leaves both the
texture and the font library.
- FileLibrary.notifyChange catches errors per listener. A broken
listener no longer starves the others. The error is still
logged as "[textures] a listener failed".
- e2e 47i no longer forces a redraw. It waits up to 3 s for the
tab to show the texture on its own.
Findings:
- The trigger was e2e module 45 (sign out, sign in), not the
texture fill. My note in #196 that the browser showed no
exception was wrong. The counter-check shows it clearly.
Tests:
- ./check.sh: 137 files, 2051 tests, exit 0 (before 136 / 2049)
- 2 new unit tests in tests/viewport/file-library.test.ts
- GM_STEPS=45,47 after a fresh seed: ALL PASSED, 71 checks
- Counter-check with the old source and the new check: 1 FAIL
(shape stays #0000ff) plus the TypeError. Old FileLibrary.ts
fails the first unit test.
- No full e2e run locally. The check count is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A sliced frame drew its borders one source pixel to one sprite unit. Pixel-art sheets are small:
a 16px corner on a 400-unit button is a thin line, and the only way around it was a larger image.
The texture now carries `Nine-slice scale` (default 1), next to `Filter`. It sets how many sprite
units one border pixel takes, for every sliced frame of that texture. The UVs stay as they are -
a corner shows the same pixels, only larger or smaller. When the sprite is too small for both
borders they are still scaled down together, now starting from the scaled size. With `Repeat`
the tile size scales too, otherwise a doubled border would sit beside a pattern of the old size.
The value is a schema row, so inspector field, saving and undo come without their own code.
Anything that is not a size above 0 reads as 1, and documents without it draw as before.
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>
Sounds follow the road fonts already paved: the file goes to the server
content-addressed under its sha256, the project keeps a named asset that
points at it, and nothing is ever re-encoded. The format is read from the
file's own signature, not from what the browser claims - the same .m4a
arrives as audio/mp4, audio/x-m4a or as nothing at all.
Playing is two nodes. "Play sound" takes the asset and may raise or lower
its volume; every call is its own voice, so two jumps in a row are two
jumps heard. Its loop input is a choice of three - from the asset, on,
off - because a checkbox has to spend one of its two positions on "not
set" and would lose a case: an asset that loops could then never be
played once.
Below the graph core stays browser-free: NodeContext hands out an asset
id, BehaviorSystem resolves it against the project and folds the asset's
own volume and loop into the call, and only viewport/SoundLibrary knows
about Web Audio. A run without a sink plays nothing instead of throwing.
The CI browser is muted, so the ear is no witness: the debug hook counts
started and stopped voices, and step 34 reads that counter.
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>
The game tab held a "Press Play" hint where the picture belongs. It now
previews the scene through the active camera, and says "No active camera"
when none is switched on.
Camera choice and its world position moved into activeCamera.ts so both
are testable without Pixi. Entity.x/.y are parent-relative; the viewport
used them as the camera's spot, which framed a parented camera at the
wrong place.
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>
A new Texture tab next to Scene/Game/Graph shows a texture sheet and lets
frames be drawn on it: drag to create, move, resize via eight handles, edit
name and rect in the inspector, Delete to remove. Every change is a command,
so one gesture is one undo step. "Slice..." cuts a grid from columns and rows
with optional offset and spacing, previewing the resulting frame count and
cell size live.
Sprites pick a frame instead of the whole texture through the same `texture`
property: it now holds either a texture id or a frame id. Frame ids come from
the same generator and are document-wide unique, so one picker, one property
and one set of instance overrides cover both cases, and every sprite written
before this still means the same thing.
A Graphics fill ignores a Pixi sub-texture's frame and draws the whole image,
because it derives its uvs from the shape bounds and the inverted style
matrix. The cutout is therefore a matrix (`viewport/textureFill.ts`), which
keeps a sheet at a single GPU upload no matter how many of its frames are on
screen at once.
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>