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.
Three things the graph view did differently from the scene view, or not
at all:
Panning moves to the middle button, which works from anywhere – a node
under the pointer included, since NodeView now lets that one button
bubble up. Shift-drag on empty canvas pans too. The left button pulls a
rubber band instead and no longer pans.
Nodes can be selected several at a time: by band, by ctrl/shift-click,
moved together, deleted together, each as one undo step. The selection
model gains `nodeIds` alongside the objects' `objectIds`.
Fit really fits. It used to pin the zoom at 1 and merely centre, so a
graph larger than the view always left nodes hanging out, and it sized
every node with one guessed height although real ones vary with their
port count. Both sums now live in editor/graph/framing.ts as pure
functions, which is what made the defect testable at all.
The band selects any node it overlaps rather than one whose position it
contains, unlike the scene view: nodes are large rectangles, and a band
drawn visibly across one has to catch it.
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.
An instance edit was dropped as soon as its value matched the template
object. With nested containers the template is not what the value falls
back to: the outer container holds an edit for the object below it, and
that one wins. If the inner template happened to carry the same value the
user typed, the edit was thrown away and the value jumped to whatever the
container in between says.
Reported against the Jump-and-Run project: a coin narrowed to 8 inside
"level", then set back to the sprite's own 24 in the scene, snapped back
to 8. Reproduced through the inspector before touching anything.
InstanceTarget now offers `base`: the template with the edits of every
container in between woven in. Everything that judges deviation uses it -
setOverride/setName/setPosition, both Revert commands, and the gold marks
in the inspector. Writing still goes into the real template, which is
where "apply to container" belongs.
`base` is built on every read. Apply writes into the template and then
asks whether the edit still deviates, so a base captured beforehand would
answer for the template as it was - the e2e suite caught exactly that.
Also in here, because it corrects the commit before it: placing an asset
by double-clicking its row is gone, as asked for in asset-doppelklick.md.
Dragging is the only way to place now. That took AssetPanel.place, the
PlacementTarget interface, Editor.placementTarget and the two worldCenter()
methods that existed only to feed it. A double-click on a scene asset still
makes it the active scene.
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.
Dragging an edge now rings the port it would connect to and snaps the line
there. A drop on a node's body takes that node's first free compatible port,
or the last compatible one when none is free, replacing its link. A drop into
empty space opens the palette filtered to the node types the edge could reach
at all, and the picked node arrives wired - node and link as one undo step,
so a single undo takes back the whole gesture.
The decision of what fits what lives in core/graph/dropTarget.ts as pure
functions, tested directly; the canvas only draws the answer. The palette
takes an optional filter rather than knowing about drags.
Verified remote on the Windows box (GPU): ALL PASSED, 284 checks.
An inspector label whose value deviates from its base was drawn bold, which is
easy to miss next to the regular labels and says nothing in the hierarchy, where
a folded subtree could hide a change entirely.
The colour now lives as a theme token (`override`, #f2c84b) used by both the
inspector labels and the hierarchy rows. Whether a row deviates is decided by
`src/core/properties/deviation.ts`: `objectDeviates` compares an object against
what it follows -- the container template for content, the asset otherwise --
and `deviationMarks` carries the mark up to every ancestor, so a folded subtree
still shows that something inside was changed.
`deviationMarks` takes the stored holder, not the resolved one: in the resolved
view container content looks like an ordinary object and `instanceTargetOf`
would compare against the asset, marking every instance.
Covered by 3 unit tests and 5 e2e checks, including one that strips an object's
own overrides to prove the row stays gold for a deviating child and restores
them byte for byte. Full suite remote on the RTX 4070: 275 checks, all passed.
`decimals` only reached `formatNumber`, so it changed the label and nothing else: typing 640.5 into
a field with `decimals: 0` stored 640.5 and displayed 641. The two existing users got away with it
because rounding happened outside the widget (`normalizeFrameRect`, the slice dialog), which their
comments had to point out.
`NumberField.clampValue` now rounds before it clamps, and every path that sets a value goes through
it (typing, scrubbing, arrow keys, the constructor), so the stored number matches the shown one and
`min`/`max` stay exact. The rounding is `roundTo` in math.ts, which `snapToStep` already did inline.
With that safe, camera `View width`/`View height` and text `Font size` become integers – they were
showing 640.000 and 32.000. Everything else that looked integral is not: positions, sprite and
collider sizes and zoom are all scrubbed, and `z` is a summed coordinate (walk.ts), not a layer.
Verified remotely against the RTX 4070 host: 270 checks, all passed (step 10 gained a check that the
field text has no decimal point and survives a reopen). `npm run check` green, 407 tests.
The aspect button now carries two offers, so it becomes a menu: fit one
side to the texture ratio, or set both sides to the exact pixel size as a
single undo step.
Dropping a row on the outer quarter of another row inserts it into that
gap instead of parenting into it, reordering among the siblings there.
Order is the stored order, so ReorderObjectCommand is a splice; equal z
falls back to tree order, which makes this the way to draw one object in
front of another. Rejected on container content like the other structural
edits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two regressions from the template-resolution rework, plus the harness gap
that hid them:
- `instanceTargetOf` decided from the shape of the id. Unpacking keeps the
derived ids, so unpacked content still counted as template content. It now
asks whether the object is spelled out in the holder.
- The parent picker captured its holder when the row was built. Since
`viewOf()` returns a derived copy, the menu listed the objects of that
moment (measured: 40 in the model, 17 in the menu). It resolves on open.
- `evaluate()` swallowed page exceptions, so a mutate that died half-way let
every later check pass or fail for the wrong reason.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A scene now stores only the container object; its content is resolved from
the template on every read, with deviations kept as sparse instanceEdits on
the container object. This drops the propagation bookkeeping entirely --
templateSync.ts is gone and the three structural gaps (add, remove, reparent)
cannot diverge any more.
Values stay editable on an instance, structure does not; Unpack writes the
content into the scene, keeping ids. Play mode bakes containers into the
throwaway copy before entities are built. No migration: formatVersion is 2
and the seeders write the new format.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Container tab reuses SceneViewport against any object holder, so a
container template is edited exactly like a scene. Right-click no longer
changes the selection, which previously made nesting impossible: opening
the menu on a container loaded it, and "Place in container" then targeted
itself.
Template edits (move, rename, override) now reach already-placed instances
per slot, through a shared templateSync used by both the apply path and the
plain object commands. Copies the user has touched keep their own value.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add ↔ next to Width and ↕ next to Height in the inspector. Each sets its
field so the sprite matches the ratio of its assigned texture or frame,
reading the frame's own rect rather than the sheet's.
The buttons reuse the existing FieldActions convention, so they simply
do not appear when no texture is assigned, when the sprite is already
proportional, or on assets whose schema has no texture property.
Comparison is a cross product with a relative tolerance: 3135 of the
40000 size pairs up to 200x200 do not survive a round trip through a
division, so an exact check would leave the button standing after its
own click.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Remove comments that only restate the code, keeping the section banners
and the notes that carry a reason, a measured fact or an external
contract.
ButtonOptions.width claimed to be a fixed width but only set minWidth,
so the Graph and Texture pickers grew with the name they showed. It is
now what it promises, with a separate minWidth for the buttons that have
two forms. Narrow buttons shrink their padding, otherwise a 24px icon
button had 8px left for its glyph and truncate dropped it entirely.
__gm.labelsDropped() guards that class: the overflow guard only saw text
that was too wide, never a label truncated to nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- NumberField takes a decimals option (default 3); frame rects and the
slice dialog use 0, matching their integer model.
- Button truncates its label to the drawn width; Dropdown reserves the
chevron slot. NodeView reuses the shared truncate().
- Setting scaleMode leaves the style's shared resource id in place, so
the old GPU sampler stayed in use until a reload. Call style.update().
- Graph gets a header bar like the Texture tab: GraphEditor is now the
frame, the former editor is GraphCanvas. A selection the tab does not
concern no longer unloads the behavior.
- New e2e step 14 guards text overflow across all widgets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
Number fields derived their display precision from `step`, so `step: 1`
(X/Y/Z, width, height, font size) rendered a stored 100.234 as "100" — and
because `onChange` parses the text, touching the field wrote the rounded
value back into the project. `formatNumber` now renders every number with
three decimals, independent of `step`, which is left to control the scrub
and arrow-key increment only.
Dragging now snaps to the increment's grid (`snapToStep`), so a scrub at
`step: 1` always lands on whole numbers even from a fractional start, while
Alt (x0.1) and the 0.1-step fields keep their precision. Typed values and
arrow keys are untouched.
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>
- SceneObject.editorOffset (x/y): shifts only where an object is drawn in
the Scene tab, cascades to children, undo-able with a reset action.
Never written into container templates.
- On click without an owner now fires for any click inside the camera
frame instead of hit-testing the (editor-only) behavior symbol.
- seed-jumprun: coins are a "Coin" container (sprite + trigger), nested
containers are expanded recursively, Destroy targets the coin's parent,
and the clock uses a Ceil node instead of ToText.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Inspector rows compute their reset/apply entries through providers, so an
entry that would not change anything is dropped; a single remaining entry
renders as a direct button instead of a menu, none removes it entirely.
- Bold and buttons now follow the same rule (deviatesFromAsset /
deviatesFromTemplate in core/properties/resolve.ts).
- Hierarchy panel is a collapsible tree: chevrons, arrow keys, double click,
expand/collapse all. Everything starts collapsed; selecting an object
expands its ancestors and scrolls it into view.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Engine additions that the example game needed:
- `enabled` as a shared property of every placeable asset (default on, an
override on the scene object). A switched-off object is not drawn, does not
collide and its behavior does not run - and neither does anything parented
below it. Disabled objects are now hidden in the scene view as well, so the
editor shows what the game shows; they stay in the hierarchy panel, which is
where they are selected and switched back on.
- Asset type `text` (text, fontSize, color, align), rendered in scene and game.
- Pointer input in the game view: `On click`, `Mouse position`, `Mouse button`,
`Is hovered`, hit-tested against colliders, else sprite/text footprints.
- New nodes: Set/Is enabled, Set text, Is asset, Name of, Equals, To text,
Join text, Floor/Ceil/Round, Modulo, Random, Time, Restart game.
Tooling: shared CDP harness (tools/harness.mjs), central project registry with
a guard that keeps destructive scripts off the playground (tools/projects.mjs),
plus smoke suites for the demo scene and the jump-and-run game.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Move with "Collide with solids" now steps in sub-steps of at most half the
thinnest collider, so a fast step can no longer jump across a collider (the
crates are only 60 high, which is why it showed up mostly on the y axis). A
pre-existing overlap is still never resolved, but it can no longer be
deepened either, so a small overlap cannot open a wall permanently.
seed-demo.mjs now writes the saved demo scene: a "Player" container (green
sprite with collider and the Mover behavior) against two "Enemy" container
instances, camera at zoom 0.5, Spawner dropping another enemy on Space.
The e2e checks assert against the old two-crate layout and locate widgets by
text, which the demo can no longer provide (three identically named sprite
instances, both crates nested in containers). Its fixture therefore moves to
tools/seed-e2e.mjs so the shipped demo can evolve without rewriting 100+
checks: seed-e2e -> e2e -> seed-demo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Collider property isTrigger (no blocking, still reports enter/stay/exit)
- Node 'Move and slide' with blocked x/y outputs, axis-wise resolution
- axisSeparation/axisCorrection geometry for box/box, circle/circle, box/circle
- Amber outline for trigger colliders in the viewport
- Demo mover uses Move and slide; E2E step 15b, 7 new unit tests
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Asset type 'collider' (box/circle) placed as child of a body object
- 'Add collider' shortcut (hierarchy context menu + inspector)
- CollisionSystem with enter/stay/exit pairs, sibling colliders excluded
- Nodes: On collision enter/stay/exit, Is touching
- Green outline in edit mode, hidden in play
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Position is now { x, y, z }; higher z draws on top, ties keep hierarchy order
- Viewport sorts the objects layer deterministically (syncDrawOrder)
- Inspector shows a Z field; asset properties that differ from the schema
default are marked bold with a reset button (ResetAssetPropertyCommand)
- isModified() helper, unit tests, E2E checks and seed updated
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Canvas-only 2D game editor/runtime built on PixiJS v8 + Vite + TypeScript.
- core: asset model (Scene/Camera/Sprite), scene objects with sparse
overrides, property schema + resolve, command stack (undo/redo),
serialization and project store interface (16 unit tests)
- ui: custom in-canvas widget toolkit (panels, fields, color picker,
list/scroll, popup) with a hidden <input> keyboard bridge
- editor: asset/hierarchy panels, schema-driven inspector, toolbar
- viewport: box rendering, camera frame, selection gizmo, drag/zoom/pan
- runtime: world/entity instantiation for play mode, input, camera follow
- api: PHP JSON project store with revision counter (409 on conflict)
- tools: CDP-based e2e smoke test
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>