Consolidate duplicated editor code, trim comments, refresh README

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>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-11 11:00:30 +02:00
1 parent 9212e61c4c
commit 8f92a06c84
96 files changed
+999 -1261

No files matched your search

+66 -44
View File
@@ -16,7 +16,7 @@ Live: <https://nockal.com/game-maker/> · Widget playground: <https://nockal.com
| **Enabled** | Every placeable asset has the switch `enabled` (default on). A disabled object is not drawn, does not collide and its behavior does not run – **and neither does anything parented below it**. Switching one instance off is a normal override, which makes a container a *screen*: switch it on to show it, off to hide it. `Set enabled` / `Is enabled` do it from a graph. | | **Enabled** | Every placeable asset has the switch `enabled` (default on). A disabled object is not drawn, does not collide and its behavior does not run – **and neither does anything parented below it**. Switching one instance off is a normal override, which makes a container a *screen*: switch it on to show it, off to hide it. `Set enabled` / `Is enabled` do it from a graph. |
| **Scene object** | An asset placed into a scene: a link to the asset + a **position** (`x`, `y`, `z`) + sparse **overrides** of the asset's properties + an optional **parent** (another object of the scene). Scenes cannot be placed. | | **Scene object** | An asset placed into a scene: a link to the asset + a **position** (`x`, `y`, `z`) + sparse **overrides** of the asset's properties + an optional **parent** (another object of the scene). Scenes cannot be placed. |
| **Container** | A reusable group ("prefab"): the asset holds a **template** of objects (asset link, local position, overrides, parent links) centred on its origin. Placing a container stores the container object **and nothing else** – its content is resolved from the template on every read, so a template edit reaches every instance at once. **Create container** turns the current selection into such an asset: the selected objects move into the template and the scene keeps one placed container at their centre (world positions unchanged). The template is edited in the **Container** tab (see *Editing a container*) or from an instance through **Apply to container**. | | **Container** | A reusable group ("prefab"): the asset holds a **template** of objects (asset link, local position, overrides, parent links) centred on its origin. Placing a container stores the container object **and nothing else** – its content is resolved from the template on every read, so a template edit reaches every instance at once. **Create container** turns the current selection into such an asset: the selected objects move into the template and the scene keeps one placed container at their centre (world positions unchanged). The template is edited in the **Container** tab (see *Editing a container*) or from an instance through **Apply to container**. |
| **Selection** | One or more scene objects. Click selects, `Ctrl`-click adds/removes, dragging on empty scene space draws a rubber band (objects whose position lies inside are selected). Dragging a selected object moves the whole selection; `Delete` removes it; the inspector offers **Create container** and **Remove** for several objects. | | **Selection** | One or more scene objects. Click selects, `Ctrl`/`Shift`-click adds/removes, dragging on empty scene space draws a rubber band (objects whose position lies inside are selected). Dragging a selected object moves the whole selection; `Delete` removes it; the inspector offers **Create container** and **Remove** for several objects. |
| **Behavior** | Logic as an asset: a **node graph** plus the **variables** it declares. Placed like anything else (a diamond symbol in the scene); its parent is the **owner** the graph acts on. | | **Behavior** | Logic as an asset: a **node graph** plus the **variables** it declares. Placed like anything else (a diamond symbol in the scene); its parent is the **owner** the graph acts on. |
| **Run in editor** | A switch only behaviors have (default off). With it on the behavior keeps running while the project is **not** playing – and unlike Play it then edits the *saved* model: what it moves or writes is a real change, autosaved and not on the undo stack. See *Running without Play*. | | **Run in editor** | A switch only behaviors have (default off). With it on the behavior keeps running while the project is **not** playing – and unlike Play it then edits the *saved* model: what it moves or writes is a real change, autosaved and not on the undo stack. See *Running without Play*. |
| **Collider** | A shape (box or circle) for collision detection, placed as a **child** of the object it belongs to (its *body*). Drawn as a green outline in edit mode only. | | **Collider** | A shape (box or circle) for collision detection, placed as a **child** of the object it belongs to (its *body*). Drawn as a green outline in edit mode only. |
@@ -37,6 +37,14 @@ one, so switching it off hands the view to the next; with none on the tab stays
shows what Play would show, minus the editor symbols. Play has no built-in behaviour (no input, shows what Play would show, minus the editor symbols. Play has no built-in behaviour (no input,
no movement): everything that happens is defined by behavior graphs. no movement): everything that happens is defined by behavior graphs.
**Toolbar and panels.** The toolbar holds the **Project** menu (new project, switch project,
*Delete this project…*), the project name (type to rename), the **Scene** picker for the active
scene, Undo/Redo, Play/Pause, and on the right the notice line, the save status and the frame
rate. The **Assets** panel's `+` creates an asset; right-clicking a row offers *Set as active
scene* (double-click or `Enter` does the same), *Create sprite from texture* and *Delete…*. In
**Scene objects** it offers *Select asset*, *Add collider*, *Create container*, *Unpack
container*, *Unparent*, *Expand all* / *Collapse all* and *Remove*.
**Editing while playing.** Play opens the Game tab but leaves the editor fully usable: the **Editing while playing.** Play opens the Game tab but leaves the editor fully usable: the
Scene tab shows the live positions (symbols, gizmos and dragging included), the hierarchy and Scene tab shows the live positions (symbols, gizmos and dragging included), the hierarchy and
inspector show and edit the running objects, the graph can be changed, undo/redo work on the inspector show and edit the running objects, the graph can be changed, undo/redo work on the
@@ -65,7 +73,7 @@ behaviors that arrange or build content rather than on ones that simulate.
**Input goes to the tab you are looking at.** While the **Scene** tab is on screen the keyboard and **Input goes to the tab you are looking at.** While the **Scene** tab is on screen the keyboard and
the mouse are the editor's – typing a name or walking the hierarchy with the arrow keys must not the mouse are the editor's – typing a name or walking the hierarchy with the arrow keys must not
steer a game at the same time. In the **Game** tab, where no key means anything to the editor, they steer a game at the same time. In the **Game** tab, where no key means anything to the editor, they
reach the run instead: `On key …`, `Is key down` and `On click` work there, and the tab says so in reach the run instead: `On key …`, `Key is down` and `On click` work there, and the tab says so in
its top-left corner. Leaving the tab (or focusing a field in it) hands the keyboard back and lets its top-left corner. Leaving the tab (or focusing a field in it) hands the keyboard back and lets
every held key go, so nothing keeps walking with nobody pressing anything. What the run does *not* every held key go, so nothing keeps walking with nobody pressing anything. What the run does *not*
get: get:
@@ -85,7 +93,7 @@ get:
A behavior asset holds a Blueprint-style graph edited in the **Graph** tab of the centre A behavior asset holds a Blueprint-style graph edited in the **Graph** tab of the centre
area (the **Scene** tab shows the editable scene, the **Game** tab the running game). Ports come in two kinds: **exec** (white area (the **Scene** tab shows the editable scene, the **Game** tab the running game). Ports come in two kinds: **exec** (white
arrows, control flow) and **data** (coloured circles; `number`, `boolean`, `string`, arrows, control flow) and **data** (coloured circles; `number`, `boolean`, `string`,
`object`, `any`). Event nodes start a pulse; the interpreter follows exec edges and pulls `object`, `list`, `struct`, `any`). Event nodes start a pulse; the interpreter follows exec edges and pulls
data inputs lazily from connected outputs. Unconnected data inputs use the inline value data inputs lazily from connected outputs. Unconnected data inputs use the inline value
shown on the node and editable in the inspector. shown on the node and editable in the inspector.
@@ -103,14 +111,14 @@ shown on the node and editable in the inspector.
handle that no reload survives. A set of objects is a list of their names, walked with `Repeat` handle that no reload survives. A set of objects is a list of their names, walked with `Repeat`
and `Object by name`. A list property is shown as a button saying how many items it holds; and `Object by name`. A list property is shown as a button saying how many items it holds;
clicking it opens the **List** tab, which gives each item a row with the same widget the clicking it opens the **List** tab, which gives each item a row with the same widget the
inspector would use for that kind – for a list of structs that is the inspector's own struct inspector would use for that kind – for a list of structs a row is the item's fields as
block, so a row is the bundle's fields under each other. The inspector stays visible beside the tab, so what the list labelled rows under each other. The inspector stays visible beside the tab, so what the list
belongs to is still on screen. Which list the tab shows is a `ListSlot` (asset property, object belongs to is still on screen. Which list the tab shows is a `ListSlot` (asset property, object
override or node setting, plus the `field` of a struct that holds one) on the editor state, override or node setting, plus the `field` of a struct that holds one) on the editor state,
because no tab may reach into the `Editor` to switch itself on. An **unconnected `list` input** because no tab may reach into the `Editor` to switch itself on. An **unconnected `list` input**
of a node is the same button and the same tab: its inline value lives in the very same params of a node is the same button and the same tab: its inline value lives in the very same params
bag a setting does, and the slot stops resolving the moment an edge feeds the port. What a list bag a setting does, and the slot stops resolving the moment an edge feeds the port. What a list
variable holds is picked from its **kind mark** (`≡#`, `≡Aa`, `≡⊞`), which on the asset doubles variable holds is picked from its **kind mark** (`≡#`, `≡Aa`, `≡✓`, `≡◧`, `≡⊞`), which on the asset doubles
as the chooser – a click opens the menu, with no chevron, since the mark has to stay as narrow as as the chooser – a click opens the menu, with no chevron, since the mark has to stay as narrow as
its column; changing it runs the declared default, the behavior's own value and every override its column; changing it runs the declared default, the behavior's own value and every override
through the new declaration in one command. A list of structs then declares its item shape with through the new declaration in one command. A list of structs then declares its item shape with
@@ -125,8 +133,8 @@ shown on the node and editable in the inspector.
variable, whatever its kind, with the very same rows (`buildVariable`), minus the renaming, the variable, whatever its kind, with the very same rows (`buildVariable`), minus the renaming, the
kind chooser and the `+ Field` / `×` buttons: it may override the values, not declare them. A kind chooser and the `+ Field` / `×` buttons: it may override the values, not declare them. A
struct's ↺ / ↑ sit on its head row, because an override covers the whole bundle rather than a struct's ↺ / ↑ sit on its head row, because an override covers the whole bundle rather than a
single field. All of it sits on **one column grid** with the rest of the inspector: mark (28 px) single field. All of it sits on **one column grid** with the rest of the inspector: mark (28 px),
plus name (90 px) is exactly as wide as a plain row's label, a nested field's name gives up what gap and name (90 px) are exactly as wide as a plain row's label (124 px), a nested field's name gives up what
its indent takes, and names are cut with `…` rather than pushing their value out of line. its indent takes, and names are cut with `…` rather than pushing their value out of line.
- **Owner** = parent: a placed behavior is usually parented to the object it controls - **Owner** = parent: a placed behavior is usually parented to the object it controls
(inspector → Parent, or drag it onto that object in the hierarchy; dashed line in the (inspector → Parent, or drag it onto that object in the hierarchy; dashed line in the
@@ -141,7 +149,7 @@ shown on the node and editable in the inspector.
- **Node set** (`src/core/graph/nodes/`, one file per node): events `On start`, `On update`, `On key down/up/held`, - **Node set** (`src/core/graph/nodes/`, one file per node): events `On start`, `On update`, `On key down/up/held`,
`On timer`, `On collision enter/stay/exit`, `On click` (the mouse pressed on the owner – anywhere when it has none), `Start` · flow `Branch`, `Sequence`, `Repeat`, `Run behavior`, `End` · data `Self`, `Owner`, `Parent`, `Object by name`, `Name of`, `On timer`, `On collision enter/stay/exit`, `On click` (the mouse pressed on the owner – anywhere when it has none), `Start` · flow `Branch`, `Sequence`, `Repeat`, `Run behavior`, `End` · data `Self`, `Owner`, `Parent`, `Object by name`, `Name of`,
`Is asset` (is this object an instance of asset X – how you tell what you collided with), `Get/Set `Is asset` (is this object an instance of asset X – how you tell what you collided with), `Get/Set
variable`, `Get/Set property`, `Get position` (local and world), `Is enabled`, literals, `Add/Subtract/Multiply/Divide`, variable`, `Get/Set variables`, `Get/Set property`, `Get position` (local and world), `Is enabled`, literals, `Add/Subtract/Multiply/Divide`,
`Min/Max/Abs/Sqrt/Clamp`, `Floor/Ceil/Round`, `Modulo`, `Random`, `Time`, `To text`, `To number`, `Join text`, `Min/Max/Abs/Sqrt/Clamp`, `Floor/Ceil/Round`, `Modulo`, `Random`, `Time`, `To text`, `To number`, `Join text`,
`Substring`, `Find text`, `Equals`, `Substring`, `Find text`, `Equals`,
lists `List` (a constant), `Fill` (one value repeated – how an empty board is made), `Count`, lists `List` (a constant), `Fill` (one value repeated – how an empty board is made), `Count`,
@@ -281,7 +289,8 @@ shown on the node and editable in the inspector.
the exec edges and nodes that just ran light up – the same canvas draws editor and game, so the exec edges and nodes that just ran light up – the same canvas draws editor and game, so
this costs nothing extra. this costs nothing extra.
- **Runtime**: `BehaviorSystem` creates one `GraphInterpreter` per behavior entity; graphs only - **Runtime**: `BehaviorSystem` creates one `GraphInterpreter` per behavior entity; graphs only
touch world entities. A runaway loop throws after 1000 steps and stops play with a notice. touch world entities. A runaway pulse throws (see *Loops* for the two limits) and stops play
with a notice.
**Placing an asset: drag it in.** Drag a row out of the **Assets** panel and drop it on the **Placing an asset: drag it in.** Drag a row out of the **Assets** panel and drop it on the
view – the Scene tab places into the active scene, the Container tab into the open template, so view – the Scene tab places into the active scene, the Container tab into the open template, so
@@ -449,7 +458,7 @@ those two numbers are written as they are.
**A sprite straight from a texture.** Right-click a texture in the asset list → *Create sprite **A sprite straight from a texture.** Right-click a texture in the asset list → *Create sprite
from texture* makes a sprite asset that wears the whole picture at its pixel size, named after the from texture* makes a sprite asset that wears the whole picture at its pixel size, named after the
texture and selected. It is the same as creating a sprite, picking the texture and clicking *Set to texture (numbered like any name clash, so `Coin` gives `Coin 2`) and selected. It is the same as creating a sprite, picking the texture and clicking *Set to
W × H px*, but in one undo step. W × H px*, but in one undo step.
Rendering a frame is a texture matrix on the fill, not a Pixi sub-texture — a Graphics fill Rendering a frame is a texture matrix on the fill, not a Pixi sub-texture — a Graphics fill
@@ -530,7 +539,8 @@ still finds it, so a graph can switch it back on. A behavior inside a disabled s
interpreter yet – it gets one, and its `On start`, the moment the screen is switched on, which interpreter yet – it gets one, and its `On start`, the moment the screen is switched on, which
makes "the screen initialises itself when shown" the default. Its variables survive being makes "the screen initialises itself when shown" the default. Its variables survive being
switched off and on again. A switched-off object is hidden in the Scene tab as well, so the switched off and on again. A switched-off object is hidden in the Scene tab as well, so the
editor shows what the game shows; it stays listed in the hierarchy panel, which is where it is editor shows what the game shows – selected, it keeps a faint outline, so where it sits is still
visible, but it cannot be dragged. It stays listed in the hierarchy panel, which is where it is
selected and switched back on. selected and switched back on.
`Restart game` throws the run away and starts the scene again from the saved model – what the `Restart game` throws the run away and starts the scene again from the saved model – what the
@@ -540,8 +550,9 @@ selected and switched back on.
- **PixiJS v8** – rendering and pointer events. No game engine; runtime systems live in `src/runtime/`. - **PixiJS v8** – rendering and pointer events. No game engine; runtime systems live in `src/runtime/`.
- **Own canvas widget toolkit** (`src/ui/`) – panels, buttons, lists, text/number/color fields, - **Own canvas widget toolkit** (`src/ui/`) – panels, buttons, lists, text/number/color fields,
popups. The only DOM element is a hidden `<input>` used as a keyboard bridge (IME, mobile popups. The only lasting DOM element is a hidden `<input>` used as a keyboard bridge (IME,
keyboard, clipboard) while a canvas text field is focused. mobile keyboard, clipboard) while a canvas text field is focused; *Upload…* adds a short-lived
file input.
Double-clicks are counted in `clicks.ts`, not read off the event: Pixi only puts a click count Double-clicks are counted in `clicks.ts`, not read off the event: Pixi only puts a click count
on the `click` it synthesises and leaves `detail` at 0 on `pointerdown`, so every press handler on the `click` it synthesises and leaves `detail` at 0 on `pointerdown`, so every press handler
that trusted `e.detail` never saw a second click. Presses are counted per key rather than per that trusted `e.detail` never saw a second click. Presses are counted per key rather than per
@@ -565,7 +576,7 @@ selected and switched back on.
direction – a button too narrow to draw any of its label – and the `pickerWidth()` of direction – a button too narrow to draw any of its label – and the `pickerWidth()` of
`__gm.graph` / `__gm.texture` / `__gm.container` catch a picker that resizes itself around its `__gm.graph` / `__gm.texture` / `__gm.container` catch a picker that resizes itself around its
content (e2e step 14). content (e2e step 14).
- **Vite + TypeScript**, **Vitest** for the pure core. - **Vite + TypeScript**, **Vitest** for core, toolkit and editor logic.
- **PHP 8** JSON API (`api/`) storing one JSON file per project in `data/projects/`, with - **PHP 8** JSON API (`api/`) storing one JSON file per project in `data/projects/`, with
atomic writes and an integer revision that rejects stale saves (HTTP 409). atomic writes and an integer revision that rejects stale saves (HTTP 409).
@@ -573,28 +584,33 @@ selected and switched back on.
``` ```
web/index.html Vite source page (dev) index.html built page (generated, served) web/index.html Vite source page (dev) index.html built page (generated, served)
src/core/model/ Project (object holders), Asset, SceneObject, hierarchy, walk (generic parent/child walks), instance (container content resolved from its template), usages (where an asset is used), container (the cycle guard) src/core/model/ Project (object holders), Asset, SceneObject, hierarchy, walk (generic parent/child walks), instance (container content resolved from its template), usages (where an asset is used), container (the cycle guard), placement (what may be dropped where)
src/core/properties/ PropertySchema, resolve (the only override merge), collider/camera/text geometry, enabled src/core/properties/ PropertySchema, resolve (the only override merge), deviation (what is gold), collider/camera/bounds geometry, aspect, enabled, editorRun
src/core/commands/ undoable commands, CommandStack, SetBagValueCommand base, require.ts (validated lookups, and what an instance may not change), naming.ts src/core/commands/ undoable commands, CommandStack, SetBagValueCommand base, require.ts (validated lookups, and what an instance may not change), naming.ts
src/core/graph/ graph model, NodeRegistry/NodeContext/values, interpreter; nodes/<category>/<Type>.ts src/core/graph/ graph model, NodeRegistry/NodeContext/values, interpreter, dropTarget, nested (behaviors as nodes),
variablePorts, keyRepeat; nodes/{events,flow,data,action,variable}/<Name>.ts
src/core/store/ serialize (untrusted JSON → Project), http (one API call), HttpProjectStore src/core/store/ serialize (untrusted JSON → Project), http (one API call), HttpProjectStore
src/runtime/ World, Entity, System interface, Input tracker, CollisionSystem, BehaviorSystem src/runtime/ World, Entity, System interface, Input tracker, CollisionSystem, BehaviorSystem
src/ui/ widget toolkit (generic, knows nothing about assets) src/ui/ widget toolkit (generic, knows nothing about assets)
src/viewport/ view (transforms), items (what to draw), symbols (how to draw it), src/viewport/ view (transforms), items (what to draw), symbols (how to draw it),
textureFill, TextureLibrary – no editor concepts, so tests reach it freely textureFill, TextureLibrary – no editor concepts, so tests reach it freely
src/editor/ App → Editor (layout + ticker), PlaySession (the run), ProjectSession (load/create), src/editor/ App → Editor (layout + ticker), PlaySession (the run), ProjectSession (load/create),
state, actions, shortcuts, debugHook, autosave, framing (fit/marquee maths), EditorRun (run in editor), state, actions, shortcuts, debugHook, autosave, textureImport,
AssetTabEditor (the shared shell of the three asset tabs) framing (fit/marquee/snap maths), AssetTabEditor (the shared shell of the three asset tabs)
src/editor/viewport/ WorldViewport base + SceneViewport/GameViewport – editing tools, hence not in src/viewport/ src/editor/viewport/ WorldViewport base + SceneViewport/GameViewport – editing tools, hence not in src/viewport/
src/editor/inspector/ Inspector panel, field factory, previewHandlers (scrub → preview → command), src/editor/inspector/ Inspector panel, field factory, previewHandlers (scrub → preview → command), valueSpecs
one section per selection kind (asset/object/node), objectQueries/vectorRows/parentRow (where a value lives – shared with the List tab),
one section per selection kind (asset/object/node/frame), variableRows (one row shape for
every variable), portSection, aspectAction, objectQueries/vectorRows/parentRow
src/editor/panels/ AssetPanel, HierarchyPanel, Toolbar src/editor/panels/ AssetPanel, HierarchyPanel, Toolbar
src/editor/graph/ GraphEditor (header + picker), GraphCanvas, NodeView, NodePalette, folding (hidden links, collapsed nodes) src/editor/graph/ GraphEditor (header + picker), GraphCanvas, EdgeLayer (curves and fold marks), graphMenus,
NodeView, NodePalette + paletteEntries, folding (hidden links, collapsed nodes)
src/editor/container/ ContainerEditor (header + picker), ContainerCanvas (a SceneViewport on a template) src/editor/container/ ContainerEditor (header + picker), ContainerCanvas (a SceneViewport on a template)
src/editor/texture/ TextureEditor (header + picker), TextureCanvas (picture + frames), SliceDialog, sheetView (the grid maths) src/editor/texture/ TextureEditor (header + picker), TextureCanvas (picture + frames), SliceDialog, sheetView (the grid maths),
ImagePicker + imageGrid (the "+ → Texture" dialog)
src/editor/list/ ListEditor (a row per item), slot (which list is being edited, and how it is written back) src/editor/list/ ListEditor (a row per item), slot (which list is being edited, and how it is written back)
api/ PHP backend data/projects/ persisted projects api/ PHP backend data/projects/ persisted projects
tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/, rig in tools/e2e/lib/), smoke-*, lib/ tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/, rig in tools/e2e/lib/), smoke-*, lib/, remote/
``` ```
The toolbar's right edge shows the frame rate (frames counted over a 0.5 s window, updated The toolbar's right edge shows the frame rate (frames counted over a 0.5 s window, updated
@@ -615,16 +631,16 @@ Node 22 is required (`/usr/bin/node`, in the default PATH).
```bash ```bash
npm install npm install
npm run dev # http://localhost:5175/game-maker/dist/ – API calls are proxied to production npm run dev # http://localhost:5175/game-maker/dist/ – API calls are proxied to production
npm test # unit tests (core) npm test # unit tests (core, editor, ui, viewport)
npm run typecheck npm run typecheck
npm run lint # ESLint, type-aware; --fix for the fixable half npm run lint # ESLint (type-aware for src and tests); lint:fix for the fixable half
npm run format # Prettier over src, tests and tools npm run format # Prettier over the repo, minus what .prettierignore lists
npm run check # typecheck + lint + format:check + tests, what CI would run npm run check # typecheck + lint + format:check + tests, what CI would run
./check.sh # the same, ending in `=== CHECK EXIT n ===`: piped into `tail` npm's exit ./check.sh # the same, ending in `=== CHECK EXIT n ===`: piped into `tail` npm's exit
# code is lost (tail's is reported), so a red run reads as green # code is lost (tail's is reported), so a red run reads as green
npm run build # writes dist/ and regenerates index.html npm run build # writes dist/ and regenerates index.html
export GM_REMOTE=user@host # optional: run the browser elsewhere; see below export GM_REMOTE=user@host # optional: run the browser elsewhere; see below
node tools/seed-e2e.mjs && node tools/e2e.mjs # 532 editor checks, screenshots in /tmp/gm-e2e-*.png node tools/seed-e2e.mjs && node tools/e2e.mjs # 534 editor checks, screenshots in /tmp/gm-e2e-*.png
GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 – a subset while working on one step GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 – a subset while working on one step
./e2e.sh start [GM_STEPS] # the same run detached, see below ./e2e.sh start [GM_STEPS] # the same run detached, see below
./e2e.sh status # live progress, or the last run's result ./e2e.sh status # live progress, or the last run's result
@@ -635,12 +651,12 @@ node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs # 19 checks: the T
**A run needs a working GPU.** An idle editor page keeps rendering a picture that never changes, and **A run needs a working GPU.** An idle editor page keeps rendering a picture that never changes, and
in software that is ruinous: measured here on an idle page over 20 s, software WebGL costs **373% of in software that is ruinous: measured here on an idle page over 20 s, software WebGL costs **373% of
a core at 8 fps**, the same page on the Intel HD 630 via ANGLE/Vulkan costs **69% at 56 fps**. The a core at 8 fps**, the same page on the Intel HD 630 via ANGLE/Vulkan costs **69% at 56 fps**. The
full suite takes ~124 s that way. `tools/lib/browser.mjs` therefore always asks for the GPU and has full suite takes about four minutes that way. `tools/lib/browser.mjs` therefore always asks for the GPU and has
**no software fallback** – on a host without one, chromium should fail loudly rather than melt the **no software fallback** – on a host without one, chromium should fail loudly rather than melt the
machine down. (`--use-angle=gl` and `--use-gl=egl` measure cheaper still, but get no WebGL context at machine down. (`--use-angle=gl` and `--use-gl=egl` measure cheaper still, but get no WebGL context at
all, so the app never renders.) all, so the app never renders.)
**Run it here by default.** With the GPU in use the full suite costs ~124 s and roughly 0.8 of the **Run it here by default.** With the GPU in use the full suite costs about four minutes and roughly 0.8 of the
four cores of this box (60% renderer, 18% GPU process, load ~1.0), which it carries fine. While one four cores of this box (60% renderer, 18% GPU process, load ~1.0), which it carries fine. While one
step is being worked on, `GM_STEPS` keeps a run to seconds – take the whole suite before a commit, step is being worked on, `GM_STEPS` keeps a run to seconds – take the whole suite before a commit,
or when a change touches drawing, the widget toolkit or anything else every step sees. or when a change touches drawing, the widget toolkit or anything else every step sees.
@@ -650,7 +666,8 @@ once; output goes to `logs/e2e-<date>_<time>.log` (gitignored) and ends in `===
Backgrounding it from a shell instead is not enough – four runs in a row died mid-suite with no Backgrounding it from a shell instead is not enough – four runs in a row died mid-suite with no
closing line, because the whole process group is killed when the calling shell exits (`nohup` only closing line, because the whole process group is killed when the calling shell exits (`nohup` only
covers SIGHUP, and `/proc/vmstat` confirmed `oom_kill 0`, so it was never memory). The unit outlives covers SIGHUP, and `/proc/vmstat` confirmed `oom_kill 0`, so it was never memory). The unit outlives
its caller. It takes the same `GM_STEPS` argument and refuses to start a second run over a live one. its caller. It takes the same `GM_STEPS` argument, refuses to start a second run over a live one, and
`systemctl stop game-maker-e2e.service` ends it.
**`./e2e.sh status`** reports the live run (elapsed, PASS/FAIL so far, the check it is on) or, when **`./e2e.sh status`** reports the live run (elapsed, PASS/FAIL so far, the check it is on) or, when
nothing is running, the newest log's result plus its FAIL lines. A log without a closing line is nothing is running, the newest log's result plus its FAIL lines. A log without a closing line is
@@ -693,7 +710,8 @@ to touch it.
The e2e run is a list of steps in `tools/e2e/` (hierarchy, inspector, play, assets, drawing, The e2e run is a list of steps in `tools/e2e/` (hierarchy, inspector, play, assets, drawing,
graph, parenting, colliders, containers, text/enabled, menus, textures, frames, overflow, aspect, graph, parenting, colliders, containers, text/enabled, menus, textures, frames, overflow, aspect,
container tab, run-in-editor, pause, nested behaviors, key repeat, list tab, struct variable) that build on each other's container tab, run-in-editor, pause, nested behaviors, key repeat, list tab, struct variable,
variable nodes) that build on each other's
scene state and share the CDP driver in `tools/lib/harness.mjs` – `press`, `undo`, `play`, scene state and share the CDP driver in `tools/lib/harness.mjs` – `press`, `undo`, `play`,
`object(id)`, `world()`, `mutate(js)` and friends, so a check reads like the interaction it `object(id)`, `world()`, `mutate(js)` and friends, so a check reads like the interaction it
exercises. exercises.
@@ -704,7 +722,7 @@ subset that skips a prerequisite fails on purpose; take `1-N` when in doubt.
**The example game** (`tools/seed-jumprun.mjs`, project *Jump and Run*) is a complete little **The example game** (`tools/seed-jumprun.mjs`, project *Jump and Run*) is a complete little
platformer built only from assets, containers and graphs: a title screen with a clickable Start platformer built only from assets, containers and graphs: a title screen with a clickable Start
button, a level of boxes with ten coins, gravity and jumping, a two-minute clock in the HUD, and button, a level of boxes with ten coins, gravity and jumping, a 30-second clock in the HUD, and
a win / lose screen with "Play again". A coin is a container of its own (sprite plus trigger a win / lose screen with "Play again". A coin is a container of its own (sprite plus trigger
collider) placed ten times inside the level container – nested containers resolve through both levels, collider) placed ten times inside the level container – nested containers resolve through both levels,
and picking one up destroys the container, not just the sprite the collider reported. `tools/smoke-jumprun.mjs` plays it in a headless browser and picking one up destroys the container, not just the sprite the collider reported. `tools/smoke-jumprun.mjs` plays it in a headless browser
@@ -732,7 +750,7 @@ lot: the call to `Next move` has **one node at each side** of it rather than fiv
graph is **44 nodes / 64 edges** where the same game in single nodes was 69 / 76. Inside `Next graph is **44 nodes / 64 edges** where the same game in single nodes was 69 / 76. Inside `Next
move` each strand writes its whole answer – the candidate, what kind of move it was and the wish move` each strand writes its whole answer – the candidate, what kind of move it was and the wish
it used up – in one node rather than three to five, which is **30 nodes** against 40. Two things it used up – in one node rather than three to five, which is **30 nodes** against 40. Two things
bound how far such a node may reach: a data node answers once per frame and keeps that answer, so bound how far such a node may reach: a data node answers once per pulse and keeps that answer, so
a `Get variables` must not straddle a write of one of its variables (which is why the board is a `Get variables` must not straddle a write of one of its variables (which is why the board is
read a second time after the lines were cleared), and a `Set variables` reads all its inputs read a second time after the lines were cleared), and a `Set variables` reads all its inputs
before it writes any, so none of them may come from a variable it writes. before it writes any, so none of them may come from a variable it writes.
@@ -740,10 +758,10 @@ before it writes any, so none of them may come from a variable it writes.
It is **ten behaviors**, not one graph. `Tetris` owns the state and does none of the work: each It is **ten behaviors**, not one graph. `Tetris` owns the state and does none of the work: each
step of a frame is a behavior of its own, called as a node – `Spawn piece`, `Next move`, `Fits`, step of a frame is a behavior of its own, called as a node – `Spawn piece`, `Next move`, `Fits`,
`Draw piece`, `Lock piece`, `Clear lines`, `Show lines`, `Draw board`, with `Piece cell` (where `Draw piece`, `Lock piece`, `Clear lines`, `Show lines`, `Draw board`, with `Piece cell` (where
one of the four cells of a piece sits) used by three of them. None of them holds state: what they one of the four cells of a piece sits) used by two of them. None of them holds state: what they
need arrives through their inputs and every answer leaves through their outputs, which is what need arrives through their inputs and every answer leaves through their outputs, which is what
lets `Fits` answer for turning, moving and falling alike, and `Piece cell` serve the fit test, lets `Fits` answer for turning, moving and falling alike, and `Piece cell` serve the fit test
the drawing and the landing instead of being the same twenty nodes three times. What is left in and the drawing instead of being the same twenty nodes twice. What is left in
the master graph is the order the steps run in and which variable each answer lands in – it holds the master graph is the order the steps run in and which variable each answer lands in – it holds
no loop and no list access of its own, which `tools/smoke-tetris.mjs` checks. That script plays no loop and no list access of its own, which `tools/smoke-tetris.mjs` checks. That script plays
the game in a headless browser (fall, move, turn, drop, land, clear a line, fill the well) – it the game in a headless browser (fall, move, turn, drop, land, clear a line, fill the well) – it
@@ -760,30 +778,34 @@ All routes go through `api/index.php?r=…` (the site's nginx does not pass `PAT
| GET | `projects` | `[{id,name,revision,updatedAt}]` | | GET | `projects` | `[{id,name,revision,updatedAt}]` |
| POST | `projects` | `{name}` → project document (201) | | POST | `projects` | `{name}` → project document (201) |
| GET | `projects/{id}` | `{id,name,revision,updatedAt,project}` | | GET | `projects/{id}` | `{id,name,revision,updatedAt,project}` |
| PUT | `projects/{id}` | `{revision, project}` → `{revision,updatedAt}` or **409** `{revision}` when stale | | PUT | `projects/{id}` | `{revision, project}` → `{revision,updatedAt}` or **409** `{error,revision}` when stale |
| DELETE | `projects/{id}` | 204 | | DELETE | `projects/{id}` | 204 |
| GET | `textures` | `[{file,width,height,bytes,updatedAt}]` – what the image picker offers | | GET | `textures` | `[{file,width,height,bytes,updatedAt}]` – what the image picker offers |
| POST | `textures` | **raw image bytes** → `{file,width,height,bytes}` (201) | | POST | `textures` | **raw image bytes** → `{file,width,height,bytes}` (201) |
| DELETE | `textures/{file}` | 204 | | DELETE | `textures/{file}` | 204 |
Uploads send the bytes as the body rather than `multipart/form-data`: no boundary parsing in Uploads send the bytes as the body rather than `multipart/form-data`: no boundary parsing in
PHP, and PHP's `upload_max_filesize` (2 MB) never applies — only `post_max_size`. The stored PHP, and PHP's `upload_max_filesize` (2 MB) never applies — only `post_max_size`. The API has its
own caps behind nginx's: 8 MB per request, 4 MB per image (413). The stored
files are served statically from `data/textures/`, so reading an image never runs PHP. files are served statically from `data/textures/`, so reading an image never runs PHP.
There is no authentication yet; `data/` is world-readable. Both are phase-2 topics. There is no authentication yet; `data/` is world-readable. Both are phase-2 topics.
## Keyboard ## Keyboard
`Ctrl+Z` / `Ctrl+Shift+Z` / `Ctrl+Y` undo/redo · `Delete` remove selection · `Ctrl+G` create `Ctrl+Z` / `Ctrl+Shift+Z` / `Ctrl+Y` undo/redo (`Cmd` works wherever `Ctrl` does) · `Delete` or
`Backspace` remove the selection (objects, nodes, frames; an asset asks first) · `Ctrl+G` create
container from the selection · `Ctrl+Enter` play, and pause/resume while playing · `Esc` stop · container from the selection · `Ctrl+Enter` play, and pause/resume while playing · `Esc` stop ·
`Ctrl+V` import an image from `Ctrl+V` import an image from
the clipboard as a texture (ignored while a text field has focus) · `Tab` cycles fields · number the clipboard as a texture (ignored while a text field has focus) · `Tab` / `Shift+Tab` cycle fields · lists: `↑`/`↓` move,
`←`/`→` fold, `Enter` activates · number
fields: drag to scrub (`Shift` ×10, `Alt` ×0.1, snapping to the grid of that increment, so a fields: drag to scrub (`Shift` ×10, `Alt` ×0.1, snapping to the grid of that increment, so a
step-1 field lands on whole numbers), `↑`/`↓` step, shown with three decimals (`ui/math.ts`, step-1 field lands on whole numbers), `↑`/`↓` step, shown with three decimals (`ui/math.ts`,
`NUMBER_DECIMALS`) so a fractional value never reads as a whole number — except where the `NUMBER_DECIMALS`) so a fractional value never reads as a whole number — except where the
value is integral by nature (frame rectangles, the slice dialog), which set value is integral by nature (frame rectangles, the slice dialog, a camera's view size, font
size, the key-repeat milliseconds), which set
`NumberField`'s `decimals: 0` and show plain whole numbers · viewport: wheel zooms, `NumberField`'s `decimals: 0` and show plain whole numbers · viewport: wheel zooms,
middle-drag or `Shift`+drag pans, drag on empty space rubber-bands, `Ctrl`-click toggles an middle-drag or `Shift`+drag pans, drag on empty space rubber-bands, `Ctrl`/`Shift`-click toggles an
object in the selection (hierarchy rows too). object in the selection (hierarchy rows too).
## Not yet ## Not yet
+5
View File
@@ -28,3 +28,8 @@ export class CompoundCommand implements Command {
for (let i = this.commands.length - 1; i >= 0; i--) this.commands[i]!.undo(project); for (let i = this.commands.length - 1; i >= 0; i--) this.commands[i]!.undo(project);
} }
} }
/** A lone command stays itself, so the undo step keeps its own label. */
export function oneStep(label: string, commands: readonly Command[]): Command {
return commands.length === 1 ? commands[0]! : new CompoundCommand(label, commands);
}
+2 -5
View File
@@ -20,11 +20,8 @@ import { nextAssetName } from './naming';
import { requireObject, requireHolder, requireStructural } from './require'; import { requireObject, requireHolder, requireStructural } from './require';
/** /**
* Turns the selected objects, with everything parented below them, into a container asset placed * Turns the selected objects and their descendants into a container asset placed in their stead, the
* in their stead. The origin is the centre of the selection's bounding box (z = 0), so the template * template centred on the selection's bounding box (z = 0).
* is centred around (0, 0, 0). The objects themselves leave the holder – the placed container
* shows them again, resolved from the template. It goes below the roots' shared parent, or at the
* top level when they do not share one.
*/ */
export class CreateContainerCommand implements Command { export class CreateContainerCommand implements Command {
readonly label = 'Create container'; readonly label = 'Create container';
+24 -40
View File
@@ -9,14 +9,14 @@ import {
type GraphNode, type GraphNode,
isTerminalNode, isTerminalNode,
isValidPortName, isValidPortName,
requireNodeDef,
RUN_BEHAVIOR, RUN_BEHAVIOR,
} from '../graph'; } from '../graph';
import type { PropertyValue } from '../model/Properties'; import type { PropertyValue } from '../model/Properties';
import type { Project } from '../model/Project'; import type { Project } from '../model/Project';
import { type Removed, removeFirst, removeWhere, restoreAt } from '../util/list'; import { type Removed, removeFirst, removeWhere, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command'; import { type Command, CommandError } from './Command';
import { requireBehavior } from './require'; import { nextFieldName } from './naming';
import { requireBehavior, requireNode, requireNodeDef } from './require';
interface PortSite { interface PortSite {
graph: Graph; graph: Graph;
@@ -52,19 +52,17 @@ function edgesOnPort(site: PortSite, key: string): (edge: GraphEdge) => boolean
: edge.from.node === site.nodeId && edge.from.port === key; : edge.from.node === site.nodeId && edge.from.port === key;
} }
/** The node whose declaration is being edited; a terminal is addressed by id like any other. */
function requirePortNode(project: Project, assetId: string, nodeId: string): GraphNode {
const node = findNode(requireBehavior(project, assetId).graph, nodeId);
if (!node) throw new CommandError(`Node ${nodeId} not found`);
return node;
}
function nodeAt(site: PortSite): GraphNode | undefined { function nodeAt(site: PortSite): GraphNode | undefined {
return site.graph.nodes.find((n) => n.id === site.nodeId); return findNode(site.graph, site.nodeId);
} }
function renameParam(site: PortSite, from: string, to: string): void { /** The port's edges follow its new key, and so does an input's typed-in value, which lives under it. */
const node = nodeAt(site); function carryPort(site: PortSite, from: string, to: string): void {
for (const edge of site.graph.edges.filter(edgesOnPort(site, from))) {
if (site.asInput) edge.to.port = to;
else edge.from.port = to;
}
const node = site.asInput ? nodeAt(site) : undefined;
const value = node?.params[from]; const value = node?.params[from];
if (!node || value === undefined) return; if (!node || value === undefined) return;
node.params[to] = value; node.params[to] = value;
@@ -90,7 +88,7 @@ export class AddPortsCommand implements Command {
} }
execute(project: Project): void { execute(project: Project): void {
const node = requirePortNode(project, this.assetId, this.nodeId); const node = requireNode(project, this.assetId, this.nodeId);
const keys = new Set((node.ports ?? []).map((p) => p.key)); const keys = new Set((node.ports ?? []).map((p) => p.key));
for (const port of this.ports) { for (const port of this.ports) {
if (!isValidPortName(port.key)) throw new CommandError(INVALID_NAME); if (!isValidPortName(port.key)) throw new CommandError(INVALID_NAME);
@@ -101,7 +99,7 @@ export class AddPortsCommand implements Command {
} }
undo(project: Project): void { undo(project: Project): void {
const node = requirePortNode(project, this.assetId, this.nodeId); const node = requireNode(project, this.assetId, this.nodeId);
const added = new Set(this.ports.map((p) => p.key)); const added = new Set(this.ports.map((p) => p.key));
node.ports = (node.ports ?? []).filter((p) => !added.has(p.key)); node.ports = (node.ports ?? []).filter((p) => !added.has(p.key));
} }
@@ -129,7 +127,7 @@ export class RemovePortCommand implements Command {
} }
execute(project: Project): void { execute(project: Project): void {
const node = requirePortNode(project, this.assetId, this.nodeId); const node = requireNode(project, this.assetId, this.nodeId);
const sites = portSites(project, this.assetId, node); const sites = portSites(project, this.assetId, node);
const ports = [...(node.ports ?? [])]; const ports = [...(node.ports ?? [])];
const removed = removeFirst(ports, (p) => p.key === this.key); const removed = removeFirst(ports, (p) => p.key === this.key);
@@ -152,7 +150,7 @@ export class RemovePortCommand implements Command {
undo(project: Project): void { undo(project: Project): void {
if (!this.removed) return; if (!this.removed) return;
const node = requirePortNode(project, this.assetId, this.nodeId); const node = requireNode(project, this.assetId, this.nodeId);
const ports = [...(node.ports ?? [])]; const ports = [...(node.ports ?? [])];
restoreAt(ports, [this.removed]); restoreAt(ports, [this.removed]);
node.ports = ports; node.ports = ports;
@@ -187,22 +185,14 @@ export class RenamePortCommand implements Command {
private rename(project: Project, from: string, to: string): void { private rename(project: Project, from: string, to: string): void {
if (from === to) return; if (from === to) return;
const node = requirePortNode(project, this.assetId, this.nodeId); const node = requireNode(project, this.assetId, this.nodeId);
const sites = portSites(project, this.assetId, node); const sites = portSites(project, this.assetId, node);
if (!isValidPortName(to)) throw new CommandError(INVALID_NAME); if (!isValidPortName(to)) throw new CommandError(INVALID_NAME);
const ports = node.ports ?? []; const ports = node.ports ?? [];
if (!ports.some((p) => p.key === from)) throw new CommandError(`Port "${from}" not found`); if (!ports.some((p) => p.key === from)) throw new CommandError(`Port "${from}" not found`);
if (ports.some((p) => p.key === to)) throw new CommandError(`"${to}" already exists`); if (ports.some((p) => p.key === to)) throw new CommandError(`"${to}" already exists`);
node.ports = ports.map((p) => (p.key === from ? { ...p, key: to } : p)); node.ports = ports.map((p) => (p.key === from ? { ...p, key: to } : p));
for (const site of sites) { for (const site of sites) carryPort(site, from, to);
for (const edge of site.graph.edges.filter(edgesOnPort(site, from))) {
if (site.asInput) edge.to.port = to;
else edge.from.port = to;
}
// An input keeps its typed-in value under the port key, so a rename has to carry it along
// or the value silently falls back to the default.
if (site.asInput) renameParam(site, from, to);
}
} }
} }
@@ -210,17 +200,12 @@ export class RenamePortCommand implements Command {
function renamePortOn(graph: Graph, node: GraphNode, from: string, to: string): void { function renamePortOn(graph: Graph, node: GraphNode, from: string, to: string): void {
const ports = node.ports ?? []; const ports = node.ports ?? [];
if (from === to || !ports.some((p) => p.key === from)) return; if (from === to || !ports.some((p) => p.key === from)) return;
const site: PortSite = {
graph,
nodeId: node.id,
asInput: requireNodeDef(node.type).declaresPorts === 'inputs',
};
node.ports = ports.map((p) => (p.key === from ? { ...p, key: to } : p)); node.ports = ports.map((p) => (p.key === from ? { ...p, key: to } : p));
for (const edge of graph.edges.filter(edgesOnPort(site, from))) { carryPort(
if (site.asInput) edge.to.port = to; { graph, nodeId: node.id, asInput: requireNodeDef(node.type).declaresPorts === 'inputs' },
else edge.from.port = to; from,
} to,
if (site.asInput) renameParam(site, from, to); );
} }
/** /**
@@ -242,7 +227,7 @@ export class SetVariablePortCommand implements Command {
} }
execute(project: Project): void { execute(project: Project): void {
const node = requirePortNode(project, this.assetId, this.nodeId); const node = requireNode(project, this.assetId, this.nodeId);
const ports = node.ports ?? []; const ports = node.ports ?? [];
const current = ports.find((p) => p.key === this.from); const current = ports.find((p) => p.key === this.from);
if (!current) throw new CommandError(`Port "${this.from}" not found`); if (!current) throw new CommandError(`Port "${this.from}" not found`);
@@ -254,7 +239,7 @@ export class SetVariablePortCommand implements Command {
undo(project: Project): void { undo(project: Project): void {
if (!this.previous) return; if (!this.previous) return;
this.swap(project, requirePortNode(project, this.assetId, this.nodeId), this.port.key, this.previous); this.swap(project, requireNode(project, this.assetId, this.nodeId), this.port.key, this.previous);
} }
private swap(project: Project, node: GraphNode, from: string, to: DeclaredPort): void { private swap(project: Project, node: GraphNode, from: string, to: DeclaredPort): void {
@@ -273,6 +258,5 @@ export function renameVariablePorts(graph: Graph, from: string, to: string): voi
/** "in1", "in2", … – the first port name the terminal node does not use yet. */ /** "in1", "in2", … – the first port name the terminal node does not use yet. */
export function nextPortName(node: GraphNode, base: string): string { export function nextPortName(node: GraphNode, base: string): string {
const taken = new Set((node.ports ?? []).map((p) => p.key)); return nextFieldName(node.ports ?? [], base);
for (let i = 1; ; i++) if (!taken.has(`${base}${i}`)) return `${base}${i}`;
} }
+3 -3
View File
@@ -4,7 +4,7 @@ import { type NodeDef, nodeDef } from '../graph/NodeRegistry';
import type { Asset, BehaviorAsset } from '../model/Asset'; import type { Asset, BehaviorAsset } from '../model/Asset';
import { findObject, type ObjectTree } from '../model/hierarchy'; import { findObject, type ObjectTree } from '../model/hierarchy';
import { isTemplateContent, objectRef, type ObjectRef } from '../model/instance'; import { isTemplateContent, objectRef, type ObjectRef } from '../model/instance';
import { findAsset, findObjectHolder, type ObjectHolder, type Project } from '../model/Project'; import { findAsset, findAssetOf, findObjectHolder, type ObjectHolder, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject'; import type { SceneObject } from '../model/SceneObject';
import { propDef, type PropertyDef } from '../properties/PropertySchema'; import { propDef, type PropertyDef } from '../properties/PropertySchema';
import { CommandError } from './Command'; import { CommandError } from './Command';
@@ -51,8 +51,8 @@ export function requireStructural(project: Project, holder: ObjectTree, objectId
} }
export function requireBehavior(project: Project, assetId: string): BehaviorAsset { export function requireBehavior(project: Project, assetId: string): BehaviorAsset {
const asset = findAsset(project, assetId); const asset = findAssetOf(project, assetId, 'behavior');
if (!asset || asset.type !== 'behavior') throw new CommandError(`Behavior ${assetId} not found`); if (!asset) throw new CommandError(`Behavior ${assetId} not found`);
return asset; return asset;
} }
+4 -4
View File
@@ -214,10 +214,6 @@ export class ClearOverrideCommand extends OverrideCommand {
} }
} }
/**
* Re-parents an object ('' = top level). It keeps its world position: the local one is recomputed
* for the new parent.
*/
/** /**
* Moves an object among its siblings. Sibling order is the order the stored list happens to have * Moves an object among its siblings. Sibling order is the order the stored list happens to have
* (`treeOrder`), so the move is a splice – and because objects sharing a `z` fall back to that * (`treeOrder`), so the move is a splice – and because objects sharing a `z` fall back to that
@@ -262,6 +258,10 @@ export class ReorderObjectCommand implements Command {
} }
} }
/**
* Re-parents an object ('' = top level). It keeps its world position: the local one is recomputed
* for the new parent.
*/
export class SetParentCommand implements Command { export class SetParentCommand implements Command {
readonly label = 'Set parent'; readonly label = 'Set parent';
private previous: { parentId: string; position: Position } = { private previous: { parentId: string; position: Position } = {
+11 -18
View File
@@ -12,7 +12,8 @@ import type { Project } from '../model/Project';
import { import {
coerceValue, coerceValue,
emptyFieldValue, emptyFieldValue,
isCommonProp, isValidFieldName,
isValidVariableName,
type ListItemKind, type ListItemKind,
type PrimitiveFieldDef, type PrimitiveFieldDef,
primitiveFieldsOf, primitiveFieldsOf,
@@ -146,16 +147,6 @@ export class RenameVariableCommand implements Command {
const INVALID_NAME = 'Variable names must start with a letter and contain only letters, digits or _'; const INVALID_NAME = 'Variable names must start with a letter and contain only letters, digits or _';
const INVALID_FIELD = 'Field names must start with a letter and contain only letters, digits or _'; const INVALID_FIELD = 'Field names must start with a letter and contain only letters, digits or _';
/** Identifier-like, and not a common property key – a variable "enabled" would shadow the switch. */
export function isValidVariableName(name: string): boolean {
return isValidFieldName(name) && !isCommonProp(name);
}
/** A field lives inside its struct, so the reserved property keys do not apply to it. */
export function isValidFieldName(name: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
}
/** Where declared fields live: on a struct variable, or on the struct items of a list variable. */ /** Where declared fields live: on a struct variable, or on the struct items of a list variable. */
interface FieldOwner { interface FieldOwner {
/** True for a list of structs, where a field may not itself be a list (`ListFieldDef`). */ /** True for a list of structs, where a field may not itself be a list (`ListFieldDef`). */
@@ -164,8 +155,12 @@ interface FieldOwner {
setFields(next: readonly StructFieldDef[]): void; setFields(next: readonly StructFieldDef[]): void;
} }
function variableOf(project: Project, assetId: string, key: string): PropertyDef | undefined {
return requireBehavior(project, assetId).variables.find((v) => v.key === key);
}
function requireFieldOwner(project: Project, assetId: string, key: string): FieldOwner { function requireFieldOwner(project: Project, assetId: string, key: string): FieldOwner {
const def = requireBehavior(project, assetId).variables.find((v) => v.key === key); const def = variableOf(project, assetId, key);
if (def?.kind === 'struct') if (def?.kind === 'struct')
return { return {
inList: false, inList: false,
@@ -182,10 +177,8 @@ function requireFieldOwner(project: Project, assetId: string, key: string): Fiel
} }
/** /**
* Every value of one variable that carries a struct: the declared default, the behavior's own, and * One struct-carrying value of a variable (default, own, override, or list item); a field change hits
* each instance override – and for a list of structs, every item of each of those. A field is added * all of them at once so none keeps a stale shape. `id` pairs a site up again on undo.
* to or taken from all of them at once, so no value keeps a shape its declaration no longer
* promises. The `id` is what pairs a site up again when the change is undone.
*/ */
interface StructSite { interface StructSite {
id: string; id: string;
@@ -194,7 +187,7 @@ interface StructSite {
} }
function structSites(project: Project, assetId: string, key: string): StructSite[] { function structSites(project: Project, assetId: string, key: string): StructSite[] {
const def = requireBehavior(project, assetId).variables.find((v) => v.key === key); const def = variableOf(project, assetId, key);
const holders = valueHolders(project, assetId, key).filter( const holders = valueHolders(project, assetId, key).filter(
// An override the object does not have stays absent: writing one here would invent a deviation. // An override the object does not have stays absent: writing one here would invent a deviation.
(h) => !h.optional || (def?.kind === 'list' ? isList(h.read()) : isStruct(h.read())), (h) => !h.optional || (def?.kind === 'list' ? isList(h.read()) : isStruct(h.read())),
@@ -440,7 +433,7 @@ function requireListDef(
assetId: string, assetId: string,
key: string, key: string,
): Extract<PropertyDef, { kind: 'list' }> { ): Extract<PropertyDef, { kind: 'list' }> {
const def = requireBehavior(project, assetId).variables.find((v) => v.key === key); const def = variableOf(project, assetId, key);
if (def?.kind !== 'list') throw new CommandError(`Variable "${key}" is not a list`); if (def?.kind !== 'list') throw new CommandError(`Variable "${key}" is not a list`);
return def; return def;
} }
-1
View File
@@ -57,7 +57,6 @@ export class GraphInterpreter {
return this.graph.nodes.filter((n) => n.type === type); return this.graph.nodes.filter((n) => n.type === type);
} }
/** Fires every event node of `type` passing `filter`, seeding its outputs with `outputs`. */
fire( fire(
type: string, type: string,
ctx: NodeContext, ctx: NodeContext,
+2 -4
View File
@@ -82,10 +82,8 @@ export interface NodeDef {
*/ */
derivePorts?(source: PortSource): DerivedShape; derivePorts?(source: PortSource): DerivedShape;
/** /**
* Pure data nodes are evaluated on demand when a downstream input needs their value; exec nodes * Event nodes have no `run` – the interpreter seeds their outputs. `def` is resolved for the node at
* run when the flow reaches them. Event nodes have no `run` – the interpreter seeds their outputs. * hand, the only way a node with derived ports knows how many it has.
* `def` is this definition as resolved for the node at hand, which is the only way a node with
* derived ports can know how many it has.
*/ */
run?(ctx: NodeContext, inputs: Record<string, Value>, params: PropertyBag, def: NodeDef): RunResult; run?(ctx: NodeContext, inputs: Record<string, Value>, params: PropertyBag, def: NodeDef): RunResult;
} }
+2 -5
View File
@@ -1,9 +1,6 @@
/** /**
* The auto-repeat of `On key down`, kept apart from the runtime so it can be checked without a * The auto-repeat of `On key down`, kept apart from the runtime so it can be tested without a world.
* world: given how long the key has been held, how often does the node fire this frame? * The browser's own repeat is dropped (`Input.press`): its delay and rate are an OS setting, not tunable.
*
* The browser's own key auto-repeat is deliberately not used (`Input.press` drops it): its delay
* and rate are an operating-system setting, not something a game can tune.
*/ */
/** Smallest repeat gap, mirroring `On timer`; 0 keeps its own meaning of "every frame". */ /** Smallest repeat gap, mirroring `On timer`; 0 keeps its own meaning of "every frame". */
+2 -1
View File
@@ -1,4 +1,5 @@
import { textOf } from '../model/Properties'; import { textOf } from '../model/Properties';
import { isValidFieldName } from '../properties/PropertySchema';
import { type DeclaredPort, declaredPortType, type Graph, type GraphNode } from './Graph'; import { type DeclaredPort, declaredPortType, type Graph, type GraphNode } from './Graph';
import type { BehaviorLookup } from './lookup'; import type { BehaviorLookup } from './lookup';
import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry'; import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry';
@@ -22,7 +23,7 @@ export function declaresVariablePorts(type: string): boolean {
/** Identifier-like, and not `exec`: that key belongs to the fixed flow port of every terminal node. */ /** Identifier-like, and not `exec`: that key belongs to the fixed flow port of every terminal node. */
export function isValidPortName(name: string): boolean { export function isValidPortName(name: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && name !== 'exec'; return isValidFieldName(name) && name !== 'exec';
} }
/** The single start (or end) node of a graph; a second one is ignored. */ /** The single start (or end) node of a graph; a second one is ignored. */
+2 -2
View File
@@ -7,6 +7,7 @@ import {
type PropertyValue, type PropertyValue,
type StructValue, type StructValue,
textOf, textOf,
booleanOf,
} from '../model/Properties'; } from '../model/Properties';
import { ENTITY_HANDLE, type EntityHandle } from './NodeContext'; import { ENTITY_HANDLE, type EntityHandle } from './NodeContext';
@@ -57,8 +58,7 @@ function asPrimitive(value: Value | undefined): number | string | boolean {
} }
export function asBoolean(value: Value | undefined): boolean { export function asBoolean(value: Value | undefined): boolean {
if (typeof value === 'string') return value !== '' && value !== 'false' && value !== '0'; return booleanOf(value);
return Boolean(value);
} }
/** Entities read as their name, so a handle can be logged or joined into text directly. */ /** Entities read as their name, so a handle can be logged or joined into text directly. */
+9
View File
@@ -23,6 +23,15 @@ export function findAsset(project: Project, assetId: string): Asset | undefined
return project.assets.find((a) => a.id === assetId); return project.assets.find((a) => a.id === assetId);
} }
export function findAssetOf<T extends Asset['type']>(
project: Project,
assetId: string,
type: T,
): Extract<Asset, { type: T }> | undefined {
const asset = findAsset(project, assetId);
return asset?.type === type ? (asset as Extract<Asset, { type: T }>) : undefined;
}
export function findScene(project: Project, sceneId: string): SceneAsset | undefined { export function findScene(project: Project, sceneId: string): SceneAsset | undefined {
const asset = findAsset(project, sceneId); const asset = findAsset(project, sceneId);
return asset && isSceneAsset(asset) ? asset : undefined; return asset && isSceneAsset(asset) ? asset : undefined;
+14 -6
View File
@@ -1,9 +1,4 @@
/** /** The declarations cap depth: `list → struct → primitive` is the deepest a stored value goes. */
* A list holds primitives or structs. Depth stops there and it stops in the declaration, not by
* convention: a list says what its items are (`ListItemKind`, struct included), a struct field
* says what it holds (`FieldKind`, primitives only). So `list → struct → primitive` is the deepest
* a stored value goes, which is what keeps the deep compare and the editor finite.
*/
export type ListValue = readonly (number | string | boolean | StructValue)[]; export type ListValue = readonly (number | string | boolean | StructValue)[];
/** What a struct field may hold. A field is never itself a struct – see `ListValue`. */ /** What a struct field may hold. A field is never itself a struct – see `ListValue`. */
@@ -40,6 +35,19 @@ export function textOf(value: PropertyValue | undefined): string {
return String(value); return String(value);
} }
/** The spelling of a text counts: `Boolean('false')` would be true. */
export function booleanOf(value: unknown): boolean {
if (typeof value !== 'string') return Boolean(value);
const s = value.trim().toLowerCase();
return !(s === '' || s === 'false' || s === '0' || s === 'no');
}
/** A number, or a hex text with or without `#`, clamped to 0…0xffffff. */
export function colorOf(value: PropertyValue, fallback: number): number {
const n = typeof value === 'number' ? value : parseInt(textOf(value).replace('#', ''), 16);
return Number.isFinite(n) ? Math.max(0, Math.min(0xffffff, Math.floor(n))) : fallback;
}
/** Values are compared by content, because two equal lists (or structs) are never the same object. */ /** Values are compared by content, because two equal lists (or structs) are never the same object. */
export function sameValue(a: PropertyValue | undefined, b: PropertyValue | undefined): boolean { export function sameValue(a: PropertyValue | undefined, b: PropertyValue | undefined): boolean {
if (isList(a) || isList(b)) { if (isList(a) || isList(b)) {
+3 -3
View File
@@ -1,6 +1,6 @@
import type { BehaviorLookup } from '../graph/lookup'; import type { BehaviorLookup } from '../graph/lookup';
import { resolveAssetProps } from '../properties/resolve'; import { resolveAssetProps } from '../properties/resolve';
import { findAsset, type Project } from './Project'; import { findAssetOf, type Project } from './Project';
/** /**
* What a `Run behavior` node needs from the project. `defaults` are the asset's variable values; * What a `Run behavior` node needs from the project. `defaults` are the asset's variable values;
@@ -8,8 +8,8 @@ import { findAsset, type Project } from './Project';
*/ */
export function behaviorLookup(project: Project): BehaviorLookup { export function behaviorLookup(project: Project): BehaviorLookup {
return (assetId) => { return (assetId) => {
const asset = findAsset(project, assetId); const asset = findAssetOf(project, assetId, 'behavior');
if (!asset || asset.type !== 'behavior') return null; if (!asset) return null;
return { name: asset.name, graph: asset.graph, defaults: resolveAssetProps(asset) }; return { name: asset.name, graph: asset.graph, defaults: resolveAssetProps(asset) };
}; };
} }
+9 -24
View File
@@ -1,14 +1,8 @@
/** /**
* A container placed in a scene is not copied into it. The scene stores the container object * A placed container's content is resolved from its template on every read. Per-instance changes live
* alone; its content is resolved from the template on every read, so a template edit reaches * in sparse `instanceEdits` keyed by template-id path; derived ids `<instance>~<path>` are never stored.
* every instance at once. What the user changes on one of those resolved objects is kept as a
* sparse `InstanceEdit` on the instance, keyed by the path of template ids leading to it – and
* only where it actually deviates, which is what makes an untouched value keep following.
*
* Ids of resolved content are derived (`<instance id>~<path>`), never stored, and stable as long
* as the template is: the selection, the hierarchy and the viewport address them like any other.
*/ */
import type { Asset, ContainerAsset } from './Asset'; import type { ContainerAsset } from './Asset';
import type { ObjectTree } from './hierarchy'; import type { ObjectTree } from './hierarchy';
import { import {
type Axis, type Axis,
@@ -19,7 +13,7 @@ import {
setBagValue, setBagValue,
type Vec2, type Vec2,
} from './Properties'; } from './Properties';
import { findAsset, type ObjectHolder, objectHolders, type Project } from './Project'; import { findAssetOf, type ObjectHolder, objectHolders, type Project } from './Project';
import type { SceneObject } from './SceneObject'; import type { SceneObject } from './SceneObject';
/** Neither character can occur inside an id (see `newId`), so a derived id parses back uniquely. */ /** Neither character can occur inside an id (see `newId`), so a derived id parses back uniquely. */
@@ -77,8 +71,7 @@ function mergeEdits(
} }
export function containerAssetOf(project: Project, object: SceneObject): ContainerAsset | undefined { export function containerAssetOf(project: Project, object: SceneObject): ContainerAsset | undefined {
const asset: Asset | undefined = findAsset(project, object.assetId); return findAssetOf(project, object.assetId, 'container');
return asset?.type === 'container' ? asset : undefined;
} }
/** /**
@@ -203,12 +196,8 @@ export interface InstanceTarget {
asset: ContainerAsset; asset: ContainerAsset;
template: SceneObject; template: SceneObject;
/** /**
* What the object would show without this instance's edit: the template with the edits of every * The template with the edits of every container in between woven in – this, not `template`, decides
* container in between already woven in. Only this is a base a value can fall back to, so it – * whether an edit deviates. Built fresh on every read, since "apply" writes into `template` first.
* not `template` – decides whether an edit deviates. The two differ once containers nest.
*
* Built fresh on every read, because "apply" writes into `template` and then asks whether the
* edit still deviates – a base captured beforehand would answer for the old template.
*/ */
readonly base: SceneObject; readonly base: SceneObject;
} }
@@ -219,8 +208,6 @@ export function instanceTargetOf(
holder: ObjectTree, holder: ObjectTree,
objectId: string, objectId: string,
): InstanceTarget | undefined { ): InstanceTarget | undefined {
// Unpacking keeps the derived ids, so the shape of the id alone still looks like content of a
// template afterwards. What decides is whether the object is spelled out in the holder.
if (!isTemplateContent(holder, objectId)) return undefined; if (!isTemplateContent(holder, objectId)) return undefined;
const split = splitInstanceId(objectId); const split = splitInstanceId(objectId);
const instance = split && holder.objects.find((o) => o.id === split.instanceId); const instance = split && holder.objects.find((o) => o.id === split.instanceId);
@@ -388,10 +375,8 @@ export function restoreObject(memo: ObjectMemo): void {
} }
/** /**
* One sparse bag of property overrides in the project, with the asset whose schema governs it: * One sparse override bag with the asset whose schema governs it: a placed object's own, or a container
* a placed object's own, or the deviations a container instance keeps for its content. Anything * instance's edits for its content. Project-wide rewrites must cover both or they miss container content.
* rewriting overrides across the project (removing or renaming a behavior variable, counting the
* uses of a texture) has to cover both, or it silently misses everything inside a container.
*/ */
export interface OverrideSite { export interface OverrideSite {
holder: ObjectHolder; holder: ObjectHolder;
+18 -26
View File
@@ -9,6 +9,8 @@ import {
type PropertyValue, type PropertyValue,
type StructValue, type StructValue,
textOf, textOf,
booleanOf,
colorOf,
} from '../model/Properties'; } from '../model/Properties';
/** The inspector is generated from these entries, so a new property is one line in a schema below. */ /** The inspector is generated from these entries, so a new property is one line in a schema below. */
@@ -81,12 +83,7 @@ export interface PrimitiveFieldDef {
kind: FieldKind; kind: FieldKind;
} }
/** /** A list held by a struct field; its items are primitives only, which keeps storable shapes finite. */
* A list held by a struct field. Its items are primitives, and that asymmetry is the whole point:
* a list may hold structs and a struct may hold lists, so only forbidding one of the two here
* keeps the storable shapes finite. `list → struct → primitive` and `struct → list → primitive`
* are the two deepest, which is exactly what `asStoredValue` lets through.
*/
export interface ListFieldDef { export interface ListFieldDef {
key: string; key: string;
kind: 'list'; kind: 'list';
@@ -116,6 +113,16 @@ export function isCommonProp(key: string): boolean {
return COMMON_KEYS.has(key); return COMMON_KEYS.has(key);
} }
/** A field lives inside its struct, so the reserved property keys do not apply to it. */
export function isValidFieldName(name: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
}
/** Identifier-like, and not a common property key – a variable "enabled" would shadow the switch. */
export function isValidVariableName(name: string): boolean {
return isValidFieldName(name) && !isCommonProp(name);
}
/** Position is deliberately absent: it belongs to scene objects, not to asset types. */ /** Position is deliberately absent: it belongs to scene objects, not to asset types. */
const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = { const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
scene: [{ key: 'backgroundColor', label: 'Background', kind: 'color', default: 0x2a2d34 }], scene: [{ key: 'backgroundColor', label: 'Background', kind: 'color', default: 0x2a2d34 }],
@@ -262,10 +269,6 @@ const EMPTY_FIELD: Record<FieldKind, number | string | boolean> = {
color: 0, color: 0,
}; };
export function emptyField(kind: FieldKind): number | string | boolean {
return EMPTY_FIELD[kind];
}
export function emptyFieldValue(field: StructFieldDef): FieldValue { export function emptyFieldValue(field: StructFieldDef): FieldValue {
return field.kind === 'list' ? [] : EMPTY_FIELD[field.kind]; return field.kind === 'list' ? [] : EMPTY_FIELD[field.kind];
} }
@@ -283,7 +286,6 @@ export function structOf(fields: readonly StructFieldDef[], from: PropertyValue
return value; return value;
} }
/** Clamps / coerces a raw value so it satisfies the definition. */
export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyValue { export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyValue {
switch (def.kind) { switch (def.kind) {
case 'number': { case 'number': {
@@ -293,12 +295,10 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal
if (def.max !== undefined) n = Math.min(def.max, n); if (def.max !== undefined) n = Math.min(def.max, n);
return n; return n;
} }
case 'color': { case 'color':
const n = typeof value === 'number' ? value : parseInt(textOf(value).replace('#', ''), 16); return colorOf(value, def.default);
return Number.isFinite(n) ? Math.max(0, Math.min(0xffffff, Math.floor(n))) : def.default;
}
case 'boolean': case 'boolean':
return Boolean(value); return booleanOf(value);
case 'text': case 'text':
case 'key': case 'key':
case 'asset': case 'asset':
@@ -333,16 +333,8 @@ export function coerceFieldValue(value: PropertyValue, field: StructFieldDef): F
/** The primitive half of `coerceListItem`, split off because a struct field is never a struct. */ /** The primitive half of `coerceListItem`, split off because a struct field is never a struct. */
export function coerceField(value: PropertyValue, items: FieldKind): number | string | boolean { export function coerceField(value: PropertyValue, items: FieldKind): number | string | boolean {
if (items === 'text') return textOf(value); if (items === 'text') return textOf(value);
if (items === 'boolean') { if (items === 'boolean') return typeof value === 'boolean' ? value : booleanOf(textOf(value));
if (typeof value === 'boolean') return value; if (items === 'color') return colorOf(value, 0);
// A list is typed as text, so the spelling has to count: Boolean('false') would be true.
const s = textOf(value).trim().toLowerCase();
return !(s === '' || s === 'false' || s === '0' || s === 'no');
}
if (items === 'color') {
const c = typeof value === 'number' ? value : parseInt(textOf(value).replace('#', ''), 16);
return Number.isFinite(c) ? Math.max(0, Math.min(0xffffff, Math.floor(c))) : 0;
}
const n = typeof value === 'number' ? value : Number(value); const n = typeof value === 'number' ? value : Number(value);
return Number.isFinite(n) ? n : 0; return Number.isFinite(n) ? n : 0;
} }
+2 -4
View File
@@ -50,10 +50,8 @@ export function matchesPixelSize(size: PixelSize, props: PropertyBag): boolean {
} }
/** /**
* Whether the sprite already has the picture's proportions. Compared as a cross product with a * Cross product with a relative tolerance, because 3135 of the 40 000 sizes up to 200 × 200 fail an
* relative tolerance, because fitting one side and then asking about the other must not answer * exact test right after their own fit (rounding).
* "no" over a rounding error: 3135 of the 40 000 sizes up to 200 × 200 fail an exact test after
* their own fit (1 × 11 at height 100 gives 9.090909090909092, whose cross product is 100.000…1).
*/ */
export function matchesAspect(size: PixelSize, props: PropertyBag): boolean { export function matchesAspect(size: PixelSize, props: PropertyBag): boolean {
const w = Number(props.width); const w = Number(props.width);
+1 -6
View File
@@ -1,9 +1,4 @@
/** /** A behavior's `runInEditor` switch: its writes are real, saved and not undoable, hence off by default. */
* The `runInEditor` switch of a behavior (see `ASSET_SCHEMAS.behavior`). A behavior with it on
* keeps running while the project is *not* playing, and then it edits the saved model: what it
* moves or writes is a real change that is saved and is not on the undo stack. That is what makes
* the switch useful (a behavior can build or arrange content) and why it is off by default.
*/
import type { ObjectTree } from '../model/hierarchy'; import type { ObjectTree } from '../model/hierarchy';
import { resolveObjects } from '../model/instance'; import { resolveObjects } from '../model/instance';
import type { PropertyBag } from '../model/Properties'; import type { PropertyBag } from '../model/Properties';
-4
View File
@@ -26,10 +26,6 @@ export function resolveSceneObjectProps(asset: Asset, object: SceneObject): Prop
return bag; return bag;
} }
export function isOverridden(object: SceneObject, key: string): boolean {
return object.overrides[key] !== undefined;
}
/** Whether the asset's own value differs from the schema default – the asset-level override. */ /** Whether the asset's own value differs from the schema default – the asset-level override. */
export function isModified(asset: Asset, key: string): boolean { export function isModified(asset: Asset, key: string): boolean {
const def = propDef(asset, key); const def = propDef(asset, key);
+4 -3
View File
@@ -23,7 +23,8 @@ import {
COMMON_PROPS, COMMON_PROPS,
FIELD_KINDS, FIELD_KINDS,
type FieldKind, type FieldKind,
isCommonProp, isValidFieldName,
isValidVariableName,
LIST_ITEM_KINDS, LIST_ITEM_KINDS,
type ListItemKind, type ListItemKind,
type PropertyDef, type PropertyDef,
@@ -174,7 +175,7 @@ function normalizeVariables(raw: unknown): PropertyDef[] {
? (v.kind as VariableKind) ? (v.kind as VariableKind)
: 'number'; : 'number';
// Common property keys are reserved – a variable of that name would shadow the switch. // Common property keys are reserved – a variable of that name would shadow the switch.
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || seen.has(key) || isCommonProp(key)) continue; if (!isValidVariableName(key) || seen.has(key)) continue;
seen.add(key); seen.add(key);
const items = (LIST_ITEM_KINDS as readonly string[]).includes(String(v.items)) const items = (LIST_ITEM_KINDS as readonly string[]).includes(String(v.items))
? (v.items as ListItemKind) ? (v.items as ListItemKind)
@@ -193,7 +194,7 @@ function normalizeStructFields(raw: unknown): StructFieldDef[] {
for (const item of Array.isArray(raw) ? raw : []) { for (const item of Array.isArray(raw) ? raw : []) {
const f = asRecord(item); const f = asRecord(item);
const key = typeof f.key === 'string' ? f.key : ''; const key = typeof f.key === 'string' ? f.key : '';
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || seen.has(key)) continue; if (!isValidFieldName(key) || seen.has(key)) continue;
const field = readField(key, f); const field = readField(key, f);
if (!field) continue; if (!field) continue;
seen.add(key); seen.add(key);
+2 -6
View File
@@ -30,12 +30,8 @@ export interface AssetTabOptions {
} }
/** /**
* The Graph, Texture and Container tabs. Each follows the selection but keeps showing its last * The Graph, Texture and Container tabs; each keeps its last asset when the selection moves elsewhere.
* asset when the selection moves elsewhere – clicking a sprite must not empty the tab being * A subclass must call `mount()`: header and canvas depend on each other, so the base cannot build them.
* worked in. "Nothing shown" is `''` throughout.
*
* A subclass finishes construction by calling `mount()`: the header needs the canvas (its Fit
* button) and the canvas needs `state`, so the base cannot build one before the other exists.
*/ */
export abstract class AssetTabEditor< export abstract class AssetTabEditor<
A extends Asset, A extends Asset,
-1
View File
@@ -239,7 +239,6 @@ export class Editor implements PlayHost {
this.editorRun.tick(dt); this.editorRun.tick(dt);
} }
/** Refreshes the toolbar readout twice a second. */
private measureFps(dt: number): void { private measureFps(dt: number): void {
this.fpsFrames += 1; this.fpsFrames += 1;
this.fpsElapsed += dt; this.fpsElapsed += dt;
+4 -14
View File
@@ -1,13 +1,6 @@
/** /** Runs "run in editor" behaviors outside play mode, on a world built from the saved model, not a copy. */
* Keeps the behaviors switched to "run in editor" running while the project is *not* playing.
*
* The one thing that makes this different from Play: the world is built from the saved model, not
* from a copy. What such a behavior moves or writes is a real edit – it is saved and it is not on
* the undo stack.
*/
import { hasEditorRun, runsInEditor } from '../core/properties/editorRun'; import { hasEditorRun, runsInEditor } from '../core/properties/editorRun';
import { BehaviorSystem } from '../runtime/systems/BehaviorSystem'; import { addGameSystems, type BehaviorSystem, behaviorError } from '../runtime/systems/BehaviorSystem';
import { CollisionSystem } from '../runtime/systems/CollisionSystem';
import { Input } from '../runtime/systems/Input'; import { Input } from '../runtime/systems/Input';
import { type ModelWrite, World } from '../runtime/World'; import { type ModelWrite, World } from '../runtime/World';
import type { EditorState } from './state'; import type { EditorState } from './state';
@@ -96,10 +89,7 @@ export class EditorRun {
// Baking a container's content into the scene would be an edit nobody asked for, so it is off // Baking a container's content into the scene would be an edit nobody asked for, so it is off
// here: the content runs, but resolved from its template (see `WorldOptions.bakeContainers`). // here: the content runs, but resolved from its template (see `WorldOptions.bakeContainers`).
const fresh = World.instantiate(project, scene.id, { bakeContainers: false }); const fresh = World.instantiate(project, scene.id, { bakeContainers: false });
const collisions = new CollisionSystem(); this.behaviors = addGameSystems(fresh, project, this.input, (e) => runsInEditor(e.props));
this.behaviors = new BehaviorSystem(project, this.input, collisions, (e) => runsInEditor(e.props));
fresh.addSystem(collisions);
fresh.addSystem(this.behaviors);
this.world = fresh; this.world = fresh;
this.hadFocus = this.hooks.focused(); this.hadFocus = this.hooks.focused();
this.input.attach(this.hooks.focused); this.input.attach(this.hooks.focused);
@@ -116,7 +106,7 @@ export class EditorRun {
return true; return true;
} }
if (!world.stopRequested) return false; if (!world.stopRequested) return false;
const error = world.log.find((line) => line.startsWith('Error in behavior')); const error = behaviorError(world);
this.stop(); this.stop();
this.state.setNotice(error ?? 'A behavior running in the editor stopped it.'); this.state.setNotice(error ?? 'A behavior running in the editor stopped it.');
return true; return true;
+8 -8
View File
@@ -4,8 +4,12 @@
* from the runtime copy `state.enterPlay()` swaps in and `exitPlay()` throws away. * from the runtime copy `state.enterPlay()` swaps in and `exitPlay()` throws away.
*/ */
import { worldPositionOf } from '../core/graph'; import { worldPositionOf } from '../core/graph';
import { BehaviorSystem, type Trace } from '../runtime/systems/BehaviorSystem'; import {
import { CollisionSystem } from '../runtime/systems/CollisionSystem'; addGameSystems,
type BehaviorSystem,
behaviorError,
type Trace,
} from '../runtime/systems/BehaviorSystem';
import { Input } from '../runtime/systems/Input'; import { Input } from '../runtime/systems/Input';
import { World } from '../runtime/World'; import { World } from '../runtime/World';
import { ui } from '../ui'; import { ui } from '../ui';
@@ -83,11 +87,7 @@ export class PlaySession {
const world = World.instantiate(runtime, scene.id); const world = World.instantiate(runtime, scene.id);
this.world = world; this.world = world;
this.input.attach(); this.input.attach();
// Collisions are detected first so behaviors see this frame's enter/stay/exit events. this.behaviors = addGameSystems(world, runtime, this.input);
const collisions = new CollisionSystem();
this.behaviors = new BehaviorSystem(runtime, this.input, collisions);
world.addSystem(collisions);
world.addSystem(this.behaviors);
this.host.gameView.setWorld(world); this.host.gameView.setWorld(world);
this.host.showGame(); this.host.showGame();
this.state.setNotice(this.playNotice()); this.state.setNotice(this.playNotice());
@@ -111,7 +111,7 @@ export class PlaySession {
this.host.gameView.setWorld(null); this.host.gameView.setWorld(null);
this.state.exitPlay(); this.state.exitPlay();
this.host.leaveGame(); this.host.leaveGame();
const lastError = world?.log.find((line) => line.startsWith('Error in behavior')); const lastError = world && behaviorError(world);
this.state.setNotice(lastError ?? ''); this.state.setNotice(lastError ?? '');
} }
+16 -5
View File
@@ -4,11 +4,13 @@
*/ */
import { DeleteAssetCommand } from '../core/commands/assetCommands'; import { DeleteAssetCommand } from '../core/commands/assetCommands';
import { AddColliderCommand } from '../core/commands/colliderCommands'; import { AddColliderCommand } from '../core/commands/colliderCommands';
import { CompoundCommand } from '../core/commands/CompoundCommand'; import { oneStep } from '../core/commands/CompoundCommand';
import { CreateContainerCommand } from '../core/commands/containerCommands'; import { CreateContainerCommand } from '../core/commands/containerCommands';
import { RemoveNodeCommand } from '../core/commands/graphCommands'; import { RemoveNodeCommand } from '../core/commands/graphCommands';
import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands'; import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands';
import { findAsset } from '../core/model/Project'; import { isLiveInstance, isTemplateContent } from '../core/model/instance';
import { findAsset, type ObjectHolder, type Project } from '../core/model/Project';
import type { SceneObject } from '../core/model/SceneObject';
import { textureRefIds } from '../core/model/texture'; import { textureRefIds } from '../core/model/texture';
import { findPropertyUsages, findUsages } from '../core/model/usages'; import { findPropertyUsages, findUsages } from '../core/model/usages';
import { Dialog } from '../ui'; import { Dialog } from '../ui';
@@ -22,6 +24,17 @@ export function createContainer(state: EditorState, holderId: string, objectIds:
return true; return true;
} }
/** The test `AddColliderCommand` makes, so no place offers what it would refuse. */
export function canAddCollider(project: Project, holder: ObjectHolder, object: SceneObject): boolean {
const type = findAsset(project, object.assetId)?.type;
return (
type !== undefined &&
type !== 'collider' &&
!isTemplateContent(holder, object.id) &&
!isLiveInstance(project, holder, object)
);
}
/** Gives the object a collider child sized like it; selects the collider. */ /** Gives the object a collider child sized like it; selects the collider. */
export function addCollider(state: EditorState, holderId: string, objectId: string): boolean { export function addCollider(state: EditorState, holderId: string, objectId: string): boolean {
const command = new AddColliderCommand(holderId, objectId); const command = new AddColliderCommand(holderId, objectId);
@@ -38,9 +51,7 @@ export function removeObjects(state: EditorState, holderId: string, objectIds: r
/** Deletes graph nodes as one undo step, however many were selected. */ /** Deletes graph nodes as one undo step, however many were selected. */
export function removeNodes(state: EditorState, assetId: string, nodeIds: readonly string[]): boolean { export function removeNodes(state: EditorState, assetId: string, nodeIds: readonly string[]): boolean {
const commands = nodeIds.map((id) => new RemoveNodeCommand(assetId, id)); const commands = nodeIds.map((id) => new RemoveNodeCommand(assetId, id));
if (commands.length === 0) return false; return commands.length > 0 && state.execute(oneStep(`Delete ${commands.length} nodes`, commands));
if (commands.length === 1) return state.execute(commands[0]!);
return state.execute(new CompoundCommand(`Delete ${commands.length} nodes`, commands));
} }
/** Asks before deleting an asset, spelling out what goes with it. */ /** Asks before deleting an asset, spelling out what goes with it. */
+2 -6
View File
@@ -171,9 +171,9 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
viewportInfo: () => viewport.debugInfo(), viewportInfo: () => viewport.debugInfo(),
/** Where a dragged asset would land in the Scene tab, and why not when it may not. */ /** Where a dragged asset would land in the Scene tab, and why not when it may not. */
dropPreview: () => viewport.dropInfo(), dropPreview: () => viewport.dropInfo(),
/** An asset row by id – two assets can share a name with the container picker's label. */
/** The panel-to-view drag in flight, if any – a drop that never started looks like a refused one. */ /** The panel-to-view drag in flight, if any – a drop that never started looks like a refused one. */
dragPayload: () => activeDrag(), dragPayload: () => activeDrag(),
/** An asset row by id – two assets can share a name with the container picker's label. */
assetRow: (id: string) => assets.rowGlobal(id), assetRow: (id: string) => assets.rowGlobal(id),
assetRowColor: (id: string) => assets.rowColor(id), assetRowColor: (id: string) => assets.rowColor(id),
gameInfo: () => gameView.debugInfo(), gameInfo: () => gameView.debugInfo(),
@@ -254,11 +254,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
const hex = (value: number | undefined) => (value ?? 0).toString(16).padStart(2, '0'); const hex = (value: number | undefined) => (value ?? 0).toString(16).padStart(2, '0');
return `#${hex(pixels[0])}${hex(pixels[1])}${hex(pixels[2])}`; return `#${hex(pixels[0])}${hex(pixels[1])}${hex(pixels[2])}`;
}, },
/** /** The filter the GPU sampler actually got, next to the model's – only the driver knows the former. */
* What the GPU was actually told about a picture's filter, next to what the model asked for.
* The two can disagree – that is the difference between a wrong value and a value that never
* reached the sampler – and only the driver can be asked for the second one.
*/
textureFilter: (file: string) => { textureFilter: (file: string) => {
const texture = textureLibrary.peek(file); const texture = textureLibrary.peek(file);
if (!texture) return null; if (!texture) return null;
+1 -6
View File
@@ -1,9 +1,4 @@
/** /** Geometry of the graph view (rubber band, fit, drag), kept out of the canvas so tests can reach it. */
* Geometry of the graph view: which nodes a rubber band caught, what view frames them all, and
* where a drag puts them. All three are decisions about rectangles, so they live here rather than
* in the canvas – the "fit" that left nodes hanging out of the view was a sum that no test could
* reach.
*/
import { snapToStep } from '../ui/math'; import { snapToStep } from '../ui/math';
+172
View File
@@ -0,0 +1,172 @@
import { Container, Graphics, Text } from 'pixi.js';
import { type GraphEdge, portsCompatible } from '../../core/graph';
import { Theme, textStyle, truncate } from '../../ui';
import {
buildFoldMarks,
foldedEdgeShown,
type FoldMarkEnd,
foldMarkAt,
foldMarkKey,
foldMarkLabel,
foldMarkRadius,
} from './folding';
import { type NodeView, portColor } from './NodeView';
/** How far a folded edge's stub reaches out of its port. */
const FOLD_STUB = 16;
const FOLD_MARK_RADIUS = 5;
const FOLD_LABEL_WIDTH = 96;
/** One port's hidden links as drawn: what they stand in for, and where they can be clicked. */
export interface FoldMark {
edgeIds: string[];
/** The nodes at the other ends, deduped – clicking selects them all. */
others: string[];
isOutput: boolean;
x: number;
y: number;
text: string;
}
/** The curves between nodes and the marks of the hidden ones, in graph coordinates. */
export class EdgeLayer extends Container {
private readonly curves = new Graphics();
private readonly chips = new Graphics();
/** Texts naming the far node of each folded edge; pooled, since a redraw rebuilds them all. */
private readonly labelLayer = new Container();
private readonly labels: Text[] = [];
private labelCount = 0;
private ends: FoldMarkEnd[] = [];
private marks: FoldMark[] = [];
/** Edges the last draw really stroked as a curve – a hidden one is missing from it. */
private readonly stroked = new Set<string>();
constructor() {
super();
this.addChild(this.curves, this.chips, this.labelLayer);
}
draw(
edges: readonly GraphEdge[],
views: ReadonlyMap<string, NodeView>,
isHot: (edgeId: string) => boolean,
selected: Set<string>,
): void {
this.curves.clear();
this.chips.clear();
this.marks = [];
this.ends = [];
this.labelCount = 0;
this.stroked.clear();
for (const edge of edges) this.drawEdge(edge, views, isHot(edge.id), selected);
// After the curves, so no line drawn later crosses a mark that stands for a hidden one.
this.drawFoldMarks();
for (let i = this.labelCount; i < this.labels.length; i++) this.labels[i]!.visible = false;
}
markAt(g: { x: number; y: number }): FoldMark | null {
return foldMarkAt(this.marks, g, FOLD_MARK_RADIUS);
}
drawnMarks(): readonly FoldMark[] {
return this.marks;
}
wasStroked(edgeId: string): boolean {
return this.stroked.has(edgeId);
}
private drawEdge(
edge: GraphEdge,
views: ReadonlyMap<string, NodeView>,
hot: boolean,
selected: Set<string>,
): void {
const from = views.get(edge.from.node);
const to = views.get(edge.to.node);
const a = from?.portPosition(edge.from.port, true);
const b = to?.portPosition(edge.to.port, false);
if (!from || !to || !a || !b) return;
const type = from.def.outputs.find((p) => p.key === edge.from.port)?.type ?? 'any';
const inType = to.def.inputs.find((p) => p.key === edge.to.port)?.type ?? 'any';
const valid = portsCompatible(type, inType);
const color = hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger;
const start = { x: from.x + a.x, y: from.y + a.y };
const end = { x: to.x + b.x, y: to.y + b.y };
if (edge.folded) {
this.addFoldEnd(edge, views, start, true, to.def.label, color, hot);
this.addFoldEnd(edge, views, end, false, from.def.label, color, hot);
if (!foldedEdgeShown(edge, selected, hot)) return;
}
bezier(this.curves, start.x, start.y, end.x, end.y);
this.curves.stroke({
width: hot ? 4 : type === 'exec' ? 2.5 : 2,
color,
// Dimmed while folded, so a curve that is only on loan is not mistaken for a plain one.
alpha: (type === 'exec' || hot ? 1 : 0.85) * (edge.folded ? 0.6 : 1),
});
this.stroked.add(edge.id);
}
private addFoldEnd(
edge: GraphEdge,
views: ReadonlyMap<string, NodeView>,
port: { x: number; y: number },
isOutput: boolean,
otherLabel: string,
color: number,
hot: boolean,
): void {
const [side, other] = isOutput ? [edge.from, edge.to.node] : [edge.to, edge.from.node];
this.ends.push({
edgeId: edge.id,
key: foldMarkKey(side, isOutput, views.get(side.node)?.collapsed),
other,
otherLabel,
isOutput,
portX: port.x,
portY: port.y,
color,
hot,
});
}
/** A stub out of the port, a dot, and what waits at the other end – one per port, not per link. */
private drawFoldMarks(): void {
for (const mark of buildFoldMarks(this.ends, FOLD_STUB)) {
const { x, y, color, isOutput } = mark;
this.curves.moveTo(mark.portX, y).lineTo(x, y).stroke({ width: 2, color, alpha: 0.9 });
const radius = foldMarkRadius(mark.edgeIds.length, FOLD_MARK_RADIUS);
this.curves.circle(x, y, radius).fill(color);
this.curves.circle(x, y, radius).stroke({ width: 1, color: Theme.colors.node });
const label = this.nextLabel();
label.text = truncate(foldMarkLabel(mark.names), textStyle('small'), FOLD_LABEL_WIDTH);
const gap = radius + 4;
const lx = isOutput ? x + gap : x - gap - label.width;
label.position.set(lx, y - label.height / 2);
// Without a plate behind it the name blurs into whatever curves pass behind the mark.
this.chips
.roundRect(lx - 3, y - label.height / 2 - 1, label.width + 6, label.height + 2, 3)
.fill({ color: Theme.colors.node, alpha: 0.85 });
this.marks.push({ edgeIds: mark.edgeIds, others: mark.others, isOutput, x, y, text: label.text });
}
}
private nextLabel(): Text {
let label = this.labels[this.labelCount];
if (!label) {
label = new Text({ text: '', style: textStyle('small') });
this.labels.push(label);
this.labelLayer.addChild(label);
}
label.visible = true;
this.labelCount++;
return label;
}
}
/** Horizontal S-curve between two points. */
export function bezier(g: Graphics, x1: number, y1: number, x2: number, y2: number): void {
const dx = Math.max(40, Math.abs(x2 - x1) * 0.5);
g.moveTo(x1, y1).bezierCurveTo(x1 + dx, y1, x2 - dx, y2, x2, y2);
}
+81 -326
View File
@@ -1,13 +1,11 @@
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js'; import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import { textOf } from '../../core/model/Properties'; import { textOf } from '../../core/model/Properties';
import type { Command } from '../../core/commands/Command'; import type { Command } from '../../core/commands/Command';
import { CompoundCommand } from '../../core/commands/CompoundCommand'; import { oneStep } from '../../core/commands/CompoundCommand';
import { import {
AddNodeCommand, AddNodeCommand,
CollapseNodesCommand, CollapseNodesCommand,
ConnectCommand, ConnectCommand,
DisconnectCommand,
FoldEdgeCommand,
MoveNodeCommand, MoveNodeCommand,
} from '../../core/commands/graphCommands'; } from '../../core/commands/graphCommands';
import { import {
@@ -19,12 +17,10 @@ import {
edgesInto, edgesInto,
edgesOutOf, edgesOutOf,
firstPortFor, firstPortFor,
type GraphEdge,
type GraphNode, type GraphNode,
type BehaviorLookup, type BehaviorLookup,
type PortSide, type PortSide,
type PortType, type PortType,
portsCompatible,
portTypeOf, portTypeOf,
resolveDef, resolveDef,
RUN_BEHAVIOR, RUN_BEHAVIOR,
@@ -37,34 +33,24 @@ import {
activeDrag, activeDrag,
clamp, clamp,
type DragPayload, type DragPayload,
drawBand,
drawGrid, drawGrid,
endDrag, endDrag,
Menu,
type MenuItem,
onDragChange, onDragChange,
pressCount, pressCount,
type Size, type Size,
Theme, Theme,
textStyle, textStyle,
truncate,
Widget, Widget,
zoomFactor, zoomFactor,
} from '../../ui'; } from '../../ui';
import { removeNodes } from '../actions';
import { type EditorState, nodeSelection } from '../state'; import { type EditorState, nodeSelection } from '../state';
import {
buildFoldMarks,
foldedEdgeShown,
type FoldMarkEnd,
foldMarkAt,
foldMarkKey,
foldMarkLabel,
foldMarkRadius,
} from './folding';
import { type Box, boxesInBand, dragOffset, frameBoxes } from '../framing'; import { type Box, boxesInBand, dragOffset, frameBoxes } from '../framing';
import { bezier, EdgeLayer } from './EdgeLayer';
import { openLinkMenu, openNodeMenu } from './graphMenus';
import { NodePalette } from './NodePalette'; import { NodePalette } from './NodePalette';
import { type PaletteEntry, paletteEntries } from './paletteEntries'; import { type PaletteEntry, paletteEntries } from './paletteEntries';
import { NODE_WIDTH, NodeView, portColor, TITLE_HEIGHT } from './NodeView'; import { NODE_WIDTH, NodeView, TITLE_HEIGHT } from './NodeView';
/** Spacing of the background grid – and, with snapping on, of the positions a drag can leave. */ /** Spacing of the background grid – and, with snapping on, of the positions a drag can leave. */
const GRID_STEP = 24; const GRID_STEP = 24;
@@ -78,21 +64,6 @@ const NODE_HEIGHT_ESTIMATE = 80;
const FIT_MARGIN = 24; const FIT_MARGIN = 24;
/** Pointer travel (screen pixels) before a press turns into a drag rather than a click. */ /** Pointer travel (screen pixels) before a press turns into a drag rather than a click. */
const DRAG_THRESHOLD = 3; const DRAG_THRESHOLD = 3;
/** How far a folded edge's stub reaches out of its port. */
const FOLD_STUB = 16;
const FOLD_MARK_RADIUS = 5;
const FOLD_LABEL_WIDTH = 96;
/** One port's hidden links as drawn: what they stand in for, and where they can be clicked. */
interface FoldMark {
edgeIds: string[];
/** The nodes at the other ends, deduped – clicking selects them all. */
others: string[];
isOutput: boolean;
x: number;
y: number;
text: string;
}
/** /**
* The node graph of one behavior asset. Which behavior that is comes from the outside (`show`), * The node graph of one behavior asset. Which behavior that is comes from the outside (`show`),
@@ -103,16 +74,7 @@ export class GraphCanvas extends Widget {
private readonly grid = new Graphics(); private readonly grid = new Graphics();
private readonly clip = new Graphics(); private readonly clip = new Graphics();
private readonly canvas = new Container(); private readonly canvas = new Container();
private readonly edges = new Graphics(); private readonly edgeLayer = new EdgeLayer();
private readonly markChips = new Graphics();
/** Texts naming the far node of each folded edge; pooled, since a redraw rebuilds them all. */
private readonly markLayer = new Container();
private readonly markLabels: Text[] = [];
private markLabelCount = 0;
private foldMarks: FoldMark[] = [];
private foldEnds: FoldMarkEnd[] = [];
/** Edges the last redraw really stroked as a curve – a hidden one is missing from it. */
private readonly strokedEdges = new Set<string>();
private readonly nodeLayer = new Container(); private readonly nodeLayer = new Container();
private readonly tempEdge = new Graphics(); private readonly tempEdge = new Graphics();
/** Drawn in screen space, so the band keeps a hairline border at any zoom. */ /** Drawn in screen space, so the band keeps a hairline border at any zoom. */
@@ -153,7 +115,7 @@ export class GraphCanvas extends Widget {
constructor(private readonly state: EditorState) { constructor(private readonly state: EditorState) {
super(); super();
this.canvas.addChild(this.edges, this.markChips, this.markLayer, this.nodeLayer, this.tempEdge); this.canvas.addChild(this.edgeLayer, this.nodeLayer, this.tempEdge);
this.addChild(this.bg, this.grid, this.canvas, this.band, this.hint, this.clip); this.addChild(this.bg, this.grid, this.canvas, this.band, this.hint, this.clip);
this.canvas.mask = this.clip; this.canvas.mask = this.clip;
this.cursor = 'default'; this.cursor = 'default';
@@ -320,22 +282,25 @@ export class GraphCanvas extends Widget {
private onCanvasPointerDown(e: FederatedPointerEvent): void { private onCanvasPointerDown(e: FederatedPointerEvent): void {
// The middle button pans from anywhere, a node under the pointer included – it is the one // The middle button pans from anywhere, a node under the pointer included – it is the one
// gesture that never means "grab this". Nodes let it through for exactly that reason. // gesture that never means "grab this". Nodes let it through for exactly that reason.
if (e.button === 1) { const startPan = () => {
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y }; this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
};
if (e.button === 1) {
startPan();
e.stopPropagation(); e.stopPropagation();
return; return;
} }
if (e.target !== this) return; if (e.target !== this) return;
// Shift-drag on empty space pans as well, as it does in the scene view. // Shift-drag on empty space pans as well, as it does in the scene view.
if (e.button === 0 && e.shiftKey) { if (e.button === 0 && e.shiftKey) {
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y }; startPan();
return; return;
} }
const local = this.toLocal(e.global); const local = this.toLocal(e.global);
const g = this.screenToGraph(local.x, local.y); const g = this.screenToGraph(local.x, local.y);
const mark = this.state.mode === 'edit' ? this.markAt(g) : null; const mark = this.state.mode === 'edit' ? this.edgeLayer.markAt(g) : null;
if (e.button === 2) { if (e.button === 2) {
if (mark) this.openMarkMenu(mark, e.global.x, e.global.y); if (mark) this.openLinks((g) => g.edges.filter((it) => mark.edgeIds.includes(it.id)), e.global);
else if (this.assetId && this.state.mode === 'edit') this.openPalette(e.global.x, e.global.y, g); else if (this.assetId && this.state.mode === 'edit') this.openPalette(e.global.x, e.global.y, g);
return; return;
} }
@@ -358,8 +323,15 @@ export class GraphCanvas extends Widget {
const local = view.toLocal(e.global); const local = view.toLocal(e.global);
const hit = view.portAt(local.x, local.y); const hit = view.portAt(local.x, local.y);
if (e.button === 2) { if (e.button === 2) {
if (hit) this.openPortMenu(view, hit.port.key, hit.isOutput, e.global.x, e.global.y); const { id } = view.node;
else this.openNodeMenu(view, e.global.x, e.global.y); if (hit) {
const key = hit.port.key;
this.openLinks((g) => (hit.isOutput ? edgesOutOf(g, id, key) : edgesInto(g, id, key)), e.global);
} else {
// Right-clicking inside a multi-selection acts on all of it; outside it, on that node alone.
if (!this.state.selectedNodeIds.includes(id)) this.state.select(nodeSelection(this.assetId, [id]));
openNodeMenu(this.state, this.assetId, this.selectedNodes(), e.global.x, e.global.y);
}
return; return;
} }
if (e.button !== 0) return; if (e.button !== 0) return;
@@ -519,8 +491,7 @@ export class GraphCanvas extends Widget {
node.y = item.start.y; node.y = item.start.y;
moves.push(new MoveNodeCommand(this.assetId, item.nodeId, next.x, next.y)); moves.push(new MoveNodeCommand(this.assetId, item.nodeId, next.x, next.y));
} }
if (moves.length === 1) this.state.execute(moves[0]!); if (moves.length > 0) this.state.execute(oneStep('Move nodes', moves));
else if (moves.length > 1) this.state.execute(new CompoundCommand('Move nodes', moves));
} }
const connecting = this.connecting; const connecting = this.connecting;
const target = connecting ? (this.dropTarget ?? this.resolveDrop(e.global.x, e.global.y)) : null; const target = connecting ? (this.dropTarget ?? this.resolveDrop(e.global.x, e.global.y)) : null;
@@ -550,15 +521,7 @@ export class GraphCanvas extends Widget {
} }
private connect(from: PortSide, to: PortSide): void { private connect(from: PortSide, to: PortSide): void {
if (!this.assetId) return; if (this.assetId) this.state.execute(connectSides(this.assetId, from, to));
const [out, inp] = from.isOutput ? [from, to] : [to, from];
this.state.execute(
new ConnectCommand(
this.assetId,
{ node: out.nodeId, port: out.port },
{ node: inp.nodeId, port: inp.port },
),
);
} }
/** /**
@@ -639,15 +602,13 @@ export class GraphCanvas extends Widget {
return asset ? paletteEntries(this.state.project, asset) : []; return asset ? paletteEntries(this.state.project, asset) : [];
} }
private newNode(entry: PaletteEntry, at: { x: number; y: number }): AddNodeCommand {
return new AddNodeCommand(this.assetId, entry.def.type, Math.round(at.x), Math.round(at.y), entry.params);
}
private addNode(entry: PaletteEntry, at: { x: number; y: number }): void { private addNode(entry: PaletteEntry, at: { x: number; y: number }): void {
if (!this.assetId) return; if (!this.assetId) return;
const command = new AddNodeCommand( const command = this.newNode(entry, at);
this.assetId,
entry.def.type,
Math.round(at.x),
Math.round(at.y),
entry.params,
);
if (this.state.execute(command)) this.state.select(nodeSelection(this.assetId, [command.node.id])); if (this.state.execute(command)) this.state.select(nodeSelection(this.assetId, [command.node.id]));
} }
@@ -659,124 +620,23 @@ export class GraphCanvas extends Widget {
at: { x: number; y: number }, at: { x: number; y: number },
): void { ): void {
if (!this.assetId) return; if (!this.assetId) return;
const add = new AddNodeCommand( const add = this.newNode(entry, at);
this.assetId,
entry.def.type,
Math.round(at.x),
Math.round(at.y),
entry.params,
);
const port = firstPortFor(entry.def, fromType, from.isOutput); const port = firstPortFor(entry.def, fromType, from.isOutput);
const to = { nodeId: add.node.id, port: port ?? '', isOutput: !from.isOutput };
const [out, inp] = from.isOutput ? [from, to] : [to, from];
const commands: Command[] = [add]; const commands: Command[] = [add];
if (port) if (port)
commands.push( commands.push(
new ConnectCommand( connectSides(this.assetId, from, { nodeId: add.node.id, port, isOutput: !from.isOutput }),
this.assetId,
{ node: out.nodeId, port: out.port },
{ node: inp.nodeId, port: inp.port },
),
); );
if (this.state.execute(new CompoundCommand(add.label, commands))) if (this.state.execute(oneStep(add.label, commands)))
this.state.select(nodeSelection(this.assetId, [add.node.id])); this.state.select(nodeSelection(this.assetId, [add.node.id]));
} }
private openNodeMenu(view: NodeView, globalX: number, globalY: number): void { private openLinks(
const assetId = this.assetId; pick: (graph: BehaviorAsset['graph']) => BehaviorAsset['graph']['edges'],
if (!assetId) return; at: { x: number; y: number },
// Right-clicking inside a multi-selection acts on all of it; outside it, on that node alone.
if (!this.state.selectedNodeIds.includes(view.node.id))
this.state.select(nodeSelection(assetId, [view.node.id]));
const ids = this.state.selectedNodeIds;
const nodes = this.selectedNodes();
const expanded = nodes.filter((n) => !n.collapsed).map((n) => n.id);
const collapsed = nodes.filter((n) => n.collapsed).map((n) => n.id);
const fold = (list: string[], verb: string, to: boolean): MenuItem[] =>
list.length === 0
? []
: [
{
label: list.length > 1 ? `${verb} ${list.length} nodes` : `${verb} node`,
onSelect: () => this.state.execute(new CollapseNodesCommand(assetId, list, to)),
},
];
new Menu([
...fold(expanded, 'Collapse', true),
...fold(collapsed, 'Expand', false),
{
label: ids.length > 1 ? `Delete ${ids.length} nodes` : 'Delete node',
danger: true,
onSelect: () => removeNodes(this.state, assetId, ids),
},
]).open(globalX, globalY);
}
private openPortMenu(
view: NodeView,
port: string,
isOutput: boolean,
globalX: number,
globalY: number,
): void { ): void {
const asset = this.asset(); const graph = this.asset()?.graph;
if (!asset) return; if (graph) openLinkMenu(this.state, this.assetId, pick(graph), at.x, at.y);
const edges = isOutput
? edgesOutOf(asset.graph, view.node.id, port)
: edgesInto(asset.graph, view.node.id, port);
if (edges.length === 0) return;
const shown = edges.filter((e) => !e.folded);
const hidden = edges.filter((e) => e.folded);
const fold = (list: GraphEdge[], folded: boolean) => () => {
const commands = list.map((e) => new FoldEdgeCommand(asset.id, e.id, folded));
this.state.execute(
commands.length === 1
? commands[0]!
: new CompoundCommand(folded ? 'Hide links' : 'Show links', commands),
);
};
const items: MenuItem[] = [];
if (shown.length > 0)
items.push({
label: shown.length === 1 ? 'Hide link' : `Hide ${shown.length} links`,
onSelect: fold(shown, true),
});
if (hidden.length > 0)
items.push({
label: hidden.length === 1 ? 'Show link' : `Show ${hidden.length} links`,
onSelect: fold(hidden, false),
});
items.push({
label: edges.length === 1 ? 'Disconnect' : `Disconnect ${edges.length} links`,
danger: true,
onSelect: () => {
for (const edge of edges) this.state.execute(new DisconnectCommand(asset.id, edge.id));
},
});
new Menu(items).open(globalX, globalY);
}
/** The mark is the only handle a hidden edge has, so it carries the way back. */
private openMarkMenu(mark: FoldMark, globalX: number, globalY: number): void {
const assetId = this.assetId;
if (!assetId) return;
const ids = mark.edgeIds;
const n = ids.length;
const all = (title: string, make: (edgeId: string) => Command) => () => {
const commands = ids.map(make);
this.state.execute(commands.length === 1 ? commands[0]! : new CompoundCommand(title, commands));
};
new Menu([
{
label: n === 1 ? 'Show link' : `Show ${n} links`,
onSelect: all('Show links', (id) => new FoldEdgeCommand(assetId, id, false)),
},
{
label: n === 1 ? 'Disconnect' : `Disconnect ${n} links`,
danger: true,
onSelect: all('Disconnect links', (id) => new DisconnectCommand(assetId, id)),
},
]).open(globalX, globalY);
} }
override layout(): void { override layout(): void {
@@ -814,43 +674,29 @@ export class GraphCanvas extends Widget {
view.redraw(); view.redraw();
} }
this.edges.clear(); this.edgeLayer.draw(asset.graph.edges, this.views, (id) => isHot(trace?.edges, id), selectedNodes);
this.markChips.clear();
this.foldMarks = [];
this.markLabelCount = 0;
this.strokedEdges.clear();
this.foldEnds = [];
for (const edge of asset.graph.edges) this.drawEdge(edge, isHot(trace?.edges, edge.id), selectedNodes);
// After the curves, so no line drawn later crosses a mark that stands for a hidden one.
this.drawFoldMarks();
for (let i = this.markLabelCount; i < this.markLabels.length; i++) this.markLabels[i]!.visible = false;
this.tempEdge.clear(); this.tempEdge.clear();
this.tempEdgeShown = false; this.tempEdgeShown = false;
const drag = this.connecting ?? this.pendingDrop; const drag = this.connecting ?? this.pendingDrop;
if (drag) { const a = drag && this.anchorPoint(drag.from);
const view = this.views.get(drag.from.nodeId); if (drag && a) {
const p = view?.portPosition(drag.from.port, drag.from.isOutput); // Snapping to the resolved port makes the prediction the ring shows unmistakable.
if (view && p) { const b = this.anchorPoint(target) ?? drag.pointer;
const a = { x: view.x + p.x, y: view.y + p.y }; const [from, to] = drag.from.isOutput ? [a, b] : [b, a];
// Snapping to the resolved port makes the prediction the ring shows unmistakable. bezier(this.tempEdge, from.x, from.y, to.x, to.y);
const b = this.anchorPoint(target) ?? drag.pointer; this.tempEdge.stroke({ width: 2, color: c.textMuted, alpha: 0.9 });
const [from, to] = drag.from.isOutput ? [a, b] : [b, a]; this.tempEdgeShown = true;
bezier(this.tempEdge, from.x, from.y, to.x, to.y);
this.tempEdge.stroke({ width: 2, color: c.textMuted, alpha: 0.9 });
this.tempEdgeShown = true;
}
} }
this.band.clear(); this.band.clear();
const m = this.marquee; const m = this.marquee;
if (m?.active) { if (m?.active) {
const a = this.graphToScreen(m.start.x, m.start.y); drawBand(
const b = this.graphToScreen(m.current.x, m.current.y); this.band,
this.band this.graphToScreen(m.start.x, m.start.y),
.rect(Math.min(a.x, b.x), Math.min(a.y, b.y), Math.abs(b.x - a.x), Math.abs(b.y - a.y)) this.graphToScreen(m.current.x, m.current.y),
.fill({ color: c.marquee, alpha: 0.12 }) );
.stroke({ width: 1, color: c.marquee });
} }
const drop = this.incoming; const drop = this.incoming;
if (drop) { if (drop) {
@@ -871,93 +717,6 @@ export class GraphCanvas extends Widget {
return view && p ? { x: view.x + p.x, y: view.y + p.y } : null; return view && p ? { x: view.x + p.x, y: view.y + p.y } : null;
} }
private drawEdge(edge: GraphEdge, hot: boolean, selected: Set<string>): void {
const from = this.views.get(edge.from.node);
const to = this.views.get(edge.to.node);
const a = from?.portPosition(edge.from.port, true);
const b = to?.portPosition(edge.to.port, false);
if (!from || !to || !a || !b) return;
const type = from.def.outputs.find((p) => p.key === edge.from.port)?.type ?? 'any';
const inType = to.def.inputs.find((p) => p.key === edge.to.port)?.type ?? 'any';
const valid = portsCompatible(type, inType);
const color = hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger;
const start = { x: from.x + a.x, y: from.y + a.y };
const end = { x: to.x + b.x, y: to.y + b.y };
if (edge.folded) {
this.addFoldMark(edge, start, true, to.def.label, color, hot);
this.addFoldMark(edge, end, false, from.def.label, color, hot);
if (!foldedEdgeShown(edge, selected, hot)) return;
}
bezier(this.edges, start.x, start.y, end.x, end.y);
this.edges.stroke({
width: hot ? 4 : type === 'exec' ? 2.5 : 2,
color,
// Dimmed while folded, so a curve that is only on loan is not mistaken for a plain one.
alpha: (type === 'exec' || hot ? 1 : 0.85) * (edge.folded ? 0.6 : 1),
});
this.strokedEdges.add(edge.id);
}
/** Records one end of a hidden link; `drawFoldMarks` groups them per port afterwards. */
private addFoldMark(
edge: GraphEdge,
port: { x: number; y: number },
isOutput: boolean,
otherLabel: string,
color: number,
hot: boolean,
): void {
const [side, other] = isOutput ? [edge.from, edge.to.node] : [edge.to, edge.from.node];
this.foldEnds.push({
edgeId: edge.id,
key: foldMarkKey(side, isOutput, this.views.get(side.node)?.collapsed),
other,
otherLabel,
isOutput,
portX: port.x,
portY: port.y,
color,
hot,
});
}
/** A stub out of the port, a dot, and what waits at the other end – one per port, not per link. */
private drawFoldMarks(): void {
for (const mark of buildFoldMarks(this.foldEnds, FOLD_STUB)) {
const { x, y, color, isOutput } = mark;
this.edges.moveTo(mark.portX, y).lineTo(x, y).stroke({ width: 2, color, alpha: 0.9 });
const radius = foldMarkRadius(mark.edgeIds.length, FOLD_MARK_RADIUS);
this.edges.circle(x, y, radius).fill(color);
this.edges.circle(x, y, radius).stroke({ width: 1, color: Theme.colors.node });
const label = this.markLabel();
label.text = truncate(foldMarkLabel(mark.names), textStyle('small'), FOLD_LABEL_WIDTH);
const gap = radius + 4;
const lx = isOutput ? x + gap : x - gap - label.width;
label.position.set(lx, y - label.height / 2);
// Without a plate behind it the name blurs into whatever curves pass behind the mark.
this.markChips
.roundRect(lx - 3, y - label.height / 2 - 1, label.width + 6, label.height + 2, 3)
.fill({ color: Theme.colors.node, alpha: 0.85 });
this.foldMarks.push({ edgeIds: mark.edgeIds, others: mark.others, isOutput, x, y, text: label.text });
}
}
private markLabel(): Text {
let label = this.markLabels[this.markLabelCount];
if (!label) {
label = new Text({ text: '', style: textStyle('small') });
this.markLabels.push(label);
this.markLayer.addChild(label);
}
label.visible = true;
this.markLabelCount++;
return label;
}
private markAt(g: { x: number; y: number }): FoldMark | null {
return foldMarkAt(this.foldMarks, g, FOLD_MARK_RADIUS);
}
private syncGrid(): void { private syncGrid(): void {
this.grid.clear(); this.grid.clear();
const step = GRID_STEP * this.zoom; const step = GRID_STEP * this.zoom;
@@ -992,14 +751,15 @@ export class GraphCanvas extends Widget {
return this.tempEdgeShown; return this.tempEdgeShown;
} }
/** Whether the last redraw drew this edge as a curve – the claim "hidden" is about exactly this. */ /** Whether the last redraw stroked this edge as a curve. */
edgeDrawn(edgeId: string): boolean { edgeDrawn(edgeId: string): boolean {
return this.strokedEdges.has(edgeId); return this.edgeLayer.wasStroked(edgeId);
} }
/** Every mark the last redraw drew – its length is the claim "one per port, not one per link". */
foldMarksDrawn(): { links: number; label: string; isOutput: boolean }[] { foldMarksDrawn(): { links: number; label: string; isOutput: boolean }[] {
return this.foldMarks.map((m) => ({ links: m.edgeIds.length, label: m.text, isOutput: m.isOutput })); return this.edgeLayer
.drawnMarks()
.map((m) => ({ links: m.edgeIds.length, label: m.text, isOutput: m.isOutput }));
} }
/** Global screen position of a folded edge's mark at one end, and what that mark stands for. */ /** Global screen position of a folded edge's mark at one end, and what that mark stands for. */
@@ -1007,41 +767,24 @@ export class GraphCanvas extends Widget {
edgeId: string, edgeId: string,
isOutput: boolean, isOutput: boolean,
): { x: number; y: number; links: number; label: string } | null { ): { x: number; y: number; links: number; label: string } | null {
const mark = this.foldMarks.find((m) => m.edgeIds.includes(edgeId) && m.isOutput === isOutput); const mark = this.edgeLayer
.drawnMarks()
.find((m) => m.edgeIds.includes(edgeId) && m.isOutput === isOutput);
if (!mark) return null; if (!mark) return null;
const g = this.canvas.toGlobal({ x: mark.x, y: mark.y }); const g = this.canvas.toGlobal({ x: mark.x, y: mark.y });
return { x: g.x, y: g.y, links: mark.edgeIds.length, label: mark.text }; return { x: g.x, y: g.y, links: mark.edgeIds.length, label: mark.text };
} }
/** A node's drawn rectangle on screen, and the canvas' own – "fit" is a claim about both. */ nodeRect(nodeId: string): Rect | null {
nodeRect(nodeId: string): { x: number; y: number; w: number; h: number } | null {
const view = this.views.get(nodeId); const view = this.views.get(nodeId);
if (!view) return null; return view ? globalRect(view, view.w, view.h) : null;
const topLeft = view.toGlobal({ x: 0, y: 0 });
const bottomRight = view.toGlobal({ x: view.w, y: view.h });
return {
x: topLeft.x,
y: topLeft.y,
w: bottomRight.x - topLeft.x,
h: bottomRight.y - topLeft.y,
};
} }
canvasRect(): { x: number; y: number; w: number; h: number } { canvasRect(): Rect {
const topLeft = this.toGlobal({ x: 0, y: 0 }); return globalRect(this, this.w, this.h);
const bottomRight = this.toGlobal({ x: this.w, y: this.h });
return {
x: topLeft.x,
y: topLeft.y,
w: bottomRight.x - topLeft.x,
h: bottomRight.y - topLeft.y,
};
} }
/** /** A nested-behavior node takes its title from the behavior it runs. */
* The title a node is drawn with. Worth reaching for from outside because a nested-behavior node
* takes its title from the behavior it runs – "which node is this" is otherwise only a guess.
*/
nodeLabel(nodeId: string): string | null { nodeLabel(nodeId: string): string | null {
return this.views.get(nodeId)?.def.label ?? null; return this.views.get(nodeId)?.def.label ?? null;
} }
@@ -1069,8 +812,20 @@ export class GraphCanvas extends Widget {
} }
} }
/** Horizontal S-curve between two points. */ type Rect = { x: number; y: number; w: number; h: number };
function bezier(g: Graphics, x1: number, y1: number, x2: number, y2: number): void {
const dx = Math.max(40, Math.abs(x2 - x1) * 0.5); function globalRect(target: Container, w: number, h: number): Rect {
g.moveTo(x1, y1).bezierCurveTo(x1 + dx, y1, x2 - dx, y2, x2, y2); const topLeft = target.toGlobal({ x: 0, y: 0 });
const bottomRight = target.toGlobal({ x: w, y: h });
return { x: topLeft.x, y: topLeft.y, w: bottomRight.x - topLeft.x, h: bottomRight.y - topLeft.y };
}
/** A drag may start at either end; the command wants the output first. */
function connectSides(assetId: string, a: PortSide, b: PortSide): ConnectCommand {
const [out, inp] = a.isOutput ? [a, b] : [b, a];
return new ConnectCommand(
assetId,
{ node: out.nodeId, port: out.port },
{ node: inp.nodeId, port: inp.port },
);
} }
+2 -4
View File
@@ -10,10 +10,8 @@ export function foldedEdgeShown(edge: GraphEdge, selected: ReadonlySet<string>,
} }
/** /**
* Which mark a folded edge end belongs to. One port, one mark: drawn per edge instead, several * One mark per port (per side on a collapsed node), so hidden links of one port don't stack on the same
* hidden links out of the same port stack their stub, dot and name on the very same pixels. * pixels. The separator is NUL: a printable one merges node "a b" + port "c" with node "a" + port "b c".
* The separator is NUL: a printable one merges node "a b" + port "c" with node "a" + port "b c".
* A collapsed node draws all its ports on one point per side, so there it is one mark per side.
*/ */
export function foldMarkKey( export function foldMarkKey(
side: { node: string; port: string }, side: { node: string; port: string },
+68
View File
@@ -0,0 +1,68 @@
import type { Command } from '../../core/commands/Command';
import { oneStep } from '../../core/commands/CompoundCommand';
import { CollapseNodesCommand, DisconnectCommand, FoldEdgeCommand } from '../../core/commands/graphCommands';
import type { GraphEdge, GraphNode } from '../../core/graph';
import { Menu, type MenuItem } from '../../ui';
import { removeNodes } from '../actions';
import type { EditorState } from '../state';
/** For the selected nodes; a selection mixing both states offers both directions. */
export function openNodeMenu(
state: EditorState,
assetId: string,
nodes: readonly GraphNode[],
x: number,
y: number,
): void {
const ids = state.selectedNodeIds;
const fold = (list: readonly GraphNode[], verb: string, to: boolean): MenuItem[] => {
if (list.length === 0) return [];
const listIds = list.map((n) => n.id);
const label = list.length > 1 ? `${verb} ${list.length} nodes` : `${verb} node`;
return [{ label, onSelect: () => state.execute(new CollapseNodesCommand(assetId, listIds, to)) }];
};
const expanded = nodes.filter((n) => !n.collapsed);
const collapsed = nodes.filter((n) => n.collapsed);
new Menu([
...fold(expanded, 'Collapse', true),
...fold(collapsed, 'Expand', false),
{
label: ids.length > 1 ? `Delete ${ids.length} nodes` : 'Delete node',
danger: true,
onSelect: () => removeNodes(state, assetId, ids),
},
]).open(x, y);
}
/**
* The links at one port, or behind one fold mark – the mark is the only handle a hidden link has,
* so it carries the way back. Every entry is one undo step, however many links it covers.
*/
export function openLinkMenu(
state: EditorState,
assetId: string,
edges: readonly GraphEdge[],
x: number,
y: number,
): void {
if (edges.length === 0) return;
const item = (
verb: string,
single: string,
list: readonly GraphEdge[],
make: (edgeId: string) => Command,
danger = false,
): MenuItem[] => {
if (list.length === 0) return [];
const label = list.length === 1 ? single : `${verb} ${list.length} links`;
const commands = () => list.map((e) => make(e.id));
return [{ label, danger, onSelect: () => state.execute(oneStep(`${verb} links`, commands())) }];
};
const shown = edges.filter((e) => !e.folded);
const hidden = edges.filter((e) => e.folded);
new Menu([
...item('Hide', 'Hide link', shown, (id) => new FoldEdgeCommand(assetId, id, true)),
...item('Show', 'Show link', hidden, (id) => new FoldEdgeCommand(assetId, id, false)),
...item('Disconnect', 'Disconnect', edges, (id) => new DisconnectCommand(assetId, id), true),
]).open(x, y);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import type { Command } from '../../core/commands/Command'; import type { Command } from '../../core/commands/Command';
import { CompoundCommand } from '../../core/commands/CompoundCommand'; import { oneStep } from '../../core/commands/CompoundCommand';
import type { Asset } from '../../core/model/Asset'; import type { Asset } from '../../core/model/Asset';
import type { PropertyBag } from '../../core/model/Properties'; import type { PropertyBag } from '../../core/model/Properties';
import { import {
@@ -23,7 +23,7 @@ export type SizeValues = Partial<Record<SizeAxis, number>>;
/** One undo step whether the action wrote one side or both. */ /** One undo step whether the action wrote one side or both. */
export function sizeCommand(values: SizeValues, make: (axis: SizeAxis, value: number) => Command): Command { export function sizeCommand(values: SizeValues, make: (axis: SizeAxis, value: number) => Command): Command {
const commands = (Object.entries(values) as [SizeAxis, number][]).map(([axis, value]) => make(axis, value)); const commands = (Object.entries(values) as [SizeAxis, number][]).map(([axis, value]) => make(axis, value));
return commands.length === 1 ? commands[0]! : new CompoundCommand('Set size', commands); return oneStep('Set size', commands);
} }
/** The pixel size as the schema would store it – null where a min/max would change a side. */ /** The pixel size as the schema would store it – null where a min/max would change a side. */
+14 -30
View File
@@ -4,6 +4,7 @@ import {
SetAssetPropertyCommand, SetAssetPropertyCommand,
} from '../../core/commands/assetCommands'; } from '../../core/commands/assetCommands';
import { nextFieldName, nextVariableName } from '../../core/commands/naming'; import { nextFieldName, nextVariableName } from '../../core/commands/naming';
import { structShapeOf } from '../../core/graph';
import { import {
AddStructFieldCommand, AddStructFieldCommand,
AddVariableCommand, AddVariableCommand,
@@ -22,7 +23,6 @@ import {
type StructFieldDef, type StructFieldDef,
type StructFieldKind, type StructFieldKind,
VARIABLE_KINDS, VARIABLE_KINDS,
type VariableKind,
} from '../../core/properties/PropertySchema'; } from '../../core/properties/PropertySchema';
import { markedAssetSchema } from '../../core/properties/deviation'; import { markedAssetSchema } from '../../core/properties/deviation';
import { isModified, resolveAssetProps } from '../../core/properties/resolve'; import { isModified, resolveAssetProps } from '../../core/properties/resolve';
@@ -30,16 +30,10 @@ import { Dropdown, HStack, Label, Menu, TextField, Theme } from '../../ui';
import { aspectActions, sizeCommand } from './aspectAction'; import { aspectActions, sizeCommand } from './aspectAction';
import { createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory'; import { createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory';
import { addNote, addTitle, type InspectorHost } from './host'; import { addNote, addTitle, type InspectorHost } from './host';
import { buildVariable, type VariableEditing } from './variableRows'; import { buildVariable, KIND_LABELS, type VariableEditing } from './variableRows';
import { listOpener, type PreviewedValue, previewingHandlers, structFields } from './previewHandlers'; import { listOpener, previewingHandlers, structFields } from './previewHandlers';
import { assetValue } from './valueSpecs';
const VARIABLE_KIND_LABELS: Record<VariableKind, string> = {
number: 'Number',
boolean: 'Boolean',
text: 'Text',
list: 'List',
struct: 'Struct',
};
/** A freshly added field; a list one starts on numbers, the default `+ Variable ▸ List` picks too. */ /** A freshly added field; a list one starts on numbers, the default `+ Variable ▸ List` picks too. */
const newField = (key: string, kind: StructFieldKind): StructFieldDef => const newField = (key: string, kind: StructFieldKind): StructFieldDef =>
kind === 'list' ? { key, kind, items: 'number' } : { key, kind }; kind === 'list' ? { key, kind, items: 'number' } : { key, kind };
@@ -54,18 +48,13 @@ export function assetSignature(project: Project, assetId: string): string {
} }
export function defSignature(def: PropertyDef): string { export function defSignature(def: PropertyDef): string {
const fields = declaredFields(def) const fields = (structShapeOf(def) ?? []).map((f) => `${f.key}:${f.kind}`).join('+');
.map((f) => `${f.key}:${f.kind}`)
.join('+');
// A list's item kind decides whether it has field rows at all, so it is structure too. // A list's item kind decides whether it has field rows at all, so it is structure too.
const items = def.kind === 'list' ? `/${def.items}` : ''; const items = def.kind === 'list' ? `/${def.items}` : '';
return `${def.key}:${def.kind}${items}${fields ? `(${fields})` : ''}`; return `${def.key}:${def.kind}${items}${fields ? `(${fields})` : ''}`;
} }
/** The fields a struct declares, or the item shape of a list of structs. */ /** The fields a struct declares, or the item shape of a list of structs. */
function declaredFields(def: PropertyDef): readonly StructFieldDef[] {
return def.kind === 'struct' ? def.fields : def.kind === 'list' ? (def.fields ?? []) : [];
}
/** Name, properties (asset values marked when they differ from the default) and, for behaviors, the variables. */ /** Name, properties (asset values marked when they differ from the default) and, for behaviors, the variables. */
export function buildAssetSection(host: InspectorHost, assetId: string): void { export function buildAssetSection(host: InspectorHost, assetId: string): void {
@@ -139,20 +128,9 @@ function buildProps(host: InspectorHost, asset: Asset, schema: readonly Property
} }
} }
/** Where one property of an asset lives. Shared, so a whole row and a single struct field agree on it. */
function assetSpec(host: InspectorHost, assetId: string, key: string): PreviewedValue<Asset> {
return {
target: () => findAsset(host.state.project, assetId),
read: (asset) => asset.props[key],
write: (asset, value) => (asset.props[key] = value),
// No `remove`: an asset bag is dense – loading fills the defaults, a new variable brings its own.
commit: (value) => new SetAssetPropertyCommand(assetId, key, value),
};
}
/** Previews straight into the asset while editing; the command records the value from before the preview. */ /** Previews straight into the asset while editing; the command records the value from before the preview. */
function assetHandlers(host: InspectorHost, assetId: string, def: PropertyDef): FieldHandlers { function assetHandlers(host: InspectorHost, assetId: string, def: PropertyDef): FieldHandlers {
const spec = assetSpec(host, assetId, def.key); const spec = assetValue(host.state, assetId, def.key);
return { return {
...previewingHandlers<Asset>(host, spec), ...previewingHandlers<Asset>(host, spec),
...listOpener(host.state, def, () => ({ kind: 'asset', assetId, key: def.key })), ...listOpener(host.state, def, () => ({ kind: 'asset', assetId, key: def.key })),
@@ -167,7 +145,7 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
const add = new Dropdown('+ Variable', { variant: 'ghost' }); const add = new Dropdown('+ Variable', { variant: 'ghost' });
add.onClick = () => { add.onClick = () => {
const items = VARIABLE_KINDS.map((kind) => ({ const items = VARIABLE_KINDS.map((kind) => ({
label: VARIABLE_KIND_LABELS[kind], label: KIND_LABELS[kind],
onSelect: () => { onSelect: () => {
const current = findAsset(host.state.project, asset.id); const current = findAsset(host.state.project, asset.id);
if (current?.type === 'behavior') if (current?.type === 'behavior')
@@ -190,7 +168,13 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
renameField: (field, next) => run(new RenameStructFieldCommand(asset.id, def.key, field.key, next)), renameField: (field, next) => run(new RenameStructFieldCommand(asset.id, def.key, field.key, next)),
removeField: (field) => run(new RemoveStructFieldCommand(asset.id, def.key, field.key)), removeField: (field) => run(new RemoveStructFieldCommand(asset.id, def.key, field.key)),
addField: (kind) => addField: (kind) =>
run(new AddStructFieldCommand(asset.id, def.key, newField(nextFieldName(declaredFields(def)), kind))), run(
new AddStructFieldCommand(
asset.id,
def.key,
newField(nextFieldName(structShapeOf(def) ?? []), kind),
),
),
}; };
const value = values[def.key] ?? def.default; const value = values[def.key] ?? def.default;
host.fields.set(def.key, buildVariable(host, def, value, assetHandlers(host, asset.id, def), { edit })); host.fields.set(def.key, buildVariable(host, def, value, assetHandlers(host, asset.id, def), { edit }));
+19 -20
View File
@@ -110,10 +110,7 @@ export function labelledRow(text: string): { row: HStack; label: Label } {
return { row, label }; return { row, label };
} }
/** /** Adds the trailing ↺/↑/extra buttons and returns their refresh; `buildVariable` uses it for its first row too. */
* Adds the trailing ↺/↑/extra buttons to a row and returns the refresh that re-evaluates them.
* Separate from `createFieldRow` because a struct block wears the same buttons on its head row.
*/
export function attachRowActions(row: HStack, rowActions: RowActions): () => void { export function attachRowActions(row: HStack, rowActions: RowActions): () => void {
const actions: { button: Button; glyph: string; available: FieldActions }[] = []; const actions: { button: Button; glyph: string; available: FieldActions }[] = [];
const addActions = (glyph: string, available: FieldActions): void => { const addActions = (glyph: string, available: FieldActions): void => {
@@ -157,13 +154,8 @@ export function attachRowActions(row: HStack, rowActions: RowActions): () => voi
} }
/** /**
* Maps one PropertySchema entry to a labelled row containing the matching widget. * Maps one schema entry to a labelled row. Action groups are asked again on each refresh: none means
* The inspector is entirely generated through this function: adding a new property * no button, one a button, several a menu.
* kind means adding a case here, adding a property means adding a schema line.
*
* A row shows its actions at the right: ↺ resets to the base value, ↑ applies the value to
* its source, `extra` adds row-specific ones. Each group is asked on every refresh: no entry
* means no button, one entry means a direct button, several mean a menu (↺ ▾ / ↑ ▾).
*/ */
export function createFieldRow( export function createFieldRow(
ctx: FieldContext, ctx: FieldContext,
@@ -174,8 +166,7 @@ export function createFieldRow(
): FieldRow { ): FieldRow {
const { row, label } = labelledRow(def.label); const { row, label } = labelledRow(def.label);
const field = createField(ctx, def, value, handlers); const field = createField(ctx, def, value, handlers);
row.add(field.widget, field.grow ? 1 : 0); addValue(row, field);
if (!field.grow) row.add(new Spacer(), 1);
const refreshActions = attachRowActions(row, rowActions); const refreshActions = attachRowActions(row, rowActions);
@@ -198,6 +189,19 @@ interface Field {
parts?: ReadonlyMap<string, Widget>; parts?: ReadonlyMap<string, Widget>;
} }
/** A widget that does not grow gets a spacer after it, so whatever follows sits at the row's end. */
export function addValue(row: HStack, field: Field): (value: PropertyValue) => void {
row.add(field.widget, field.grow ? 1 : 0);
if (!field.grow) row.add(new Spacer(), 1);
return field.setValue;
}
export function removeButton(onClick: () => void): Button {
return new Button('×', { variant: 'ghost', width: Theme.controlHeight, onClick });
}
export const READ_ONLY: FieldHandlers = { onInput: () => {}, onChange: () => {} };
/** A primitive as a property definition, so one widget set serves a list item and a struct field. */ /** A primitive as a property definition, so one widget set serves a list item and a struct field. */
export function primitiveDef(key: string, kind: FieldKind, label = key): PrimitiveDef { export function primitiveDef(key: string, kind: FieldKind, label = key): PrimitiveDef {
switch (kind) { switch (kind) {
@@ -263,18 +267,13 @@ export function createField(
case 'struct': { case 'struct': {
// A bundle has few fields, so unlike a list they fit under each other right here. Editing // A bundle has few fields, so unlike a list they fit under each other right here. Editing
// one writes the whole bundle, which is why the handlers come per field from the caller. // one writes the whole bundle, which is why the handlers come per field from the caller.
const per = handlers.structFieldHandlers; const per = handlers.structFieldHandlers ?? (() => READ_ONLY);
if (!per) {
const summary = new Label(textOf(value), { style: 'muted' });
return { widget: summary, grow: true, setValue: (v) => summary.setText(textOf(v)) };
}
const stack = new VStack({ gap: Theme.gap }); const stack = new VStack({ gap: Theme.gap });
const fields = new Map<string, Field>(); const fields = new Map<string, Field>();
for (const field of def.fields) { for (const field of def.fields) {
const { row } = labelledRow(field.key); const { row } = labelledRow(field.key);
const sub = createField(ctx, fieldDef(field), structFieldValue(value, field), per(field)); const sub = createField(ctx, fieldDef(field), structFieldValue(value, field), per(field));
row.add(sub.widget, sub.grow ? 1 : 0); addValue(row, sub);
if (!sub.grow) row.add(new Spacer(), 1);
fields.set(field.key, sub); fields.set(field.key, sub);
stack.add(row); stack.add(row);
} }
+2 -3
View File
@@ -4,7 +4,7 @@ import {
SetFrameRectCommand, SetFrameRectCommand,
} from '../../core/commands/frameCommands'; } from '../../core/commands/frameCommands';
import type { TextureAsset, TextureFrame } from '../../core/model/Asset'; import type { TextureAsset, TextureFrame } from '../../core/model/Asset';
import { findAsset, type Project } from '../../core/model/Project'; import { findAssetOf, type Project } from '../../core/model/Project';
import { frameRect, type FrameRect, normalizeFrameRect, setFrameRect } from '../../core/model/texture'; import { frameRect, type FrameRect, normalizeFrameRect, setFrameRect } from '../../core/model/texture';
import { Button, TextField } from '../../ui'; import { Button, TextField } from '../../ui';
import { createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory'; import { createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory';
@@ -121,6 +121,5 @@ function currentFrame(project: Project, assetId: string, frameId: string): Textu
} }
function currentTexture(project: Project, assetId: string): TextureAsset | undefined { function currentTexture(project: Project, assetId: string): TextureAsset | undefined {
const asset = findAsset(project, assetId); return findAssetOf(project, assetId, 'texture');
return asset?.type === 'texture' ? asset : undefined;
} }
+8 -26
View File
@@ -1,8 +1,7 @@
import { RemoveNodeCommand, SetNodeParamCommand } from '../../core/commands/graphCommands'; import { RemoveNodeCommand } from '../../core/commands/graphCommands';
import { import {
callProblem, callProblem,
declaresVariablePorts, declaresVariablePorts,
type Graph,
edgesInto, edgesInto,
type GraphNode, type GraphNode,
inlineInputDef, inlineInputDef,
@@ -12,18 +11,19 @@ import {
} from '../../core/graph'; } from '../../core/graph';
import type { Asset } from '../../core/model/Asset'; import type { Asset } from '../../core/model/Asset';
import { behaviorLookup } from '../../core/model/behaviorPrograms'; import { behaviorLookup } from '../../core/model/behaviorPrograms';
import { findAsset, type Project } from '../../core/model/Project'; import { findAssetOf, type Project } from '../../core/model/Project';
import type { PropertyDef } from '../../core/properties/PropertySchema'; import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, Label } from '../../ui'; import { Button, Label } from '../../ui';
import { createFieldRow, type FieldHandlers } from './fieldFactory'; import { createFieldRow, type FieldHandlers } from './fieldFactory';
import { addGap, addNote, addTitle, type InspectorHost } from './host'; import { addGap, addNote, addTitle, type InspectorHost } from './host';
import { buildPortSection, buildVariablePortSection } from './portSection'; import { buildPortSection, buildVariablePortSection } from './portSection';
import { listOpener, previewingHandlers } from './previewHandlers'; import { listOpener, previewingHandlers } from './previewHandlers';
import { currentNode, nodeParamValue } from './valueSpecs';
/** Structure-defining facts of a node selection: ports, connected inputs, declared variables. */ /** Structure-defining facts of a node selection: ports, connected inputs, declared variables. */
export function nodeSignature(project: Project, assetId: string, nodeId: string): string { export function nodeSignature(project: Project, assetId: string, nodeId: string): string {
const asset = findAsset(project, assetId); const asset = findAssetOf(project, assetId, 'behavior');
if (!asset || asset.type !== 'behavior') return 'missing'; if (!asset) return 'missing';
const node = asset.graph.nodes.find((n) => n.id === nodeId); const node = asset.graph.nodes.find((n) => n.id === nodeId);
if (!node) return 'missing'; if (!node) return 'missing';
const lookup = behaviorLookup(project); const lookup = behaviorLookup(project);
@@ -41,8 +41,8 @@ export function nodeSignature(project: Project, assetId: string, nodeId: string)
/** Description, settings (params) and the inline values of unconnected data inputs. */ /** Description, settings (params) and the inline values of unconnected data inputs. */
export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: string): void { export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: string): void {
const asset = findAsset(host.state.project, assetId); const asset = findAssetOf(host.state.project, assetId, 'behavior');
if (!asset || asset.type !== 'behavior') return; if (!asset) return;
const node = asset.graph.nodes.find((n) => n.id === nodeId); const node = asset.graph.nodes.find((n) => n.id === nodeId);
const def = node && resolveDef(node, asset.graph, behaviorLookup(host.state.project)); const def = node && resolveDef(node, asset.graph, behaviorLookup(host.state.project));
if (!node || !def) return; if (!node || !def) return;
@@ -126,25 +126,7 @@ function paramHandlers(
): FieldHandlers { ): FieldHandlers {
const key = def.key; const key = def.key;
return { return {
...previewingHandlers<GraphNode>(host, { ...previewingHandlers<GraphNode>(host, nodeParamValue(host.state, assetId, nodeId, key)),
target: () => currentNode(host.state.project, assetId, nodeId)?.node,
read: (node) => node.params[key],
write: (node, value) => (node.params[key] = value),
remove: (node) => delete node.params[key],
commit: (value) => new SetNodeParamCommand(assetId, nodeId, key, value),
}),
...listOpener(host.state, def, () => ({ kind: 'node', assetId, nodeId, key })), ...listOpener(host.state, def, () => ({ kind: 'node', assetId, nodeId, key })),
}; };
} }
/** The node together with the graph it sits in, because its ports are derived from both. */
function currentNode(
project: Project,
assetId: string,
nodeId: string,
): { node: GraphNode; graph: Graph } | undefined {
const asset = findAsset(project, assetId);
if (asset?.type !== 'behavior') return undefined;
const node = asset.graph.nodes.find((n) => n.id === nodeId);
return node && { node, graph: asset.graph };
}
+8 -17
View File
@@ -38,14 +38,15 @@ import {
resolveSceneObjectProps, resolveSceneObjectProps,
} from '../../core/properties/resolve'; } from '../../core/properties/resolve';
import { Button, TextField } from '../../ui'; import { Button, TextField } from '../../ui';
import { addCollider, createContainer, removeObjects } from '../actions'; import { addCollider, canAddCollider, createContainer, removeObjects } from '../actions';
import { aspectActions, sizeCommand } from './aspectAction'; import { aspectActions, sizeCommand } from './aspectAction';
import { defSignature } from './assetSection'; import { defSignature } from './assetSection';
import { createFieldRow, type FieldRow, labelledRow, type RowActions } from './fieldFactory'; import { createFieldRow, type FieldRow, labelledRow, type RowActions } from './fieldFactory';
import { addGap, addNote, addTitle, type InspectorHost } from './host'; import { addGap, addNote, addTitle, type InspectorHost } from './host';
import type { ListSlot } from '../state'; import type { ListSlot } from '../state';
import { parentRow } from './parentRow'; import { parentRow } from './parentRow';
import { listOpener, type PreviewedValue, previewingHandlers, structFields } from './previewHandlers'; import { listOpener, previewingHandlers, structFields } from './previewHandlers';
import { overrideValue } from './valueSpecs';
import { buildVariable } from './variableRows'; import { buildVariable } from './variableRows';
import { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf } from './objectQueries'; import { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf } from './objectQueries';
import { componentRow, OFFSET_AXES, offsetKey, POSITION_AXES } from './vectorRows'; import { componentRow, OFFSET_AXES, offsetKey, POSITION_AXES } from './vectorRows';
@@ -230,7 +231,8 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length); if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length);
if (asset.type !== 'collider' && !target) { const stored = findObjectHolder(project, holderId);
if (stored && canAddCollider(project, stored, object)) {
addGap(host); addGap(host);
host.body.add( host.body.add(
new Button('+ Add collider', { new Button('+ Add collider', {
@@ -292,12 +294,8 @@ export function refreshObjectSection(host: InspectorHost, holderId: string, obje
} }
/** /**
* One editable property of a placed object: writes an override, offers ↺ (back to the asset * One editable property of a placed object. Reset always goes to the nearest base (the template inside
* or, inside a container, to the template) and ↑ (apply upwards). * a container), while "Apply to asset" reaches past it, dropping the template's override.
*
* Inside a container a value has two bases – the template it follows and, one step further up,
* the asset. Reset always goes to the nearest one, so content of a placed container offers only
* "Reset to container"; "Apply to asset" still reaches past it, dropping the template's override.
*/ */
function propertyRow( function propertyRow(
host: InspectorHost, host: InspectorHost,
@@ -309,14 +307,7 @@ function propertyRow(
variable = false, variable = false,
): FieldRow { ): FieldRow {
const bases = () => deviations(host.state, holderId, objectId, def.key); const bases = () => deviations(host.state, holderId, objectId, def.key);
const overrideSpec: PreviewedValue<ObjectRef> = { const overrideSpec = overrideValue(host.state, holderId, objectId, def.key);
target: () => currentRef(host.state, holderId, objectId),
read: (ref) => ref.object.overrides[def.key],
current: () => currentProps(host.state, holderId, objectId)?.[def.key],
write: (ref, value) => ref.setOverride(def.key, value),
remove: (ref) => ref.setOverride(def.key, undefined),
commit: (value) => new SetOverrideCommand(holderId, objectId, def.key, value),
};
const fieldSlot = (field: string): ListSlot => ({ const fieldSlot = (field: string): ListSlot => ({
kind: 'object', kind: 'object',
holderId, holderId,
+35 -57
View File
@@ -10,7 +10,6 @@ import {
BEHAVIOR_START, BEHAVIOR_START,
DECLARED_PORT_KINDS, DECLARED_PORT_KINDS,
type DeclaredPort, type DeclaredPort,
type DeclaredPortKind,
type GraphNode, type GraphNode,
isTerminalNode, isTerminalNode,
missingFieldPorts, missingFieldPorts,
@@ -18,26 +17,20 @@ import {
variablePortKind, variablePortKind,
} from '../../core/graph'; } from '../../core/graph';
import type { PropertyDef } from '../../core/properties/PropertySchema'; import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Spacer, TextField, Theme } from '../../ui'; import {
Dropdown,
HStack,
Label,
Menu,
type MenuItem,
Spacer,
TextField,
Theme,
type Widget,
} from '../../ui';
import { NAME_WIDTH, removeButton } from './fieldFactory';
import { addNote, type InspectorHost } from './host'; import { addNote, type InspectorHost } from './host';
import { KIND_LABELS, kindMark, markLabel } from './variableRows';
const KIND_LABELS: Record<DeclaredPortKind, string> = {
number: 'Number',
boolean: 'Boolean',
text: 'Text',
object: 'Object',
list: 'List',
struct: 'Struct',
};
const KIND_MARKS: Record<DeclaredPortKind, string> = {
number: '#',
boolean: '✓',
text: 'Aa',
object: '◇',
list: '≡',
struct: '⊞',
};
/** /**
* The ports of a Get/Set variables node: each one *is* a variable of the behavior, so it is picked * The ports of a Get/Set variables node: each one *is* a variable of the behavior, so it is picked
@@ -75,11 +68,6 @@ export function buildVariablePortSection(
for (const port of ports) { for (const port of ports) {
const known = variables.some((v) => v.key === port.key); const known = variables.some((v) => v.key === port.key);
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
const mark = new Label(KIND_MARKS[port.kind], { style: 'small', align: 'center' });
mark.minWidth = 18;
row.add(mark);
const choose = new Dropdown(port.key, { variant: known ? 'default' : 'danger' }); const choose = new Dropdown(port.key, { variant: known ? 'default' : 'danger' });
choose.onClick = () => { choose.onClick = () => {
const items = pick(port.key, (next) => const items = pick(port.key, (next) =>
@@ -87,16 +75,7 @@ export function buildVariablePortSection(
); );
new Menu(items).openBelow(choose); new Menu(items).openBelow(choose);
}; };
row.add(choose, 1); portRow(host, assetId, node, port, choose);
row.add(new Spacer());
row.add(
new Button('×', {
variant: 'ghost',
width: Theme.controlHeight,
onClick: () => host.state.execute(new RemovePortCommand(assetId, node.id, port.key)),
}),
);
host.body.add(row);
if (!known) addNote(host, `"${port.key}" is not a variable of this behavior.`, 'warning'); if (!known) addNote(host, `"${port.key}" is not a variable of this behavior.`, 'warning');
} }
} }
@@ -143,31 +122,30 @@ export function buildPortSection(
); );
for (const port of ports) { for (const port of ports) {
const row = new HStack({ gap: Theme.gap, align: 'center' }); const name = new TextField(port.key, {
row.minHeight = Theme.rowHeight; width: NAME_WIDTH,
const mark = new Label(KIND_MARKS[port.kind], { style: 'small', align: 'center' }); onChange: (next) => host.state.execute(new RenamePortCommand(assetId, node.id, port.key, next.trim())),
mark.minWidth = 18; });
row.add(mark); portRow(host, assetId, node, port, name);
row.add(
new TextField(port.key, {
width: 90,
onChange: (next) =>
host.state.execute(new RenamePortCommand(assetId, node.id, port.key, next.trim())),
}),
1,
);
row.add(new Spacer());
row.add(
new Button('×', {
variant: 'ghost',
width: Theme.controlHeight,
onClick: () => host.state.execute(new RemovePortCommand(assetId, node.id, port.key)),
}),
);
host.body.add(row);
} }
} }
function portRow(
host: InspectorHost,
assetId: string,
node: GraphNode,
port: DeclaredPort,
name: Widget,
): void {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
row.add(markLabel(kindMark(port.kind)));
row.add(name, 1);
row.add(new Spacer());
row.add(removeButton(() => host.state.execute(new RemovePortCommand(assetId, node.id, port.key))));
host.body.add(row);
}
/** /**
* Takes the fields of a Pack/Expand from a variable that already declares the shape, instead of * Takes the fields of a Pack/Expand from a variable that already declares the shape, instead of
* typing them again. Only offered where there is a shape to copy; a field the node already has * typing them again. Only offered where there is a shape to copy; a field the node already has
+2 -4
View File
@@ -78,10 +78,8 @@ export interface PreviewedValue<Target, Model = PropertyValue> {
} }
/** /**
* Row handlers that preview into the model while editing and take that preview back before * Row handlers that preview into the model and restore the pre-preview value (or its absence, via
* committing. A command records the value it finds at execute time, so committing right away * `remove`) before committing, since a command records whatever it finds at execute time as its undo target.
* would make the last previewed frame the undo target: the value from before the preview is
* written back first – including "the key did not exist", via `remove`.
*/ */
export function previewingHandlers<Target, Model = PropertyValue>( export function previewingHandlers<Target, Model = PropertyValue>(
host: { state: EditorState }, host: { state: EditorState },
+64
View File
@@ -0,0 +1,64 @@
import { SetAssetPropertyCommand } from '../../core/commands/assetCommands';
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import { SetOverrideCommand } from '../../core/commands/sceneObjectCommands';
import { findNode, type Graph, type GraphNode } from '../../core/graph/Graph';
import type { Asset } from '../../core/model/Asset';
import type { ObjectRef } from '../../core/model/instance';
import { findAsset, findAssetOf, type Project } from '../../core/model/Project';
import type { EditorState } from '../state';
import { currentProps, currentRef } from './objectQueries';
import type { PreviewedValue } from './previewHandlers';
/** Where one value lives, shared by the inspector and the List tab so both write it the same way. */
export function assetValue(state: EditorState, assetId: string, key: string): PreviewedValue<Asset> {
return {
target: () => findAsset(state.project, assetId),
read: (asset) => asset.props[key],
write: (asset, value) => (asset.props[key] = value),
// No `remove`: an asset bag is dense – loading fills the defaults, a new variable brings its own.
commit: (value) => new SetAssetPropertyCommand(assetId, key, value),
};
}
export function overrideValue(
state: EditorState,
holderId: string,
objectId: string,
key: string,
): PreviewedValue<ObjectRef> {
return {
target: () => currentRef(state, holderId, objectId),
read: (ref) => ref.object.overrides[key],
current: () => currentProps(state, holderId, objectId)?.[key],
write: (ref, value) => ref.setOverride(key, value),
remove: (ref) => ref.setOverride(key, undefined),
commit: (value) => new SetOverrideCommand(holderId, objectId, key, value),
};
}
export function nodeParamValue(
state: EditorState,
assetId: string,
nodeId: string,
key: string,
): PreviewedValue<GraphNode> {
return {
target: () => currentNode(state.project, assetId, nodeId)?.node,
read: (node) => node.params[key],
write: (node, value) => (node.params[key] = value),
remove: (node) => delete node.params[key],
commit: (value) => new SetNodeParamCommand(assetId, nodeId, key, value),
};
}
/** The node together with its graph, because its ports are derived from both. */
export function currentNode(
project: Project,
assetId: string,
nodeId: string,
): { node: GraphNode; graph: Graph } | undefined {
const asset = findAssetOf(project, assetId, 'behavior');
if (!asset) return undefined;
const node = findNode(asset.graph, nodeId);
return node && { node, graph: asset.graph };
}
+29 -44
View File
@@ -1,3 +1,4 @@
import type { DeclaredPortKind } from '../../core/graph';
import type { PropertyValue } from '../../core/model/Properties'; import type { PropertyValue } from '../../core/model/Properties';
import { import {
FIELD_KINDS, FIELD_KINDS,
@@ -8,10 +9,12 @@ import {
type StructDef, type StructDef,
type StructFieldDef, type StructFieldDef,
type StructFieldKind, type StructFieldKind,
type VariableKind,
} from '../../core/properties/PropertySchema'; } from '../../core/properties/PropertySchema';
import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme, type Widget } from '../../ui'; import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme, type Widget } from '../../ui';
import type { ListDef } from '../list/slot'; import type { ListDef } from '../list/slot';
import { import {
addValue,
attachRowActions, attachRowActions,
createField, createField,
FIELD_INDENT, FIELD_INDENT,
@@ -20,26 +23,34 @@ import {
type FieldRow, type FieldRow,
MARK_WIDTH, MARK_WIDTH,
NAME_WIDTH, NAME_WIDTH,
READ_ONLY,
removeButton,
type RowActions, type RowActions,
structFieldValue, structFieldValue,
} from './fieldFactory'; } from './fieldFactory';
import { addNote, type InspectorHost } from './host'; import { addNote, type InspectorHost } from './host';
// Keyed by the wide `PropertyDef['kind']` because variables are `PropertyDef`s, but only the type ShownKind = VariableKind | ListItemKind | StructFieldKind | DeclaredPortKind;
// VariableKinds ever reach it – the '?' fallback is a safety net, not a case to fill in.
const VARIABLE_KIND_MARKS: Partial<Record<PropertyDef['kind'], string>> = {
number: '#',
boolean: '✓',
text: 'Aa',
struct: '⊞',
};
const LIST_ITEM_MARKS: Record<ListItemKind, string> = { /** One glyph and one name per kind, wherever a kind is shown: variables, list items, fields, ports. */
const KIND_MARKS: Record<ShownKind, string> = {
number: '#', number: '#',
boolean: '✓', boolean: '✓',
text: 'Aa', text: 'Aa',
color: '◧', color: '◧',
list: '≡',
struct: '⊞', struct: '⊞',
object: '◇',
};
export const KIND_LABELS: Record<ShownKind, string> = {
number: 'Number',
boolean: 'Boolean',
text: 'Text',
color: 'Color',
list: 'List',
struct: 'Struct',
object: 'Object',
}; };
const LIST_ITEM_LABELS: Record<ListItemKind, string> = { const LIST_ITEM_LABELS: Record<ListItemKind, string> = {
@@ -50,21 +61,10 @@ const LIST_ITEM_LABELS: Record<ListItemKind, string> = {
struct: 'Structs', struct: 'Structs',
}; };
const FIELD_KIND_MARKS: Record<StructFieldKind, string> = { /** Variables are `PropertyDef`s, so the kind is typed wide; only the shown kinds ever reach it. */
number: '#', export function kindMark(kind: string): string {
boolean: '✓', return (KIND_MARKS as Partial<Record<string, string>>)[kind] ?? '?';
text: 'Aa', }
color: '◧',
list: '≡',
};
const FIELD_KIND_LABELS: Record<StructFieldKind, string> = {
number: 'Number',
boolean: 'Boolean',
text: 'Text',
color: 'Color',
list: 'List',
};
/** Changing a declaration – offered only on the asset that declares it. */ /** Changing a declaration – offered only on the asset that declares it. */
export interface VariableEditing { export interface VariableEditing {
@@ -82,8 +82,6 @@ export interface VariableOptions {
actions?: RowActions; actions?: RowActions;
} }
const READ_ONLY: FieldHandlers = { onInput: () => {}, onChange: () => {} };
/** /**
* One variable, drawn the same on its asset and on a placed object – minus `edit` there, since the * One variable, drawn the same on its asset and on a placed object – minus `edit` there, since the
* shape belongs to the asset. Adds its rows to the body itself and returns the first. * shape belongs to the asset. Adds its rows to the body itself and returns the first.
@@ -169,7 +167,7 @@ function itemShapeRows(host: InspectorHost, def: ListDef, edit: VariableEditing)
function fieldRow(field: StructFieldDef, edit: VariableEditing | undefined): HStack { function fieldRow(field: StructFieldDef, edit: VariableEditing | undefined): HStack {
const name = nameCell(field.key, 1, edit && ((next) => edit.renameField(field, next))); const name = nameCell(field.key, 1, edit && ((next) => edit.renameField(field, next)));
return gridRow(1, markLabel(FIELD_KIND_MARKS[field.kind]), name); return gridRow(1, markLabel(KIND_MARKS[field.kind]), name);
} }
/** Indent, mark and name – sized so that whatever follows starts on the value column at any depth. */ /** Indent, mark and name – sized so that whatever follows starts on the value column at any depth. */
@@ -194,22 +192,13 @@ function nameCell(text: string, depth: number, rename: ((next: string) => void)
: new Label(text, { style: 'muted', width, padding: Theme.fieldPadding }); : new Label(text, { style: 'muted', width, padding: Theme.fieldPadding });
} }
function addValue(
row: HStack,
field: { widget: Widget; grow: boolean; setValue: (v: PropertyValue) => void },
): (v: PropertyValue) => void {
row.add(field.widget, field.grow ? 1 : 0);
if (!field.grow) row.add(new Spacer(), 1);
return field.setValue;
}
function markOf(def: PropertyDef, edit: VariableEditing | undefined): Widget { function markOf(def: PropertyDef, edit: VariableEditing | undefined): Widget {
if (def.kind !== 'list') return markLabel(VARIABLE_KIND_MARKS[def.kind] ?? '?'); if (def.kind !== 'list') return markLabel(kindMark(def.kind));
const glyph = `≡${LIST_ITEM_MARKS[def.items]}`; const glyph = `≡${KIND_MARKS[def.items]}`;
return edit ? itemKindPicker(glyph, def.items, edit) : markLabel(glyph); return edit ? itemKindPicker(glyph, def.items, edit) : markLabel(glyph);
} }
function markLabel(glyph: string): Label { export function markLabel(glyph: string): Label {
return new Label(glyph, { style: 'small', align: 'center', width: MARK_WIDTH }); return new Label(glyph, { style: 'small', align: 'center', width: MARK_WIDTH });
} }
@@ -244,12 +233,8 @@ function addFieldMenu(kinds: readonly StructFieldKind[], edit: VariableEditing):
const add = new Dropdown('+ Field', { variant: 'ghost' }); const add = new Dropdown('+ Field', { variant: 'ghost' });
add.onClick = () => { add.onClick = () => {
new Menu( new Menu(
kinds.map((kind) => ({ label: FIELD_KIND_LABELS[kind], onSelect: () => edit.addField(kind) })), kinds.map((kind) => ({ label: KIND_LABELS[kind], onSelect: () => edit.addField(kind) })),
).openBelow(add); ).openBelow(add);
}; };
return add; return add;
} }
function removeButton(onClick: () => void): Button {
return new Button('×', { variant: 'ghost', width: Theme.controlHeight, onClick });
}
+10 -10
View File
@@ -6,7 +6,14 @@
import { Button, HStack, Label, ScrollContainer, Spacer, Theme, VStack, type Widget } from '../../ui'; import { Button, HStack, Label, ScrollContainer, Spacer, Theme, VStack, type Widget } from '../../ui';
import { textOf, type ListValue, type PropertyValue } from '../../core/model/Properties'; import { textOf, type ListValue, type PropertyValue } from '../../core/model/Properties';
import { type StructDef, structOf } from '../../core/properties/PropertySchema'; import { type StructDef, structOf } from '../../core/properties/PropertySchema';
import { createField, type FieldContext, primitiveDef, type PrimitiveDef } from '../inspector/fieldFactory'; import {
addValue,
createField,
type FieldContext,
primitiveDef,
type PrimitiveDef,
removeButton,
} from '../inspector/fieldFactory';
import type { EditorState } from '../state'; import type { EditorState } from '../state';
import { type ListAccess, type ListDef, resolveList } from './slot'; import { type ListAccess, type ListDef, resolveList } from './slot';
@@ -124,15 +131,8 @@ export class ListEditor extends VStack {
if (tall) label.minHeight = Theme.rowHeight; if (tall) label.minHeight = Theme.rowHeight;
row.add(label); row.add(label);
const field = createField(this.ctx, itemDef(access.def, index), item, access.itemHandlers(index)); const field = createField(this.ctx, itemDef(access.def, index), item, access.itemHandlers(index));
row.add(field.widget, field.grow ? 1 : 0); addValue(row, field);
if (!field.grow) row.add(new Spacer(), 1); row.add(removeButton(() => access.replace(access.value.filter((_, i) => i !== index))));
row.add(
new Button('×', {
variant: 'ghost',
width: Theme.controlHeight,
onClick: () => access.replace(access.value.filter((_, i) => i !== index)),
}),
);
this.setters.push(field.setValue); this.setters.push(field.setValue);
this.fields.push({ widget: field.widget, parts: field.parts }); this.fields.push({ widget: field.widget, parts: field.parts });
return row; return row;
+12 -42
View File
@@ -1,14 +1,8 @@
/** /**
* Resolves a `ListSlot` to the list in it and to the two ways of writing it back. Each of the * Resolves a `ListSlot` to its list and write-back, reusing each inspector section's spec so a List tab
* three cases is the spec the matching inspector section already uses, so an item edited in the * edit gets the same command, coercion and undo step. A `field` slot still writes the whole struct.
* List tab goes through the same command, the same coercion and the same undo step as the row
* it replaces. A slot with a `field` names a list *inside* a struct: the same three owners, one
* level deeper, and the writes still go through the owner's command as a whole bundle.
*/ */
import { SetAssetPropertyCommand } from '../../core/commands/assetCommands'; import { edgesInto, type Graph, type GraphNode } from '../../core/graph/Graph';
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import { SetOverrideCommand } from '../../core/commands/sceneObjectCommands';
import { edgesInto, findNode, type Graph, type GraphNode } from '../../core/graph/Graph';
import { resolveDef } from '../../core/graph/nested'; import { resolveDef } from '../../core/graph/nested';
import { inlineInputDef, inlineInputs, type NodeDef } from '../../core/graph/NodeRegistry'; import { inlineInputDef, inlineInputs, type NodeDef } from '../../core/graph/NodeRegistry';
import type { Asset } from '../../core/model/Asset'; import type { Asset } from '../../core/model/Asset';
@@ -28,6 +22,7 @@ import { resolveAssetProps } from '../../core/properties/resolve';
import type { FieldHandlers } from '../inspector/fieldFactory'; import type { FieldHandlers } from '../inspector/fieldFactory';
import { currentProps, currentRef } from '../inspector/objectQueries'; import { currentProps, currentRef } from '../inspector/objectQueries';
import { previewingHandlers, type PreviewedValue } from '../inspector/previewHandlers'; import { previewingHandlers, type PreviewedValue } from '../inspector/previewHandlers';
import { assetValue, currentNode, nodeParamValue, overrideValue } from '../inspector/valueSpecs';
import type { EditorState, ListSlot } from '../state'; import type { EditorState, ListSlot } from '../state';
export type ListDef = Extract<PropertyDef, { kind: 'list' }>; export type ListDef = Extract<PropertyDef, { kind: 'list' }>;
@@ -157,20 +152,14 @@ function assetOwner(state: EditorState, slot: Extract<ListSlot, { kind: 'asset'
const asset = findAsset(state.project, slot.assetId); const asset = findAsset(state.project, slot.assetId);
const def = asset && schemaForAsset(asset).find((d) => d.key === slot.key); const def = asset && schemaForAsset(asset).find((d) => d.key === slot.key);
if (!asset || !def) return null; if (!asset || !def) return null;
const at = () => findAsset(state.project, slot.assetId);
return { return {
def, def,
title: `${asset.name} · ${def.label}`, title: `${asset.name} · ${def.label}`,
current: () => { current: () => {
const now = at(); const now = findAsset(state.project, slot.assetId);
return now ? resolveAssetProps(now)[slot.key] : undefined; return now && resolveAssetProps(now)[slot.key];
},
spec: {
target: at,
read: (found) => found.props[slot.key],
write: (found, value) => (found.props[slot.key] = value),
commit: (value) => new SetAssetPropertyCommand(slot.assetId, slot.key, value),
}, },
spec: assetValue(state, slot.assetId, slot.key),
}; };
} }
@@ -186,18 +175,12 @@ function objectOwner(
def, def,
title: `${ref.object.name} · ${def.label}`, title: `${ref.object.name} · ${def.label}`,
current: () => currentProps(state, slot.holderId, slot.objectId)?.[slot.key], current: () => currentProps(state, slot.holderId, slot.objectId)?.[slot.key],
spec: { spec: overrideValue(state, slot.holderId, slot.objectId, slot.key),
target: () => currentRef(state, slot.holderId, slot.objectId),
read: (found) => found.object.overrides[slot.key],
write: (found, value) => found.setOverride(slot.key, value),
remove: (found) => found.setOverride(slot.key, undefined),
commit: (value) => new SetOverrideCommand(slot.holderId, slot.objectId, slot.key, value),
},
}; };
} }
function nodeOwner(state: EditorState, slot: Extract<ListSlot, { kind: 'node' }>): Owner<GraphNode> | null { function nodeOwner(state: EditorState, slot: Extract<ListSlot, { kind: 'node' }>): Owner<GraphNode> | null {
const found = nodeIn(state, slot); const found = currentNode(state.project, slot.assetId, slot.nodeId);
if (!found) return null; if (!found) return null;
const nodeDef = resolveDef(found.node, found.graph, behaviorLookup(state.project)); const nodeDef = resolveDef(found.node, found.graph, behaviorLookup(state.project));
const def = const def =
@@ -206,14 +189,9 @@ function nodeOwner(state: EditorState, slot: Extract<ListSlot, { kind: 'node' }>
return { return {
def, def,
title: `${nodeDef.label} · ${def.label}`, title: `${nodeDef.label} · ${def.label}`,
current: () => nodeIn(state, slot)?.node.params[slot.key] ?? def.default, current: () =>
spec: { currentNode(state.project, slot.assetId, slot.nodeId)?.node.params[slot.key] ?? def.default,
target: () => nodeIn(state, slot)?.node, spec: nodeParamValue(state, slot.assetId, slot.nodeId, slot.key),
read: (node) => node.params[slot.key],
write: (node, value) => (node.params[slot.key] = value),
remove: (node) => delete node.params[slot.key],
commit: (value) => new SetNodeParamCommand(slot.assetId, slot.nodeId, slot.key, value),
},
}; };
} }
@@ -228,11 +206,3 @@ function inlineListDef(def: NodeDef, found: { node: GraphNode; graph: Graph }, k
const inline = inlineInputDef(port); const inline = inlineInputDef(port);
return inline.kind === 'list' ? inline : null; return inline.kind === 'list' ? inline : null;
} }
/** The node together with its graph, because `resolveDef` needs both. */
function nodeIn(state: EditorState, slot: Extract<ListSlot, { kind: 'node' }>) {
const asset = findAsset(state.project, slot.assetId);
if (asset?.type !== 'behavior') return null;
const node = findNode(asset.graph, slot.nodeId);
return node ? { node, graph: asset.graph } : null;
}
+7 -23
View File
@@ -1,5 +1,5 @@
import type { Command } from '../../core/commands/Command'; import type { Command } from '../../core/commands/Command';
import { CompoundCommand } from '../../core/commands/CompoundCommand'; import { oneStep } from '../../core/commands/CompoundCommand';
import { UnpackContainerCommand } from '../../core/commands/containerCommands'; import { UnpackContainerCommand } from '../../core/commands/containerCommands';
import { ReorderObjectCommand, SetParentCommand } from '../../core/commands/sceneObjectCommands'; import { ReorderObjectCommand, SetParentCommand } from '../../core/commands/sceneObjectCommands';
import { hasColorSwatch } from '../../core/model/AssetTypes'; import { hasColorSwatch } from '../../core/model/AssetTypes';
@@ -9,22 +9,12 @@ import { findAsset, type ObjectHolder } from '../../core/model/Project';
import { deviationMarks } from '../../core/properties/deviation'; import { deviationMarks } from '../../core/properties/deviation';
import { resolveSceneObjectProps } from '../../core/properties/resolve'; import { resolveSceneObjectProps } from '../../core/properties/resolve';
import { type DropSpot, ListView, Menu, Panel, ScrollContainer } from '../../ui'; import { type DropSpot, ListView, Menu, Panel, ScrollContainer } from '../../ui';
import { addCollider, createContainer, removeObjects } from '../actions'; import { addCollider, canAddCollider, createContainer, removeObjects } from '../actions';
import { type EditorState, objectSelection } from '../state'; import { type EditorState, objectSelection } from '../state';
/** /**
* Objects of the scene or container being edited, as a tree (children indented below their parent). * Objects of the scene or container being edited, as a tree; dragging re-parents or reorders and always
* Click selects, Ctrl/Shift-click adds to or removes from the selection. Drag a row onto * keeps world position. Fold state is view state kept here, never in the project.
* another to parent it there, into the gap above or below a row to order it among that row's
* siblings, or onto the empty space below the rows to move it to the top level. The object keeps
* its world position every time.
*
* A row's name turns gold when the object deviates from what it follows – its container template
* or its asset – or when anything below it does, so a folded subtree still shows it.
*
* Rows with children fold: the chevron (or ←/→, or a double-click on the row) toggles them.
* Everything starts collapsed – which ids are open is view state kept here, never part of the
* project, and selecting an object always unfolds the way down to it.
*/ */
export class HierarchyPanel extends Panel { export class HierarchyPanel extends Panel {
private readonly list = new ListView(); private readonly list = new ListView();
@@ -72,12 +62,7 @@ export class HierarchyPanel extends Panel {
this.refresh(); this.refresh();
} }
/** /** A move that leaves the order as it is does nothing, so a drag back to its start costs no undo step. */
* A drop either puts the dragged row inside another one (parenting, which keeps the world
* position) or into a gap, which orders it among the siblings there – and does both when the
* gap belongs to a parent the object did not have. A move that would leave the order as it is
* does nothing, so a drag that lands where it started costs no undo step.
*/
private drop(draggedId: string, spot: DropSpot): void { private drop(draggedId: string, spot: DropSpot): void {
const holder = this.holder(); const holder = this.holder();
const object = holder && findObject(holder, draggedId); const object = holder && findObject(holder, draggedId);
@@ -108,7 +93,7 @@ export class HierarchyPanel extends Panel {
if (object.parentId !== parentId) commands.push(new SetParentCommand(holder.id, draggedId, parentId)); if (object.parentId !== parentId) commands.push(new SetParentCommand(holder.id, draggedId, parentId));
commands.push(new ReorderObjectCommand(holder.id, draggedId, beforeId)); commands.push(new ReorderObjectCommand(holder.id, draggedId, beforeId));
if (parentId) this.expanded.add(parentId); if (parentId) this.expanded.add(parentId);
this.state.execute(commands.length === 1 ? commands[0]! : new CompoundCommand('Move object', commands)); this.state.execute(oneStep('Move object', commands));
} }
/** /**
@@ -165,7 +150,6 @@ export class HierarchyPanel extends Panel {
if (!holder) return undefined; if (!holder) return undefined;
const inScene = holder.type === 'scene'; const inScene = holder.type === 'scene';
const object = findObject(holder, id); const object = findObject(holder, id);
const type = object && findAsset(state.project, object.assetId)?.type;
// Content of a placed container belongs to its template: values can be changed here, the // Content of a placed container belongs to its template: values can be changed here, the
// structure cannot (see `model/instance.ts`). // structure cannot (see `model/instance.ts`).
const source = this.source(); const source = this.source();
@@ -181,7 +165,7 @@ export class HierarchyPanel extends Panel {
}, },
{ {
label: 'Add collider', label: 'Add collider',
disabled: !type || type === 'collider' || many || fromTemplate || instance, disabled: !object || !source || many || !canAddCollider(state.project, source, object),
onSelect: () => addCollider(state, holder.id, id), onSelect: () => addCollider(state, holder.id, id),
}, },
{ {
-1
View File
@@ -8,7 +8,6 @@ import type { EditorState, SaveStatus } from '../state';
export interface ToolbarActions { export interface ToolbarActions {
/** The "Project" dropdown: list, create and delete projects. `anchor` is the button to open the menu under. */ /** The "Project" dropdown: list, create and delete projects. `anchor` is the button to open the menu under. */
openProjectMenu(anchor: Widget): void; openProjectMenu(anchor: Widget): void;
/** The scene dropdown: switch the active scene. */
openSceneMenu(anchor: Widget): void; openSceneMenu(anchor: Widget): void;
/** Play in edit mode, Stop in play mode. */ /** Play in edit mode, Stop in play mode. */
togglePlay(): void; togglePlay(): void;
+11 -26
View File
@@ -6,6 +6,7 @@ import { resolvedHolder } from '../core/model/instance';
import { import {
createEmptyProject, createEmptyProject,
findAsset, findAsset,
findAssetOf,
findObjectHolder, findObjectHolder,
findScene, findScene,
type Project, type Project,
@@ -89,13 +90,8 @@ export type EditorEvent =
type Listener = () => void; type Listener = () => void;
/** /**
* Single source of truth for the editor: the project, undo stack, selection, mode and * Single source of truth for the editor. Play mode swaps `project` for a deep clone (same ids)
* save status. Panels subscribe to coarse events and refresh themselves; there is no * with its own undo stack; panels and commands keep working on it, and `exitPlay()` discards it.
* framework and no virtual DOM.
*
* **Play mode edits a copy.** `enterPlay()` swaps `project` for a deep clone (same ids) with
* its own undo stack; every panel and command keeps working on it, the running world mirrors
* it, and `exitPlay()` throws the copy away and restores the saved model untouched.
*/ */
export class EditorState { export class EditorState {
project: Project = createEmptyProject('', 'Untitled'); project: Project = createEmptyProject('', 'Untitled');
@@ -105,9 +101,8 @@ export class EditorState {
listSlot: ListSlot | null = null; listSlot: ListSlot | null = null;
mode: EditorMode = 'edit'; mode: EditorMode = 'edit';
/** /**
* Play is on but the world is not being ticked. A sub-state of `mode === 'play'` rather than a * A sub-state of play rather than a third mode: the runtime copy, the keyboard and unsaved edits all
* third mode: everything that distinguishes playing from editing (the runtime copy, the * still apply.
* keyboard, edits not being saved) is just as true while paused.
*/ */
paused = false; paused = false;
saveStatus: SaveStatus = 'saved'; saveStatus: SaveStatus = 'saved';
@@ -118,7 +113,6 @@ export class EditorState {
private readonly listeners = new Map<EditorEvent, Set<Listener>>(); private readonly listeners = new Map<EditorEvent, Set<Listener>>();
/** Subscribes to an event; returns the unsubscribe function. */
on(event: EditorEvent, listener: Listener): () => void { on(event: EditorEvent, listener: Listener): () => void {
let set = this.listeners.get(event); let set = this.listeners.get(event);
if (!set) this.listeners.set(event, (set = new Set())); if (!set) this.listeners.set(event, (set = new Set()));
@@ -147,10 +141,7 @@ export class EditorState {
this.emit('mode'); this.emit('mode');
} }
/** /** Returns the runtime copy; the selection survives because ids are identical. */
* Switches to play mode: from now on `project` is a runtime copy of the model. Returns
* that copy (the world is instantiated from it). Selection is kept – ids are identical.
*/
enterPlay(): Project { enterPlay(): Project {
if (this.mode === 'play') return this.project; if (this.mode === 'play') return this.project;
const copy = deserializeProject(serializeProject(this.project), this.project.id); const copy = deserializeProject(serializeProject(this.project), this.project.id);
@@ -171,7 +162,6 @@ export class EditorState {
this.emit('mode'); this.emit('mode');
} }
/** Leaves play mode: the runtime copy is discarded, the saved model comes back unchanged. */
exitPlay(): void { exitPlay(): void {
if (this.mode !== 'play') return; if (this.mode !== 'play') return;
if (this.edit) { if (this.edit) {
@@ -285,7 +275,6 @@ export class EditorState {
this.emit('selection'); this.emit('selection');
} }
/** Ctrl/Shift-click: adds the object to the selection or removes it again. */
toggleObject(holderId: string, objectId: string): void { toggleObject(holderId: string, objectId: string): void {
const s = this.selection; const s = this.selection;
const current = s?.kind === 'object' && s.holderId === holderId ? s.objectIds : []; const current = s?.kind === 'object' && s.holderId === holderId ? s.objectIds : [];
@@ -295,7 +284,6 @@ export class EditorState {
this.select(objectSelection(holderId, next, current.includes(objectId) ? undefined : objectId)); this.select(objectSelection(holderId, next, current.includes(objectId) ? undefined : objectId));
} }
/** Ctrl/Shift-click: adds the node to the selection or removes it again. */
toggleNode(assetId: string, nodeId: string): void { toggleNode(assetId: string, nodeId: string): void {
const s = this.selection; const s = this.selection;
const current = s?.kind === 'node' && s.assetId === assetId ? s.nodeIds : []; const current = s?.kind === 'node' && s.assetId === assetId ? s.nodeIds : [];
@@ -305,12 +293,10 @@ export class EditorState {
this.select(nodeSelection(assetId, next, held ? undefined : nodeId) ?? { kind: 'asset', assetId }); this.select(nodeSelection(assetId, next, held ? undefined : nodeId) ?? { kind: 'asset', assetId });
} }
/** Ids of the selected scene objects (empty unless objects are selected). */
get selectedObjectIds(): string[] { get selectedObjectIds(): string[] {
return this.selection?.kind === 'object' ? this.selection.objectIds : []; return this.selection?.kind === 'object' ? this.selection.objectIds : [];
} }
/** Ids of the selected graph nodes (empty unless nodes are selected). */
get selectedNodeIds(): string[] { get selectedNodeIds(): string[] {
return this.selection?.kind === 'node' ? this.selection.nodeIds : []; return this.selection?.kind === 'node' ? this.selection.nodeIds : [];
} }
@@ -327,9 +313,9 @@ export class EditorState {
if (alive.length !== s.objectIds.length) this.select(objectSelection(s.holderId, alive, s.objectId)); if (alive.length !== s.objectIds.length) this.select(objectSelection(s.holderId, alive, s.objectId));
} }
if (s.kind === 'node') { if (s.kind === 'node') {
const asset = findAsset(this.project, s.assetId); const asset = findAssetOf(this.project, s.assetId, 'behavior');
// Fall back to the behavior asset so the graph stays open when its node vanished (undo). // Fall back to the behavior asset so the graph stays open when its node vanished (undo).
if (!asset || asset.type !== 'behavior') this.select(null); if (!asset) this.select(null);
else { else {
const alive = s.nodeIds.filter((id) => asset.graph.nodes.some((n) => n.id === id)); const alive = s.nodeIds.filter((id) => asset.graph.nodes.some((n) => n.id === id));
if (alive.length !== s.nodeIds.length) if (alive.length !== s.nodeIds.length)
@@ -337,9 +323,9 @@ export class EditorState {
} }
} }
if (s.kind === 'frame') { if (s.kind === 'frame') {
const asset = findAsset(this.project, s.assetId); const asset = findAssetOf(this.project, s.assetId, 'texture');
// Same fallback as a graph node: the texture stays open when its frame vanished. // Same fallback as a graph node: the texture stays open when its frame vanished.
if (!asset || asset.type !== 'texture') this.select(null); if (!asset) this.select(null);
else if (!asset.frames.some((f) => f.id === s.frameId)) else if (!asset.frames.some((f) => f.id === s.frameId))
this.select({ kind: 'asset', assetId: s.assetId }); this.select({ kind: 'asset', assetId: s.assetId });
} }
@@ -350,8 +336,7 @@ export class EditorState {
const s = this.selection; const s = this.selection;
if (!s) return null; if (!s) return null;
if (s.kind !== 'asset' && s.kind !== 'frame') return null; if (s.kind !== 'asset' && s.kind !== 'frame') return null;
const asset = findAsset(this.project, s.assetId); return findAssetOf(this.project, s.assetId, 'texture')?.id ?? null;
return asset && asset.type === 'texture' ? asset.id : null;
} }
/** The behavior asset the current selection refers to (asset, placed object or node), if any. */ /** The behavior asset the current selection refers to (asset, placed object or node), if any. */
+4 -13
View File
@@ -4,7 +4,7 @@
* work on the same shape of data, so the Container tab subclasses this and swaps `holder()`. * work on the same shape of data, so the Container tab subclasses this and swaps `holder()`.
*/ */
import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js'; import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import { CompoundCommand } from '../../core/commands/CompoundCommand'; import { oneStep } from '../../core/commands/CompoundCommand';
import { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/sceneObjectCommands'; import { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
import { isSymbolType } from '../../core/model/AssetTypes'; import { isSymbolType } from '../../core/model/AssetTypes';
import { findObject, subtreeRoots } from '../../core/model/hierarchy'; import { findObject, subtreeRoots } from '../../core/model/hierarchy';
@@ -18,6 +18,7 @@ import { type EditorState, objectSelection } from '../state';
import { import {
activeDrag, activeDrag,
clamp, clamp,
drawBand,
drawGrid, drawGrid,
endDrag, endDrag,
onDragChange, onDragChange,
@@ -299,8 +300,7 @@ export class SceneViewport extends WorldViewport {
ref.setPosition(item.start); ref.setPosition(item.start);
moves.push(new MoveSceneObjectCommand(drag.holderId, item.objectId, next)); moves.push(new MoveSceneObjectCommand(drag.holderId, item.objectId, next));
} }
if (moves.length === 1) this.state.execute(moves[0]!); if (moves.length > 0) this.state.execute(oneStep('Move objects', moves));
else if (moves.length > 1) this.state.execute(new CompoundCommand('Move objects', moves));
} }
private onWheel(e: FederatedWheelEvent): void { private onWheel(e: FederatedWheelEvent): void {
@@ -489,15 +489,6 @@ export class SceneViewport extends WorldViewport {
} }
this.drawDropPreview(view); this.drawDropPreview(view);
const m = this.marquee; const m = this.marquee;
if (m?.active) { if (m?.active) drawBand(this.gizmos, m.start, m.current);
const x = Math.min(m.start.x, m.current.x);
const y = Math.min(m.start.y, m.current.y);
const w = Math.abs(m.current.x - m.start.x);
const h = Math.abs(m.current.y - m.start.y);
this.gizmos
.rect(x, y, w, h)
.fill({ color: Theme.colors.marquee, alpha: 0.12 })
.stroke({ width: 1, color: Theme.colors.marquee });
}
} }
} }
-3
View File
@@ -30,7 +30,6 @@ export function mountPlayground(app: Application, root: VStack): void {
const row = new HStack({ gap: Theme.padding, padding: Theme.padding }); const row = new HStack({ gap: Theme.padding, padding: Theme.padding });
// Column 1: inputs
const inputs = new Panel({ title: 'Inputs' }); const inputs = new Panel({ title: 'Inputs' });
inputs.content.add(new Label('Text field')); inputs.content.add(new Label('Text field'));
inputs.content.add( inputs.content.add(
@@ -67,7 +66,6 @@ export function mountPlayground(app: Application, root: VStack): void {
inputs.content.add(checkRow); inputs.content.add(checkRow);
row.add(inputs, 1); row.add(inputs, 1);
// Column 2: buttons + popups
const actions = new Panel({ title: 'Buttons & popups' }); const actions = new Panel({ title: 'Buttons & popups' });
actions.content.add(new Button('Default', { onClick: () => log('default clicked') })); actions.content.add(new Button('Default', { onClick: () => log('default clicked') }));
actions.content.add(new Button('Primary', { variant: 'primary', onClick: () => log('primary clicked') })); actions.content.add(new Button('Primary', { variant: 'primary', onClick: () => log('primary clicked') }));
@@ -102,7 +100,6 @@ export function mountPlayground(app: Application, root: VStack): void {
); );
row.add(actions, 1); row.add(actions, 1);
// Column 3: list in a scroll container
const listPanel = new Panel({ title: 'List (scroll)', padding: 0 }); const listPanel = new Panel({ title: 'List (scroll)', padding: 0 });
const scroll = new ScrollContainer(); const scroll = new ScrollContainer();
const list = new ListView(); const list = new ListView();
+1 -6
View File
@@ -2,12 +2,7 @@ import type { ENTITY_HANDLE } from '../core/graph/NodeContext';
import type { AssetType } from '../core/model/AssetTypes'; import type { AssetType } from '../core/model/AssetTypes';
import type { PropertyBag } from '../core/model/Properties'; import type { PropertyBag } from '../core/model/Properties';
/** /** A live instance of a scene object; its resolved props are mutable runtime state the world mirrors back. */
* A live instance created from a scene object when Play starts. Holds the *resolved*
* property values as mutable runtime state. Systems change entities freely; the world
* mirrors them into the play-mode copy of the project (never the saved model) so the
* editor panels can show and edit them while playing.
*/
export interface Entity { export interface Entity {
/** What tells a graph this object is a live entity and not a struct value. */ /** What tells a graph this object is a live entity and not a struct value. */
readonly [ENTITY_HANDLE]: true; readonly [ENTITY_HANDLE]: true;
+5 -19
View File
@@ -29,13 +29,8 @@ export interface ModelWrite {
} }
/** /**
* The running game: entities instantiated from one scene plus the systems ticking them. * The running game: entities of one scene plus the systems ticking them. Built on the play copy
* * (discarded on stop) or, for the editor run, on the saved model, where its writes stay.
* The world mirrors the scene of the project it was built from: while playing that is a copy of
* the model, thrown away on stop; the editor run builds on the saved one, where the writes stay.
* `syncFromModel()` pulls editor changes (moved, placed, removed or re-parented objects, edited
* properties) into the entities; `syncToModel()` pushes what the systems changed (positions,
* variables, destroyed entities) back, so the panels show live values.
*/ */
export class World { export class World {
readonly entities: Entity[] = []; readonly entities: Entity[] = [];
@@ -82,12 +77,7 @@ export class World {
return this.elapsed; return this.elapsed;
} }
/** /** Makes the entities match the scene; props are updated in place so systems holding a bag stay current. */
* Makes the entities match the scene: one entity per placed object (created on demand,
* dropped when the object is gone), positions, names and resolved properties taken from
* the model, parent links re-established. Property bags are updated in place so systems
* holding a reference keep seeing the current values.
*/
syncFromModel(): void { syncFromModel(): void {
const scene = findScene(this.project, this.sceneId); const scene = findScene(this.project, this.sceneId);
if (!scene) return; if (!scene) return;
@@ -145,11 +135,8 @@ export class World {
} }
/** /**
* Creates a new instance of an asset while running (Spawn node): the object is added to * Adds the object to the scene and creates only its entity, leaving the others alone (they may have
* the scene of the runtime copy – so the panels list it and a full sync keeps it – and its * moved this tick already). Position is local to `parent`, world when there is none.
* entity is created right away, without touching the other entities (they may have moved
* during this tick already). Position is local to `parent`, world when there is none.
* Returns null for unknown or unplaceable assets.
*/ */
spawn( spawn(
assetId: string, assetId: string,
@@ -279,7 +266,6 @@ export class World {
return this.entities.filter((e) => e.parent === entity); return this.entities.filter((e) => e.parent === entity);
} }
/** Everything parented below an entity, at any depth. */
descendantsOf(entity: Entity): Entity[] { descendantsOf(entity: Entity): Entity[] {
return descendantsBelow( return descendantsBelow(
entity, entity,
+28 -10
View File
@@ -10,10 +10,10 @@ import {
import { stepKeyRepeat } from '../../core/graph/keyRepeat'; import { stepKeyRepeat } from '../../core/graph/keyRepeat';
import { behaviorLookup } from '../../core/model/behaviorPrograms'; import { behaviorLookup } from '../../core/model/behaviorPrograms';
import { type PropertyBag, textOf } from '../../core/model/Properties'; import { type PropertyBag, textOf } from '../../core/model/Properties';
import { findAsset, type Project } from '../../core/model/Project'; import { findAssetOf, type Project } from '../../core/model/Project';
import type { Entity } from '../Entity'; import type { Entity } from '../Entity';
import type { World } from '../World'; import type { World } from '../World';
import { bodyOf, type CollisionPair, type CollisionSystem } from './CollisionSystem'; import { bodyOf, type CollisionPair, CollisionSystem } from './CollisionSystem';
import type { Input } from './Input'; import type { Input } from './Input';
import type { System } from './System'; import type { System } from './System';
@@ -36,6 +36,27 @@ interface Instance {
* Runs the graph of every behavior entity. Each instance has its own interpreter (and * Runs the graph of every behavior entity. Each instance has its own interpreter (and
* therefore its own timers and variable state); the project is only read to find graphs. * therefore its own timers and variable state); the project is only read to find graphs.
*/ */
const ERROR_PREFIX = 'Error in behavior';
/** Collisions are detected first, so behaviors see this frame's enter/stay/exit events. */
export function addGameSystems(
world: World,
project: Project,
input: Input,
shouldRun?: (entity: Entity) => boolean,
): BehaviorSystem {
const collisions = new CollisionSystem();
const behaviors = new BehaviorSystem(project, input, collisions, shouldRun);
world.addSystem(collisions);
world.addSystem(behaviors);
return behaviors;
}
/** The behavior failure that stopped a run, if that is why it stopped. */
export function behaviorError(world: World): string | undefined {
return world.log.find((line) => line.startsWith(ERROR_PREFIX));
}
export class BehaviorSystem implements System { export class BehaviorSystem implements System {
private instances: Instance[] = []; private instances: Instance[] = [];
private world: World | null = null; private world: World | null = null;
@@ -64,10 +85,8 @@ export class BehaviorSystem implements System {
} }
/** /**
* One instance per behavior entity: entities that appeared (placed while playing) get an * One instance per behavior entity. A switched-off behavior gets its instance and `On start` only when
* interpreter and their `On start`; instances whose entity is gone are dropped. A behavior * switched on, which is what lets a hidden screen wake up when shown.
* that is switched off has no instance yet – it gets one, and its `On start`, the moment it
* is switched on. That is what lets a hidden screen wake up when it is shown.
*/ */
private syncInstances(world: World): void { private syncInstances(world: World): void {
this.instances = this.instances.filter((i) => world.entities.includes(i.entity)); this.instances = this.instances.filter((i) => world.entities.includes(i.entity));
@@ -79,8 +98,8 @@ export class BehaviorSystem implements System {
this.instances.some((i) => i.entity === entity) this.instances.some((i) => i.entity === entity)
) )
continue; continue;
const asset = findAsset(this.project, entity.assetId); const asset = findAssetOf(this.project, entity.assetId, 'behavior');
if (!asset || asset.type !== 'behavior') continue; if (!asset) continue;
const trace: Trace = { edges: new Map(), nodes: new Map() }; const trace: Trace = { edges: new Map(), nodes: new Map() };
const interpreter = new GraphInterpreter(asset.graph, behaviorLookup(this.project)); const interpreter = new GraphInterpreter(asset.graph, behaviorLookup(this.project));
interpreter.trace = { interpreter.trace = {
@@ -175,7 +194,6 @@ export class BehaviorSystem implements System {
fire('OnCollisionExit', this.collisions.exits); fire('OnCollisionExit', this.collisions.exits);
} }
/** Object ids of the behaviors this system is currently running. */
runningIds(): string[] { runningIds(): string[] {
return this.instances.map((i) => i.entity.objectId); return this.instances.map((i) => i.entity.objectId);
} }
@@ -210,7 +228,7 @@ export class BehaviorSystem implements System {
body(instance.interpreter, ctx); body(instance.interpreter, ctx);
} catch (error) { } catch (error) {
world.log.push( world.log.push(
`Error in behavior "${instance.entity.name}": ${error instanceof Error ? error.message : String(error)}`, `${ERROR_PREFIX} "${instance.entity.name}": ${error instanceof Error ? error.message : String(error)}`,
); );
world.stopRequested = true; world.stopRequested = true;
} }
+4 -12
View File
@@ -24,10 +24,8 @@ export interface CollisionPair {
} }
/** /**
* Detects overlaps between collider entities once per tick and sorts them into enter / * Sorts collider overlaps into enter / stay / exit per tick, detection only; colliders of one body never
* stay / exit lists for the frame. Detection only – nothing is pushed apart. Colliders * collide. Runs before the BehaviorSystem so graphs see this frame's events.
* of the same body (same parent) never collide with each other. Runs before the
* BehaviorSystem so graphs see this frame's events.
*/ */
export class CollisionSystem implements System { export class CollisionSystem implements System {
enters: CollisionPair[] = []; enters: CollisionPair[] = [];
@@ -78,13 +76,8 @@ export class CollisionSystem implements System {
} }
/** /**
* Moves `entity` by (dx, dy) in its local units, one axis at a time (x, then y): after * Moves one axis at a time, stopping each at contact so the object slides along walls. An existing
* each axis step its solid colliders are pushed back to just touch whatever solid collider * overlap is never resolved (no jolt) but never deepened, so an object can leave a wall but not burrow in.
* they ran into, so the remaining axis still moves – the object slides along walls.
* An overlap that already existed is never resolved (no jolt) but also never deepened, so
* an object inside a wall can leave it without being able to burrow further in. Long moves
* are split into sub-steps so nothing jumps across a thin wall. Triggers, the entity's own
* colliders and colliders that travel with it (descendants) never block.
*/ */
moveAndSlide( moveAndSlide(
world: World, world: World,
@@ -114,7 +107,6 @@ export class CollisionSystem implements System {
delta: number, delta: number,
): boolean { ): boolean {
if (delta === 0 || own.length === 0) return false; if (delta === 0 || own.length === 0) return false;
// Long steps are split so a fast mover cannot jump across a thin obstacle in one frame.
const steps = Math.min( const steps = Math.min(
MAX_STEPS, MAX_STEPS,
Math.max(1, Math.ceil(Math.abs(delta) / stepLimit(own, obstacles, axis))), Math.max(1, Math.ceil(Math.abs(delta) / stepLimit(own, obstacles, axis))),
-2
View File
@@ -66,12 +66,10 @@ export class Input {
return this.down.has(key); return this.down.has(key);
} }
/** Keys that went down since the last frame. */
get justPressed(): ReadonlySet<string> { get justPressed(): ReadonlySet<string> {
return this.pressed; return this.pressed;
} }
/** Keys that went up since the last frame. */
get justReleased(): ReadonlySet<string> { get justReleased(): ReadonlySet<string> {
return this.released; return this.released;
} }
-2
View File
@@ -5,11 +5,9 @@
*/ */
export interface TextEditTarget { export interface TextEditTarget {
/** Called whenever value or selection changed. */
onBridgeInput(value: string, selectionStart: number, selectionEnd: number): void; onBridgeInput(value: string, selectionStart: number, selectionEnd: number): void;
/** Called for navigation keys (Enter, Escape, Tab, arrows …). Return true to consume. */ /** Called for navigation keys (Enter, Escape, Tab, arrows …). Return true to consume. */
onBridgeKey(event: KeyboardEvent): boolean; onBridgeKey(event: KeyboardEvent): boolean;
/** The bridge lost DOM focus. */
onBridgeBlur(): void; onBridgeBlur(): void;
} }
-1
View File
@@ -70,7 +70,6 @@ export abstract class Widget extends Container {
this.invalidate(); this.invalidate();
} }
// Focus hooks – called by the FocusManager.
onFocus(): void { onFocus(): void {
this.focused = true; this.focused = true;
this.invalidate(); this.invalidate();
+2 -7
View File
@@ -17,13 +17,8 @@ interface LastPress {
let last: LastPress = { key: null, button: -1, time: 0, x: 0, y: 0, count: 0 }; let last: LastPress = { key: null, button: -1, time: 0, x: 0, y: 0, count: 0 };
/** /**
* How often `key` was pressed in a row with the same button – 2 on a double-click. Pixi counts * Press count in a row (2 on a double-click), since Pixi leaves `detail` at 0 on `pointerdown`. `key`
* clicks only for the `click`/`pointertap` it synthesises and leaves `detail` at 0 on * must survive a rebuild of its widget: the first click's selection can recreate the list rows.
* `pointerdown`, so a press handler reading `e.detail` never sees a second click.
*
* `key` identifies what was hit and should survive a rebuild of the widget it belongs to: list
* rows are thrown away and recreated whenever the list refreshes, which a selection – the first
* click – can trigger.
*/ */
export function pressCount(e: FederatedPointerEvent, key: unknown): number { export function pressCount(e: FederatedPointerEvent, key: unknown): number {
const gap = e.timeStamp - last.time; const gap = e.timeStamp - last.time;
+2 -4
View File
@@ -22,10 +22,8 @@ let session: Session | null = null;
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
/** /**
* A drag that left the widget it started in, e.g. an asset on its way to the viewport. The * A drag that left its source widget; whoever is under the pointer reads `activeDrag()` on `pointerup`.
* source starts it and forgets it; whoever is under the pointer reads `activeDrag()` and, on * The session ends via a stage listener, which fires last, so a drop target still sees the payload.
* `pointerup`, acts. The session ends itself on release: it listens on the stage, which sees
* every pointer event last, so a drop target still has the payload when its own handler runs.
*/ */
export function startDrag(payload: DragPayload, label: string): void { export function startDrag(payload: DragPayload, label: string): void {
endDrag(); endDrag();
+7
View File
@@ -50,3 +50,10 @@ export function drawGrid(g: Graphics, options: GridOptions): void {
} }
const GRID_COLOR = 0xffffff; const GRID_COLOR = 0xffffff;
/** A rubber-band selection between two corners, in whatever space `g` draws. */
export function drawBand(g: Graphics, a: { x: number; y: number }, b: { x: number; y: number }): void {
g.rect(Math.min(a.x, b.x), Math.min(a.y, b.y), Math.abs(b.x - a.x), Math.abs(b.y - a.y))
.fill({ color: Theme.colors.marquee, alpha: 0.12 })
.stroke({ width: 1, color: Theme.colors.marquee });
}
+1 -1
View File
@@ -2,7 +2,7 @@ export { Widget, Box, type Size } from './Widget';
export { HStack, VStack, Spacer, Stack, type StackOptions } from './layout/Stack'; export { HStack, VStack, Spacer, Stack, type StackOptions } from './layout/Stack';
export { Theme, textStyle, type TextStyleToken } from './Theme'; export { Theme, textStyle, type TextStyleToken } from './Theme';
export { initUI, ui, type UIContext } from './UIContext'; export { initUI, ui, type UIContext } from './UIContext';
export { drawControlBox, drawGrid, type ControlBoxOptions, type GridOptions } from './draw'; export { drawBand, drawControlBox, drawGrid, type ControlBoxOptions, type GridOptions } from './draw';
export { clamp, centreY, formatBytes, formatNumber, NUMBER_DECIMALS, snapToStep, zoomFactor } from './math'; export { clamp, centreY, formatBytes, formatNumber, NUMBER_DECIMALS, snapToStep, zoomFactor } from './math';
export { activeDrag, type DragPayload, endDrag, onDragChange, startDrag } from './drag'; export { activeDrag, type DragPayload, endDrag, onDragChange, startDrag } from './drag';
export { Label, truncate } from './widgets/Label'; export { Label, truncate } from './widgets/Label';
+2 -4
View File
@@ -11,10 +11,8 @@ export interface MatrixValues {
} }
/** /**
* The `matrix` a Graphics fill needs so that only `rect` of a `sourceWidth`×`sourceHeight` image * Pixi v8 Graphics fills ignore a `Texture`'s `frame`, so a frame must be a fill matrix. Pixi inverts it
* covers the filled shape. A Pixi v8 Graphics fill ignores a `Texture`'s `frame` and draws the * against the shape's bounds, hence the inverse of "unit square onto the frame".
* whole source, so a frame has to be a fill matrix; Pixi inverts it against the shape's own
* bounds, hence the inverse of "unit square onto the frame" here.
*/ */
export function frameFillMatrix( export function frameFillMatrix(
rect: FrameRect, rect: FrameRect,
+1 -2
View File
@@ -121,8 +121,7 @@ describe('assetRefChoices', () => {
}); });
describe('node asset params', () => { describe('node asset params', () => {
// Guards against the old second notion of an asset reference: an enum with empty options // An asset param uses the asset kind, never an enum with empty options filled in by the inspector.
// that the graph inspector filled in.
it('use the asset kind and offer every placeable asset', () => { it('use the asset kind and offer every placeable asset', () => {
const params = BUILT_IN_NODES.flatMap((def) => def.params).filter((p) => p.key === 'asset'); const params = BUILT_IN_NODES.flatMap((def) => def.params).filter((p) => p.key === 'asset');
expect(params.length).toBeGreaterThan(0); expect(params.length).toBeGreaterThan(0);
+1 -2
View File
@@ -217,8 +217,7 @@ describe('move and slide', () => {
if (i > 60) expect(blockedY).toBe(true); if (i > 60) expect(blockedY).toBe(true);
} }
expect(crateA.y).toBeGreaterThan(240 - SKIN); expect(crateA.y).toBeGreaterThan(240 - SKIN);
// A step larger than the obstacle used to jump clean over it: after the move nothing // A step larger than the obstacle must not jump over it: past the wall nothing overlaps to push back.
// overlapped any more, so nothing pushed back and the mover was through the wall.
crateA.y = 0; crateA.y = 0;
expect(collisions.moveAndSlide(world, crateA, 0, 5000)).toEqual({ blockedX: false, blockedY: true }); expect(collisions.moveAndSlide(world, crateA, 0, 5000)).toEqual({ blockedX: false, blockedY: true });
expect(crateA.y).toBeCloseTo(240, 6); expect(crateA.y).toBeCloseTo(240, 6);
+1 -4
View File
@@ -81,10 +81,7 @@ describe('a Sequence sized by what is plugged into it', () => {
}); });
}); });
/** /** The point of a struct: a bundle crosses a behavior boundary on ONE edge. */
* The point of a struct: a bundle crosses a behavior boundary on ONE edge. Tetris carries the same
* state today as eight parallel ports wired by hand, which is the pain this replaces.
*/
describe('a struct travelling through a nested behavior', () => { describe('a struct travelling through a nested behavior', () => {
it('goes in as one port and comes back out as its parts', () => { it('goes in as one port and comes back out as its parts', () => {
const fixture = makeProject(); const fixture = makeProject();
+14 -4
View File
@@ -4,9 +4,9 @@ import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'
import { sameValue, textOf } from '../../src/core/model/Properties'; import { sameValue, textOf } from '../../src/core/model/Properties';
import { findAsset, objectHolders, type Project } from '../../src/core/model/Project'; import { findAsset, objectHolders, type Project } from '../../src/core/model/Project';
import { deserializeProject } from '../../src/core/store/serialize'; import { deserializeProject } from '../../src/core/store/serialize';
import { coerceValue, variableDef } from '../../src/core/properties/PropertySchema'; import { coerceField, coerceValue, variableDef } from '../../src/core/properties/PropertySchema';
import { deviatesFromAsset } from '../../src/core/properties/resolve'; import { deviatesFromAsset } from '../../src/core/properties/resolve';
import { asEntity, asStruct, writeTyped } from '../../src/core/graph'; import { asBoolean, asEntity, asStruct, writeTyped } from '../../src/core/graph';
import { fakeEntity, makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers'; import { fakeEntity, makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers';
const findObject = (project: Project, id: string) => const findObject = (project: Project, id: string) =>
@@ -28,8 +28,7 @@ describe('a list as a value', () => {
}); });
it('survives inside a container instance too, where no schema is there to coerce it', () => { it('survives inside a container instance too, where no schema is there to coerce it', () => {
// Instance edits are read from an untyped slot: the old filter kept number/string/boolean and // Instance edits are read from an untyped slot, so a primitives-only filter would drop the list silently.
// dropped everything else, which would have swallowed the list without a word.
const loaded = deserializeProject( const loaded = deserializeProject(
{ {
name: 'P', name: 'P',
@@ -139,3 +138,14 @@ describe('a struct as a value', () => {
expect(bag.piece).toEqual({ x: 1 }); expect(bag.piece).toEqual({ x: 1 });
}); });
}); });
describe('a yes/no written as text', () => {
it('reads its spelling the same in a property, a struct field and a graph', () => {
const spellings = ['false', ' No ', '0', '', 'true', 'yes'];
const expected = [false, false, false, false, true, true];
const def = variableDef('on', 'boolean', true);
expect(spellings.map((s) => coerceValue(def, s))).toEqual(expected);
expect(spellings.map((s) => coerceField(s, 'boolean'))).toEqual(expected);
expect(spellings.map((s) => asBoolean(s))).toEqual(expected);
});
});
+8
View File
@@ -261,6 +261,14 @@ describe('declared ports', () => {
expect(runOnStart(fixture, caller, { result: 0 }).ctx.variables.result).toBe(6); expect(runOnStart(fixture, caller, { result: 0 }).ctx.variables.result).toBe(6);
}); });
it('a node of an unknown type is refused with a CommandError, which the editor turns into a notice', () => {
const fixture = makeProject();
const behavior = makeBehavior(fixture);
const ports = [{ key: 'a', kind: 'number' as const }];
behavior.graph.nodes.push({ id: 'n', type: 'NoSuchNode', x: 0, y: 0, params: {}, ports });
expect(() => new RemovePortCommand(behavior.id, 'n', 'a').execute(fixture.project)).toThrow(CommandError);
});
it('removing one takes the edges hanging on it, in both graphs, and undo brings them back', () => { it('removing one takes the edges hanging on it, in both graphs, and undo brings them back', () => {
const fixture = makeProject(); const fixture = makeProject();
const callee = doubler(fixture); const callee = doubler(fixture);
+8 -8
View File
@@ -55,7 +55,7 @@ const pointer = (overrides: Partial<NodeContext['pointer']>): Partial<NodeContex
}); });
const CASES: Record<string, Case[]> = { const CASES: Record<string, Case[]> = {
// --- flow ------------------------------------------------------------------------------------ // flow
Branch: [ Branch: [
{ name: 'true takes then', inputs: { condition: true }, next: 'then' }, { name: 'true takes then', inputs: { condition: true }, next: 'then' },
@@ -103,7 +103,7 @@ const CASES: Record<string, Case[]> = {
}, },
], ],
// --- variables ------------------------------------------------------------------------------- // variables
GetVariable: [ GetVariable: [
{ name: 'reads a variable', params: { variable: 'speed' }, outputs: { value: 100 } }, { name: 'reads a variable', params: { variable: 'speed' }, outputs: { value: 100 } },
@@ -151,7 +151,7 @@ const CASES: Record<string, Case[]> = {
}, },
], ],
// --- references and properties ---------------------------------------------------------------- // references and properties
Self: [{ name: 'outputs the behavior object', outputs: { object: fakeContext().self } }], Self: [{ name: 'outputs the behavior object', outputs: { object: fakeContext().self } }],
Owner: [ Owner: [
@@ -237,7 +237,7 @@ const CASES: Record<string, Case[]> = {
}, },
], ],
// --- literals and math ------------------------------------------------------------------------- // literals and math
Number: [ Number: [
{ name: 'outputs its setting', params: { value: 7.5 }, outputs: { value: 7.5 } }, { name: 'outputs its setting', params: { value: 7.5 }, outputs: { value: 7.5 } },
@@ -306,7 +306,7 @@ const CASES: Record<string, Case[]> = {
}, },
], ],
// --- text and comparison ----------------------------------------------------------------------- // text and comparison
ToText: [ ToText: [
{ name: 'formats with decimals', inputs: { value: 12.345, decimals: 1 }, outputs: { text: '12.3' } }, { name: 'formats with decimals', inputs: { value: 12.345, decimals: 1 }, outputs: { text: '12.3' } },
@@ -334,7 +334,7 @@ const CASES: Record<string, Case[]> = {
outputs: { text: 'b' }, outputs: { text: 'b' },
}, },
], ],
// --- lists ----------------------------------------------------------------------------------- // lists
List: [ List: [
{ name: 'hands out the list it was given', params: { value: [1, 2, 3] }, outputs: { value: [1, 2, 3] } }, { name: 'hands out the list it was given', params: { value: [1, 2, 3] }, outputs: { value: [1, 2, 3] } },
@@ -461,7 +461,7 @@ const CASES: Record<string, Case[]> = {
{ name: '"0" as text counts as false', inputs: { value: '0' }, outputs: { result: true } }, { name: '"0" as text counts as false', inputs: { value: '0' }, outputs: { result: true } },
], ],
// --- vectors and input -------------------------------------------------------------------------- // vectors and input
Length: [ Length: [
{ name: '3-4-5 triangle', inputs: { x: 3, y: 4 }, outputs: { result: 5 } }, { name: '3-4-5 triangle', inputs: { x: 3, y: 4 }, outputs: { result: 5 } },
@@ -585,7 +585,7 @@ const CASES: Record<string, Case[]> = {
}, },
], ],
// --- actions -------------------------------------------------------------------------------------- // actions
Move: [ Move: [
{ {
-4
View File
@@ -7,7 +7,6 @@ import {
deviatesFromTemplate, deviatesFromTemplate,
inheritedValue, inheritedValue,
isModified, isModified,
isOverridden,
resolveAssetProps, resolveAssetProps,
resolveSceneObjectProps, resolveSceneObjectProps,
} from '../../src/core/properties/resolve'; } from '../../src/core/properties/resolve';
@@ -70,8 +69,6 @@ describe('resolve', () => {
object.overrides.color = 0x0000ff; object.overrides.color = 0x0000ff;
expect(resolveSceneObjectProps(sprite, object).color).toBe(0x0000ff); expect(resolveSceneObjectProps(sprite, object).color).toBe(0x0000ff);
expect(isOverridden(object, 'color')).toBe(true);
expect(isOverridden(object, 'width')).toBe(false);
expect(inheritedValue(sprite, 'color')).toBe(0x00ff00); expect(inheritedValue(sprite, 'color')).toBe(0x00ff00);
}); });
@@ -92,7 +89,6 @@ describe('resolve', () => {
expect(deviatesFromAsset(object, sprite, 'width')).toBe(false); // no override at all expect(deviatesFromAsset(object, sprite, 'width')).toBe(false); // no override at all
object.overrides.width = 250; // same value as the asset → not a deviation object.overrides.width = 250; // same value as the asset → not a deviation
expect(isOverridden(object, 'width')).toBe(true);
expect(deviatesFromAsset(object, sprite, 'width')).toBe(false); expect(deviatesFromAsset(object, sprite, 'width')).toBe(false);
object.overrides.width = 300; object.overrides.width = 300;
+1 -1
View File
@@ -68,7 +68,7 @@ describe('cycle-safe walks in the runtime', () => {
expect(worldPositionOf(b)).toEqual({ x: 3, y: 30, z: 0 }); expect(worldPositionOf(b)).toEqual({ x: 3, y: 30, z: 0 });
}); });
// Guards `travelsWith`: before the shared iterator it walked parents without a seen-set and hung here. // Guards `travelsWith`: a parent walk without a seen-set hangs here.
it('moveAndSlide terminates when a collider sits in a cyclic parent chain', () => { it('moveAndSlide terminates when a collider sits in a cyclic parent chain', () => {
const f = makeSpriteScene(); const f = makeSpriteScene();
const moverId = place(f, f.spriteId, { x: 0, y: 0 }); const moverId = place(f, f.spriteId, { x: 0, y: 0 });
+1 -4
View File
@@ -124,10 +124,7 @@ describe('the address a list is edited through', () => {
expect(resolveList(f.state, words)!.value).toEqual(['red', 'blue']); expect(resolveList(f.state, words)!.value).toEqual(['red', 'blue']);
}); });
/** /** An unconnected `list` input carries its inline value in the same params bag a setting does. */
* An unconnected `list` input carries its inline value in the same params bag a setting does,
* so it is the same slot – before this it offered a number field instead.
*/
it('reaches the inline value of an unconnected list input', () => { it('reaches the inline value of an unconnected list input', () => {
const add = new AddNodeCommand(f.behavior.id, 'Count', 0, 0); const add = new AddNodeCommand(f.behavior.id, 'Count', 0, 0);
expect(f.state.execute(add)).toBe(true); expect(f.state.execute(add)).toBe(true);
+2 -4
View File
@@ -1,8 +1,6 @@
/** /**
* A variable is drawn by one builder on the asset that declares it and on a placed object, and * One builder draws a variable on its asset (renamable) and on a placed object (names are labels).
* every row of the inspector shares one column grid. The asset may rename and reshape; a placed * `NumberField extends TextField`, so the tests go by the name's widget rather than counting by type.
* object may not, so there the names are labels. `NumberField extends TextField`, so counting
* editable widgets by type would count the value fields too: the tests go by the name's widget.
*/ */
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { createEmptyProject } from '../../src/core/model/Project'; import { createEmptyProject } from '../../src/core/model/Project';
+3 -6
View File
@@ -39,8 +39,7 @@ describe('formatNumber', () => {
}); });
it('never hides a fractional value behind a whole number', () => { it('never hides a fractional value behind a whole number', () => {
// The bug this replaces: a field with step 1 showed a stored 100.234 as "100", and // A step-1 field showing 100.234 as "100" would write the rounded value back on commit.
// committing that text wrote the rounded value back into the project.
expect(formatNumber(100.234)).toBe('100.234'); expect(formatNumber(100.234)).toBe('100.234');
expect(formatNumber(0.001)).toBe('0.001'); expect(formatNumber(0.001)).toBe('0.001');
}); });
@@ -71,8 +70,7 @@ describe('formatNumber', () => {
describe('roundTo', () => { describe('roundTo', () => {
it('keeps a value at the precision its field shows', () => { it('keeps a value at the precision its field shows', () => {
// Why the field rounds instead of only formatting: a `decimals: 0` field used to display // The field rounds, not just formats, so a `decimals: 0` field never shows "641" over a stored 640.5.
// "641" while the project still held 640.5.
expect(roundTo(640.5, 0)).toBe(641); expect(roundTo(640.5, 0)).toBe(641);
expect(roundTo(32.4, 0)).toBe(32); expect(roundTo(32.4, 0)).toBe(32);
expect(roundTo(1.2345)).toBe(1.234); expect(roundTo(1.2345)).toBe(1.234);
@@ -90,8 +88,7 @@ describe('roundTo', () => {
describe('snapToStep', () => { describe('snapToStep', () => {
it('lands on whole numbers for a step-1 field', () => { it('lands on whole numbers for a step-1 field', () => {
// The bug: a scrub added fractional device pixels to an already fractional value, so // A scrub adds fractional device pixels; without snapping X would land on 100.667 instead of 101.
// dragging X produced values like 100.667 instead of 101.
expect(snapToStep(100.667, 1)).toBe(101); expect(snapToStep(100.667, 1)).toBe(101);
expect(snapToStep(100.333, 1)).toBe(100); expect(snapToStep(100.333, 1)).toBe(100);
expect(snapToStep(-3.5, 1)).toBe(-3); expect(snapToStep(-3.5, 1)).toBe(-3);
@@ -0,0 +1,2 @@
- Scene: Add collider button aus insepctor entfernen
- Graph: Delete node button aus inspector entfernen
-1
View File
@@ -121,7 +121,6 @@ export async function inspector(t) {
check('viewport drag moved the red crate', o1Now.x > -150, JSON.stringify(o1Now)); check('viewport drag moved the red crate', o1Now.x > -150, JSON.stringify(o1Now));
await shot('06-dragged'); await shot('06-dragged');
// 5. Autosave → server.
await sleep(1800); await sleep(1800);
const saved = await api(`projects/${projectId}`); const saved = await api(`projects/${projectId}`);
const renamed = sceneOf(saved).objects.find((o) => o.id === 'o2'); const renamed = sceneOf(saved).objects.find((o) => o.id === 'o2');
+24 -3
View File
@@ -8,6 +8,7 @@ export async function graph(t) {
sleep, sleep,
evaluate, evaluate,
json, json,
find,
shot, shot,
click, click,
clickText, clickText,
@@ -70,9 +71,8 @@ export async function graph(t) {
`${axisUndone.x},${axisUndone.y}`, `${axisUndone.x},${axisUndone.y}`,
); );
// 13b. Regression: typing a query and then *clicking* a row must add exactly one node. // 13b. Typing a query and then *clicking* a row adds exactly one node: the search field's
// (The search field's blur-commit once picked the first match and the row click picked // blur-commit must not pick the first match as well.
// it again → two nodes.)
const gcenter = await json('__gm.graph.center()'); const gcenter = await json('__gm.graph.center()');
const beforeClickPick = await graphCounts(); const beforeClickPick = await graphCounts();
await click(gcenter.x + 250, gcenter.y + 250, 'right'); await click(gcenter.x + 250, gcenter.y + 250, 'right');
@@ -742,6 +742,27 @@ export async function graph(t) {
await undo(); await undo();
await sleep(200); await sleep(200);
const forkedNow = async () =>
(await asset('mover')).graph.edges.filter(
(e) => e.from.node === edge.from.node && e.from.port === edge.from.port,
).length;
const portNow = await json(
`__gm.graph.portAt(${JSON.stringify(edge.from.node)}, ${JSON.stringify(edge.from.port)}, true)`,
);
await click(portNow.x, portNow.y, 'right');
await sleep(150);
const offered = await find('Disconnect 2 links');
if (offered) await click(offered.x, offered.y);
await sleep(250);
const cut = await forkedNow();
await undo();
await sleep(250);
check(
'disconnecting both links at a port is one undo step',
Boolean(offered) && cut === 0 && (await forkedNow()) === 2,
JSON.stringify({ offered: Boolean(offered), cut, after: await forkedNow() }),
);
// 13q. Collapsing a node: the title bar alone, every link meeting at one point per side. No new // 13q. Collapsing a node: the title bar alone, every link meeting at one point per side. No new
// link lands on it until it is expanded, and the state is saved with the project. // link lands on it until it is expanded, and the state is saved with the project.
await select({ kind: 'asset', assetId: 'mover' }); await select({ kind: 'asset', assetId: 'mover' });
+22 -13
View File
@@ -2,13 +2,8 @@ import { instanceChild } from '../lib/builders.mjs';
import { same } from '../lib/harness.mjs'; import { same } from '../lib/harness.mjs';
/** /**
* Multi-selection and placed containers. * Multi-selection and placed containers: what the scene *holds* (the container object alone) against
* * what the editor *lists* (content resolved from the template), and deviations kept per instance.
* A container placed in a scene is not copied into it: the scene stores the container object
* alone and its content is resolved from the template on every read, so a template edit reaches
* every instance at once (see `src/core/model/instance.ts`). That is what the checks below are
* about – what the scene *holds* against what the editor *lists*, deviations kept per instance,
* and the structure of resolved content being the template's business and nothing else's.
*/ */
export async function containers(t) { export async function containers(t) {
const { const {
@@ -16,6 +11,7 @@ export async function containers(t) {
sleep, sleep,
evaluate, evaluate,
json, json,
has,
shot, shot,
click, click,
clickText, clickText,
@@ -41,7 +37,6 @@ export async function containers(t) {
projectId, projectId,
} = t; } = t;
const center = await json('__gm.viewportCenter()'); const center = await json('__gm.viewportCenter()');
const has = (text) => evaluate(`Boolean(__gm.locate(${JSON.stringify(text)}))`);
/** Types a value into an inspector field, which runs the real command behind that row. */ /** Types a value into an inspector field, which runs the real command behind that row. */
const setField = async (label, value) => { const setField = async (label, value) => {
const row = await locate(label); const row = await locate(label);
@@ -129,10 +124,8 @@ export async function containers(t) {
await undo(); await undo();
check('undo restores all of them', (await objects()).length === listedBeforeDelete); check('undo restores all of them', (await objects()).length === listedBeforeDelete);
// 16d. Create container: both crates, with their colliders and the Mover, become the content // 16d. Create container: both crates, their colliders and the Mover *leave the scene*; one stored
// of a new container asset and *leave the scene*. The scene stores one object in their // container object stands in their stead and the five are resolved from its template.
// stead; the five members are only resolved from the template from now on, which is the
// whole difference to copying them in – see `src/core/model/instance.ts`.
const assetsBefore = (await json('__gm.state.project.assets')).length; const assetsBefore = (await json('__gm.state.project.assets')).length;
const storedBefore = (await storedObjects()).length; const storedBefore = (await storedObjects()).length;
const listedBefore = (await objects()).length; const listedBefore = (await objects()).length;
@@ -306,7 +299,23 @@ export async function containers(t) {
await selectObject(redA); await selectObject(redA);
check('the inspector offers no collider for resolved content', !(await has('+ Add collider'))); check('the inspector offers no collider for resolved content', !(await has('+ Add collider')));
await selectObject(instanceA.id); await selectObject(instanceA.id);
check('but it does for the stored container object', await has('+ Add collider')); check(
'nor for the placed container itself, which the command refuses as the hierarchy menu does',
!(await has('+ Add collider')),
);
const plainId = await json(`(() => {
const p = __gm.state.project;
const sprite = __gm.state.activeScene.objects.find(
(o) => p.assets.find((a) => a.id === o.assetId)?.type === 'sprite',
);
return sprite ? sprite.id : null;
})()`);
await selectObject(plainId);
check(
'while a plain sprite still gets it',
Boolean(plainId) && (await has('+ Add collider')),
JSON.stringify({ plainId }),
);
await selectObject(redA); await selectObject(redA);
let noticeAfter = await notice(); let noticeAfter = await notice();
await press('Delete'); await press('Delete');
+1 -1
View File
@@ -5,6 +5,7 @@ export async function textAndEnabled(t) {
sleep, sleep,
evaluate, evaluate,
json, json,
has,
shot, shot,
click, click,
clickText, clickText,
@@ -16,7 +17,6 @@ export async function textAndEnabled(t) {
stop, stop,
typeText, typeText,
} = t; } = t;
const has = (text) => evaluate(`Boolean(__gm.locate(${JSON.stringify(text)}))`);
// 17. Text assets: created through the + menu, placed, and rendered as real text in the scene. // 17. Text assets: created through the + menu, placed, and rendered as real text in the scene.
await clickText('+'); await clickText('+');
+2 -4
View File
@@ -43,10 +43,8 @@ export async function menus(t) {
await shot('31-long-parent-menu'); await shot('31-long-parent-menu');
// 18a. The entries scroll under the wheel; the search box above them does not move. // 18a. The entries scroll under the wheel; the search box above them does not move.
// Anchored on a filler row: "None" is also what an empty dropdown reads, so locating it could // Anchored on a filler row read off the open menu: "None" could match an inspector field behind it,
// find an inspector field behind the menu instead – one that never moves. Which filler is asked // and which fillers are drawn depends on what earlier steps left in the scene.
// for is read off the open menu: the capped list only draws the rows it shows, and how many
// objects come before them depends on what the earlier steps left in the scene.
const visibleFillers = (await json('__gm.overlayTexts()')).filter((e) => e.startsWith('Filler ')); const visibleFillers = (await json('__gm.overlayTexts()')).filter((e) => e.startsWith('Filler '));
const anchor = visibleFillers[visibleFillers.length - 1]; const anchor = visibleFillers[visibleFillers.length - 1];
check('the picker lists the filler objects', Boolean(anchor), JSON.stringify(visibleFillers)); check('the picker lists the filler objects', Boolean(anchor), JSON.stringify(visibleFillers));
+8 -20
View File
@@ -1,3 +1,5 @@
import { colourDistance } from '../lib/harness.mjs';
/** /**
* Uploading a picture and wearing it. Runs last because it adds an asset and repaints the * Uploading a picture and wearing it. Runs last because it adds an asset and repaints the
* scene; the checks that count assets live in earlier steps. * scene; the checks that count assets live in earlier steps.
@@ -36,13 +38,6 @@ const PASTE_SPARE = `(async () => {
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true })); window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true }));
})()`; })()`;
function colourDistance(a, b) {
const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
const [ar, ag, ab] = channels(a);
const [br, bg, bb] = channels(b);
return Math.abs(ar - br) + Math.abs(ag - bg) + Math.abs(ab - bb);
}
export async function textures(t) { export async function textures(t) {
const { check, sleep, json, evaluate, shot, mutate, press } = t; const { check, sleep, json, evaluate, shot, mutate, press } = t;
const { api, click, clickText, doubleClick, locate, undo } = t; const { api, click, clickText, doubleClick, locate, undo } = t;
@@ -143,8 +138,7 @@ export async function textures(t) {
await shot('32-textured-sprite'); await shot('32-textured-sprite');
// 19b-i. Switching the Filter property takes effect at once, without a reload. Read off the // 19b-i. Switching the Filter property takes effect at once, without a reload. Read off the
// screen rather than off `scaleMode`: the property was always assigned correctly, it was the // screen rather than off `scaleMode`, because the GPU sampler can miss a correctly assigned value.
// GPU sampler behind it that used to survive the change.
const enlarge = (w, h) => ` const enlarge = (w, h) => `
const sprite = __gm.state.project.assets.find((a) => a.id === 'tex-sprite'); const sprite = __gm.state.project.assets.find((a) => a.id === 'tex-sprite');
sprite.props.width = ${w}; sprite.props.width = ${w};
@@ -162,10 +156,8 @@ export async function textures(t) {
} }
return points; return points;
}; };
// Read the pixels first, then ask the driver what it actually has: a failure then says by // Pixels plus what the driver has, so a failure says whether the model, the GPU sampler or the
// itself whether the model never switched, the switch missed the GPU sampler, or the pixels // measurement is wrong.
// were measured wrong. This check has fallen over sporadically without any of the three ever
// being distinguishable from the message.
const sample = async (value) => { const sample = async (value) => {
await mutate( await mutate(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)}).props.filter = ${JSON.stringify(value)}`, `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)}).props.filter = ${JSON.stringify(value)}`,
@@ -184,9 +176,7 @@ export async function textures(t) {
); );
const soft = await sample('linear'); const soft = await sample('linear');
// The drift — that the picture actually changed — is what makes this a test of the switch. // The drift proves the switch: with a hung one, "some pixel is blended" alone would pass. Measured:
// Were the switch to hang, both readings would be the blurred one, and "some pixel is blended"
// alone would pass and hide the failure of the check above. Measured on the recorded readings:
// a working switch drifts 425, a hung one 0, and 30 even if every channel were off by two. // a working switch drifts 425, a hung one 0, and 30 even if every channel were off by two.
const drift = hard.pixels.reduce((sum, c, i) => sum + colourDistance(c, soft.pixels[i]), 0); const drift = hard.pixels.reduce((sum, c, i) => sum + colourDistance(c, soft.pixels[i]), 0);
check( check(
@@ -219,10 +209,8 @@ export async function textures(t) {
); );
} }
// 19e. "+ → Texture" offers the pictures the server already holds instead of the machine's // 19e. "+ → Texture" offers the pictures the server already holds, unreferenced ones included:
// file system. Nothing collects unreferenced files, so a picture one project dropped is // paste a second picture, undo the asset (the file stays), then pick that file in the dialog.
// still there for the next – which is exactly what this drives: paste a second picture,
// undo the asset (the file stays), then pick that file out of the dialog.
await evaluate(PASTE_SPARE); await evaluate(PASTE_SPARE);
await sleep(2500); await sleep(2500);
const two = await json("__gm.state.project.assets.filter((a) => a.type === 'texture').map((a) => a.file)"); const two = await json("__gm.state.project.assets.filter((a) => a.type === 'texture').map((a) => a.file)");
+2 -7
View File
@@ -1,3 +1,5 @@
import { colourDistance } from '../lib/harness.mjs';
/** /**
* The Texture tab: cutting frames out of a sheet, and a sprite wearing one. * The Texture tab: cutting frames out of a sheet, and a sprite wearing one.
* *
@@ -5,13 +7,6 @@
* of its own – that is what makes "which part of the sheet is on screen" a `pixelAt` question. * of its own – that is what makes "which part of the sheet is on screen" a `pixelAt` question.
*/ */
function colourDistance(a, b) {
const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
const [ar, ag, ab] = channels(a);
const [br, bg, bb] = channels(b);
return Math.abs(ar - br) + Math.abs(ag - bg) + Math.abs(ab - bb);
}
/** The four quadrants of the sheet the texture step pastes, plus the sprite's own white. */ /** The four quadrants of the sheet the texture step pastes, plus the sprite's own white. */
const PALETTE = { const PALETTE = {
red: '#e63c3c', red: '#e63c3c',
+3 -7
View File
@@ -1,9 +1,6 @@
/** /**
* Labels must be shortened to fit their control, not written past it. Runs last because it * Labels must be shortened to fit their control, not written past it. Runs last because it renames
* renames assets to absurd lengths to provoke the case. * assets to absurd lengths to provoke the case.
*
* `__gm.textOverflows()` walks every visible text and reports the ones drawn wider than their
* widget; against a build without the fix it reported the graph picker at 106 px over.
*/ */
/** A name far wider than any 200 px picker, so a control that does not truncate must spill. */ /** A name far wider than any 200 px picker, so a control that does not truncate must spill. */
@@ -31,8 +28,7 @@ export async function overflow(t) {
__gm.state.select(null)`); __gm.state.select(null)`);
await sleep(600); await sleep(600);
// 21. The pickers keep the width they were given. `width` used to set only a minimum, so a // 21. The pickers keep the width they were given instead of growing with the name they show.
// picker grew with whatever name it happened to show and the header reflowed on load.
const after = await pickerWidths(); const after = await pickerWidths();
for (const tab of ['graph', 'texture', 'container']) { for (const tab of ['graph', 'texture', 'container']) {
check( check(
+2 -7
View File
@@ -1,11 +1,6 @@
/** /**
* The ↔ / ↕ buttons beside a sprite's Width and Height: fit that side to the proportions of the * The ↔ / ↕ buttons beside a sprite's Width and Height (absent without a picture). Runs on the imported
* picture the sprite wears, set both sides to its pixel size, and not exist at all while it wears * 64 × 48 sheet plus a frame of a different shape, so "sheet or frame?" shows in the numbers.
* no picture. With both on offer the button is the menu form "↔ ▾", which is what the checks
* below distinguish.
*
* Runs on the sheet the texture steps imported (64 × 48) and adds a frame with a deliberately
* different shape, which is what makes "sheet or frame?" a question the numbers can answer.
*/ */
/** The rows are read as numbers, so a float from a division needs a tolerance. */ /** The rows are read as numbers, so a float from a division needs a tolerance. */
+2 -6
View File
@@ -1,12 +1,8 @@
import { instanceChild } from '../lib/builders.mjs'; import { instanceChild } from '../lib/builders.mjs';
/** /**
* The Container tab: editing a container's template with the scene view's own tools. * The Container tab: editing a template with the scene view's tools. Relies on the container step
* * having left two instances of a two-crate container in the scene.
* Runs after the container step, which created a container asset from two crates and left two
* instances of it in the scene. Neither of them holds a copy: what they show is resolved from
* the template on every read, which is what makes "which instance follows a template edit"
* a question worth asking.
*/ */
const NAME = 'Crate pair'; const NAME = 'Crate pair';
+9 -49
View File
@@ -1,4 +1,5 @@
import { edge, node } from '../lib/builders.mjs'; import { edge, node } from '../lib/builders.mjs';
import { MAKE_OBJECT } from './lib/scaffold.mjs';
/** /**
* "Run in editor": a behavior that keeps running while the project is not playing, and whose * "Run in editor": a behavior that keeps running while the project is not playing, and whose
@@ -49,7 +50,6 @@ export async function editorRun(t) {
await mutate(` await mutate(`
const p = __gm.state.project; const p = __gm.state.project;
const scene = __gm.state.activeScene; const scene = __gm.state.activeScene;
const proto = scene.objects[0];
const asset = structuredClone(p.assets.find((a) => a.id === 'mover')); const asset = structuredClone(p.assets.find((a) => a.id === 'mover'));
Object.assign(asset, { Object.assign(asset, {
id: 'drifter', id: 'drifter',
@@ -59,20 +59,8 @@ export async function editorRun(t) {
graph: ${JSON.stringify(driftGraph)}, graph: ${JSON.stringify(driftGraph)},
}); });
p.assets.push(asset); p.assets.push(asset);
const make = (id, assetId, parentId, y) => { ${MAKE_OBJECT}
const o = structuredClone(proto); scene.objects.push(make('er-box', 'crate', '', 400, 'Drift box'), make('er-logic', 'drifter', 'er-box', 0, 'Drifter'));
Object.assign(o, {
id,
assetId,
name: id === 'er-box' ? 'Drift box' : 'Drifter',
position: { x: 0, y, z: 0 },
parentId,
overrides: {},
instanceEdits: {},
});
return o;
};
scene.objects.push(make('er-box', 'crate', '', 400), make('er-logic', 'drifter', 'er-box', 0));
`); `);
await sleep(400); await sleep(400);
@@ -166,10 +154,8 @@ export async function editorRun(t) {
`${JSON.stringify(stopped)} x=${stoppedAt}`, `${JSON.stringify(stopped)} x=${stoppedAt}`,
); );
// 18f. Undo covers the two clicks on the switch – but not a millimetre of what the behavior did // 18f. Undo covers the two clicks on the switch but nothing the behavior did while on. The first
// while it was on. That is the price of editing the model directly, better stated here than // undo re-enables it, so it briefly runs again: undo rewinds the switch, never the clock.
// discovered later. Note the first undo takes back the *switching off*, so the behavior
// briefly runs again between the two: undo rewinds the switch, never the clock.
await undo(); await undo();
await undo(); await undo();
await sleep(300); await sleep(300);
@@ -188,20 +174,7 @@ export async function editorRun(t) {
await mutate(` await mutate(`
const p = __gm.state.project; const p = __gm.state.project;
const scene = __gm.state.activeScene; const scene = __gm.state.activeScene;
const proto = scene.objects[0]; ${MAKE_OBJECT}
const make = (id, assetId, parentId, y) => {
const o = structuredClone(proto);
Object.assign(o, {
id,
assetId,
name: '',
position: { x: 0, y, z: 0 },
parentId,
overrides: {},
instanceEdits: {},
});
return o;
};
p.assets.push({ p.assets.push({
id: 'er-level', id: 'er-level',
type: 'container', type: 'container',
@@ -246,26 +219,13 @@ export async function editorRun(t) {
await mutate(` await mutate(`
const p = __gm.state.project; const p = __gm.state.project;
const scene = __gm.state.activeScene; const scene = __gm.state.activeScene;
const proto = scene.objects[0];
const asset = structuredClone(p.assets.find((a) => a.id === 'drifter')); const asset = structuredClone(p.assets.find((a) => a.id === 'drifter'));
Object.assign(asset, { id: 'walker', name: 'Walker', graph: ${JSON.stringify(walkGraph)} }); Object.assign(asset, { id: 'walker', name: 'Walker', graph: ${JSON.stringify(walkGraph)} });
p.assets.push(asset); p.assets.push(asset);
const make = (id, assetId, parentId, y, overrides) => { ${MAKE_OBJECT}
const o = structuredClone(proto);
Object.assign(o, {
id,
assetId,
name: '',
position: { x: 0, y, z: 0 },
parentId,
overrides,
instanceEdits: {},
});
return o;
};
scene.objects.push( scene.objects.push(
make('er-walkbox', 'crate', '', 1200, {}), make('er-walkbox', 'crate', '', 1200),
make('er-walker', 'walker', 'er-walkbox', 0, { runInEditor: true }), Object.assign(make('er-walker', 'walker', 'er-walkbox', 0), { overrides: { runInEditor: true } }),
); );
`); `);
await sleep(600); await sleep(600);
+5 -10
View File
@@ -2,12 +2,8 @@ import { edge, node } from '../lib/builders.mjs';
import { LANES, scaffoldBehavior } from './lib/scaffold.mjs'; import { LANES, scaffoldBehavior } from './lib/scaffold.mjs';
/** /**
* Pausing and resuming play: the world stops where it is, the clock stops with it, and input that * Pausing freezes world and clock and drops input instead of replaying it on resume. Brings its own
* arrives while it is held is dropped instead of firing all at once on resume. * self-drifting box, since the fixture's Mover only moves on arrow keys and a pause would look like idling.
*
* The step brings its own box and its own behavior. The fixture's Mover needs the arrow keys to
* move at all, so a hold would be indistinguishable from simply not pressing anything; this one
* drifts on its own. Everything it adds is removed again at the end.
*/ */
const DRIFT_PER_SECOND = 100; const DRIFT_PER_SECOND = 100;
@@ -35,7 +31,7 @@ const pauseGraph = {
}; };
export async function pause(t) { export async function pause(t) {
const { check, sleep, json, shot, clickText, press, entity, world, stop } = t; const { check, sleep, json, has, shot, clickText, press, entity, world, stop } = t;
const playInfo = () => json('__gm.playInfo()'); const playInfo = () => json('__gm.playInfo()');
const boxX = async () => (await entity('p-box')).x; const boxX = async () => (await entity('p-box')).x;
const keyHits = async () => (await world()).log.filter((line) => line.includes(KEY_LOG)).length; const keyHits = async () => (await world()).log.filter((line) => line.includes(KEY_LOG)).length;
@@ -82,11 +78,10 @@ export async function pause(t) {
); );
// `locate` throws on a missing label, so the button that must be *gone* is asked for through // `locate` throws on a missing label, so the button that must be *gone* is asked for through
// the hook, which answers with null. // the hook, which answers with null.
const labelShown = (text) => json(`__gm.locate(${JSON.stringify(text)}) !== null`);
check( check(
'the button offers to resume while it is held, and no longer to pause', 'the button offers to resume while it is held, and no longer to pause',
(await labelShown('▶ Resume')) === true && (await labelShown('❚❚ Pause')) === false, (await has('▶ Resume')) === true && (await has('❚❚ Pause')) === false,
`resume=${await labelShown('▶ Resume')} pause=${await labelShown('❚❚ Pause')}`, `resume=${await has('▶ Resume')} pause=${await has('❚❚ Pause')}`,
); );
await shot('36-paused'); await shot('36-paused');
+5 -14
View File
@@ -1,4 +1,5 @@
import { edge, node, port } from '../lib/builders.mjs'; import { edge, node, port } from '../lib/builders.mjs';
import { MAKE_OBJECT } from './lib/scaffold.mjs';
/** /**
* A behavior used as a node inside another behavior's graph: the ports it grows from the other * A behavior used as a node inside another behavior's graph: the ports it grows from the other
@@ -72,7 +73,6 @@ export async function nestedBehavior(t) {
await mutate(` await mutate(`
const p = __gm.state.project; const p = __gm.state.project;
const scene = __gm.state.activeScene; const scene = __gm.state.activeScene;
const proto = scene.objects[0];
const mover = p.assets.find((a) => a.id === 'mover'); const mover = p.assets.find((a) => a.id === 'mover');
// At the front of the list, not the end: these get dragged out of the asset panel below, and // At the front of the list, not the end: these get dragged out of the asset panel below, and
// by this point in the suite the list is long enough to scroll a row at the end out of reach. // by this point in the suite the list is long enough to scroll a row at the end out of reach.
@@ -84,16 +84,8 @@ export async function nestedBehavior(t) {
behavior('nb-double', 'Double', ${JSON.stringify(doubleGraph)}); behavior('nb-double', 'Double', ${JSON.stringify(doubleGraph)});
behavior('nb-caller', 'Caller', ${JSON.stringify(callerGraph)}); behavior('nb-caller', 'Caller', ${JSON.stringify(callerGraph)});
behavior('nb-empty', 'Empty', { nodes: [], edges: [] }); behavior('nb-empty', 'Empty', { nodes: [], edges: [] });
const make = (id, assetId, parentId, name) => { ${MAKE_OBJECT}
const o = structuredClone(proto); scene.objects.push(make('nb-box', 'crate', '', 700, 'Nested box'), make('nb-logic', 'nb-caller', 'nb-box', 700, 'Caller'));
Object.assign(o, {
id, assetId, name,
position: { x: 0, y: 700, z: 0 },
parentId, overrides: {}, instanceEdits: {},
});
return o;
};
scene.objects.push(make('nb-box', 'crate', '', 'Nested box'), make('nb-logic', 'nb-caller', 'nb-box', 'Caller'));
`); `);
await sleep(400); await sleep(400);
@@ -235,8 +227,7 @@ export async function nestedBehavior(t) {
`dropped at ${JSON.stringify(droppedAt)}, let go at ${JSON.stringify(dropAt)}`, `dropped at ${JSON.stringify(droppedAt)}, let go at ${JSON.stringify(dropAt)}`,
); );
// 19h. The question from the note: a behavior with no start node can still be added – and now it // 19h. A behavior with no start node can still be added, but says why nothing happens.
// says why nothing happens instead of sitting there looking healthy.
const beforeEmpty = await nodeIds(); const beforeEmpty = await nodeIds();
const emptyRow = await json(`__gm.assetRow('nb-empty')`); const emptyRow = await json(`__gm.assetRow('nb-empty')`);
await t.dragAsset('nb-empty', spot(0.6, 0.75).x, spot(0.6, 0.75).y); await t.dragAsset('nb-empty', spot(0.6, 0.75).x, spot(0.6, 0.75).y);
@@ -275,7 +266,7 @@ export async function nestedBehavior(t) {
`nodes ${beforeSelf.length} → ${afterSelf.length}, notice=${JSON.stringify(notice)} row=${JSON.stringify(selfRow)} dragging=${JSON.stringify(dragging)}`, `nodes ${beforeSelf.length} → ${afterSelf.length}, notice=${JSON.stringify(notice)} row=${JSON.stringify(selfRow)} dragging=${JSON.stringify(dragging)}`,
); );
// 19j. Double-clicking a call opens the behavior it runs – the other half of the note. // 19j. Double-clicking a call opens the behavior it runs.
const callNode = await json(`__gm.graph.nodeAt('nb-call')`); const callNode = await json(`__gm.graph.nodeAt('nb-call')`);
check('the graph shown is still the caller', (await evaluate('__gm.graph.current()')) === 'nb-caller'); check('the graph shown is still the caller', (await evaluate('__gm.graph.current()')) === 'nb-caller');
await t.doubleClick(callNode.x, callNode.y); await t.doubleClick(callNode.x, callNode.y);
+2 -6
View File
@@ -2,12 +2,8 @@ import { edge, node } from '../lib/builders.mjs';
import { LANES, scaffoldBehavior } from './lib/scaffold.mjs'; import { LANES, scaffoldBehavior } from './lib/scaffold.mjs';
/** /**
* The auto-repeat of `On key down`: a held key fires again after a delay and then on its own * The auto-repeat of `On key down`. A plain and a repeating node on the same key are measured in one
* interval, which is what lets a Tetris piece slide sideways on one press. * hold, so "it repeated" cannot be confused with "the key arrived more often than expected".
*
* The step brings its own box and behavior with *two* `On key down` nodes on the same key – one
* plain, one repeating. One hold then measures both at once, so "it repeated" cannot be confused
* with "the key arrived more often than expected". Everything it adds is removed again at the end.
*/ */
const PLAIN = 'plain hit'; const PLAIN = 'plain hit';
+3 -6
View File
@@ -3,10 +3,9 @@
* tab, and there a row per item edits, adds and removes them with the inspector's own widgets. * tab, and there a row per item edits, adds and removes them with the inspector's own widgets.
*/ */
export async function listTab(t) { export async function listTab(t) {
const { check, sleep, json, shot, click, clickText, press, typeText, mutate, undo } = t; const { check, sleep, json, find, shot, click, clickText, press, typeText, mutate, undo } = t;
/** `locate` throws when the text is absent, which is the wrong shape for "is it there?". */ /** `locate` throws when the text is absent, which is the wrong shape for "is it there?". */
const find = (text) => json(`__gm.locate(${JSON.stringify(text)}, false)`);
// A throwaway behavior carrying one list variable; removed again at the end of the step. Its // A throwaway behavior carrying one list variable; removed again at the end of the step. Its
// items are two digits so that no value can be confused with a row's index label. // items are two digits so that no value can be confused with a row's index label.
@@ -30,7 +29,6 @@ export async function listTab(t) {
check('the editor is still on the scene tab', (await json('__gm.tab()')) === 'scene'); check('the editor is still on the scene tab', (await json('__gm.tab()')) === 'scene');
await shot('01-count-button'); await shot('01-count-button');
// 2. Clicking it opens the List tab on that very list.
await click(button.x, button.y); await click(button.x, button.y);
await sleep(200); await sleep(200);
check('clicking it switches to the List tab', (await json('__gm.tab()')) === 'list'); check('clicking it switches to the List tab', (await json('__gm.tab()')) === 'list');
@@ -75,7 +73,6 @@ export async function listTab(t) {
JSON.stringify(shortRows), JSON.stringify(shortRows),
); );
// 5. Adding and removing whole items.
await clickText('+ Item'); await clickText('+ Item');
await sleep(200); await sleep(200);
info = await json('__gm.list.info()'); info = await json('__gm.list.info()');
@@ -249,8 +246,8 @@ export async function listTab(t) {
JSON.stringify(await cellsOf()), JSON.stringify(await cellsOf()),
); );
// 9. A node's unconnected `list` input carries its value in the very same params bag a setting // 9. A node's unconnected `list` input lives in the same params bag as a setting, so it is a count
// does, so it is a count button too – before this it offered a number field. // button too.
await mutate(` await mutate(`
const a = __gm.state.project.assets.find((x) => x.id === 'celly'); const a = __gm.state.project.assets.find((x) => x.id === 'celly');
a.graph.nodes.push({ id: 'counter', type: 'Count', x: 40, y: 40, params: {} }); a.graph.nodes.push({ id: 'counter', type: 'Count', x: 40, y: 40, params: {} });
+1 -2
View File
@@ -3,10 +3,9 @@
* same row, and writing one field keeps the others – the trap a shared snapshot would fall into. * same row, and writing one field keeps the others – the trap a shared snapshot would fall into.
*/ */
export async function structVariable(t) { export async function structVariable(t) {
const { check, sleep, json, shot, click, clickText, press, typeText, mutate, select, undo } = t; const { check, sleep, json, find, shot, click, clickText, press, typeText, mutate, select, undo } = t;
/** `locate` throws when the text is absent, which is the wrong shape for "is it there?". */ /** `locate` throws when the text is absent, which is the wrong shape for "is it there?". */
const find = (text) => json(`__gm.locate(${JSON.stringify(text)}, false)`);
const piece = () => json("__gm.state.project.assets.find((a) => a.id === 'structy').props.piece"); const piece = () => json("__gm.state.project.assets.find((a) => a.id === 'structy').props.piece");
/** Left edge of each row's control – one column grid means one number, in the browser's own font. */ /** Left edge of each row's control – one column grid means one number, in the browser's own font. */
const leftsOf = (labels) => const leftsOf = (labels) =>
+3 -11
View File
@@ -1,4 +1,5 @@
import { edge, node, port, variable } from '../lib/builders.mjs'; import { edge, node, port, variable } from '../lib/builders.mjs';
import { MAKE_OBJECT } from './lib/scaffold.mjs';
/** /**
* Get variables / Set variables: one node for several of them. Their ports are the behavior's * Get variables / Set variables: one node for several of them. Their ports are the behavior's
@@ -56,7 +57,6 @@ export async function variableNodes(t) {
await mutate(` await mutate(`
const p = __gm.state.project; const p = __gm.state.project;
const scene = __gm.state.activeScene; const scene = __gm.state.activeScene;
const proto = scene.objects[0];
const mover = p.assets.find((a) => a.id === 'mover'); const mover = p.assets.find((a) => a.id === 'mover');
const a = structuredClone(mover); const a = structuredClone(mover);
Object.assign(a, { Object.assign(a, {
@@ -67,16 +67,8 @@ export async function variableNodes(t) {
graph: ${JSON.stringify(logicGraph)}, graph: ${JSON.stringify(logicGraph)},
}); });
p.assets.unshift(a); p.assets.unshift(a);
const make = (id, assetId, parentId, name) => { ${MAKE_OBJECT}
const o = structuredClone(proto); scene.objects.push(make('vp-box', 'crate', '', 900, 'Speedy box'), make('vp-brain', 'vp-logic', 'vp-box', 900, 'Speeds'));
Object.assign(o, {
id, assetId, name,
position: { x: 0, y: 900, z: 0 },
parentId, overrides: {}, instanceEdits: {},
});
return o;
};
scene.objects.push(make('vp-box', 'crate', '', 'Speedy box'), make('vp-brain', 'vp-logic', 'vp-box', 'Speeds'));
`); `);
await sleep(400); await sleep(400);
+4 -9
View File
@@ -1,8 +1,6 @@
/** /**
* Steps 17–20 each need the same throwaway rig in the fixture scene: a behavior asset cloned off * The throwaway rig steps 17–20 share in the fixture scene: a behavior asset cloned off `mover` with the
* `mover` carrying the step's own graph, a crate to hold it, and a logic child that runs it. * step's graph, a crate to hold it, and a logic child that runs it.
* Spelled out per step it drifted four ways – `push` vs `unshift`, three different `make`
* signatures, and y-offsets nothing kept apart.
*/ */
/** /**
@@ -38,11 +36,8 @@ export const MAKE_OBJECT = `
};`; };`;
/** /**
* Builds the rig and hands back the ids it created, plus `remove()` which takes them out again * Returns the created ids plus `remove()`, so a step's cleanup matches what it made. `front` puts the
* and returns what is left – so a step's cleanup cannot fall out of step with what it made. * asset at the head of the list: a row appended at the end scrolls out of `dragAsset`'s reach.
*
* `front` puts the asset at the head of the project list: the asset panel is long by this point
* in the suite, and a row appended at the end scrolls out of reach of `dragAsset`.
*/ */
export async function scaffoldBehavior(t, options) { export async function scaffoldBehavior(t, options) {
const { const {
+7 -18
View File
@@ -1,13 +1,6 @@
/** /**
* Where the headless browser for a check run lives. * Launches the check browser locally, or with `GM_REMOTE=user@host` on another machine with the CDP port
* * forwarded over SSH, since this 8 GB host has little memory to spare.
* Local by default. Set `GM_REMOTE=user@host` to run it on another machine instead: an SSH
* session forwards the CDP port back to 127.0.0.1, so everything downstream – waitForChrome,
* /json/new, the WebSocket – stays unchanged. The checks load the public base URL, so the
* remote side needs nothing but a browser and internet.
*
* Why bother: even with the GPU in use an idle editor page keeps rendering frames that never
* change, and this machine has 8 GB with most of the swap already gone.
*/ */
import { spawn, execFileSync } from 'node:child_process'; import { spawn, execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -21,10 +14,8 @@ const FLAGS = [
'--headless', '--headless',
'--hide-scrollbars', '--hide-scrollbars',
/** /**
* Measured on this host (idle editor page, 20 s): software WebGL costs 373% of a core at 8 fps, * Measured (idle editor, 20 s): software WebGL 373% of a core at 8 fps, HD 630 via ANGLE/Vulkan 69% at
* the Intel HD 630 via ANGLE/Vulkan costs 69% at 56 fps. There is deliberately no software * 56 fps; no software fallback on purpose. `--use-angle=gl` and `--use-gl=egl` get no WebGL context at all.
* fallback — a host without a usable GPU should fail loudly rather than melt down. `--use-angle=gl`
* and `--use-gl=egl` look even cheaper but get no WebGL context at all — do not use them.
*/ */
'--use-angle=vulkan', '--use-angle=vulkan',
'--ignore-gpu-blocklist', '--ignore-gpu-blocklist',
@@ -81,7 +72,7 @@ export function launchBrowser({ port, width, height }) {
} }
const key = process.env.GM_REMOTE_KEY ?? join(homedir(), '.ssh', 'gm-win.key'); const key = process.env.GM_REMOTE_KEY ?? join(homedir(), '.ssh', 'gm-win.key');
// Copied every run: a stale launcher on the remote is a debugging session nobody enjoys. // Copied every run so the remote never runs a stale launcher.
execFileSync('scp', [...sshArgs(key), LAUNCHER, `${remote}:gm-browser.ps1`], { stdio: 'pipe' }); execFileSync('scp', [...sshArgs(key), LAUNCHER, `${remote}:gm-browser.ps1`], { stdio: 'pipe' });
const proc = spawn( const proc = spawn(
@@ -101,10 +92,8 @@ export function launchBrowser({ port, width, height }) {
return { return {
proc, proc,
/** /**
* The launcher notices a finished run on its own, but only after its idle window – a browser * Ends the browser now rather than after the launcher's idle window. Kill only this pid: the host is
* lingering a minute on someone else's desktop is rude, so end it now and let that window be * someone's desktop, and killing by name would close their browser.
* the net for runs that die without reaching here. Only this run's pid: the host is somebody's
* desktop, and killing by name would close the browser they are reading this in.
*/ */
stop: () => { stop: () => {
const pidFile = `$env:TEMP\\gm-browser-${port}.pid`; const pidFile = `$env:TEMP\\gm-browser-${port}.pid`;
-1
View File
@@ -26,7 +26,6 @@ export const sceneObject = (
*/ */
export const instanceChild = (instanceId, ...templatePath) => `${instanceId}~${templatePath.join('/')}`; export const instanceChild = (instanceId, ...templatePath) => `${instanceId}~${templatePath.join('/')}`;
/** A graph edge from `from.fromPort` to `to.toPort`. */
export const edge = (id, from, fromPort, to, toPort) => ({ export const edge = (id, from, fromPort, to, toPort) => ({
id, id,
from: { node: from, port: fromPort }, from: { node: from, port: fromPort },
+23 -26
View File
@@ -1,18 +1,7 @@
/** /**
* Headless-Chromium harness for the check scripts (e2e.mjs, smoke-jumprun.mjs). * Headless-Chromium harness for the checks; widgets are found via `__gm.locate(text)`, not coordinates.
* * It always waits for chromium to exit (a leftover GPU process burns CPU) and exits 1 on failures or when
* Starts chromium with remote debugging, opens a CDP session and hands a set of helpers to * the summary line is missing, since chromium can die halfway under memory pressure.
* the body function. Widgets are located through `window.__gm.locate(text)` (installed by
* the editor), so checks never depend on pixel coordinates.
*
* await runChecks({ projectId, baseUrl, port, shotPrefix }, async (t) => { ... });
*
* The harness always waits for chromium to actually exit (a leftover GPU process kept rendering at
* full CPU once when only SIGTERM was sent) and exits with code 1 on failures.
*
* A run that ends without printing its summary exits 1 as well. Every wait here can time out for
* that reason: chromium dies under memory pressure on this machine, and a run that stops halfway
* used to exit 0 – silently reporting success for checks it never ran.
*/ */
import { writeFileSync } from 'node:fs'; import { writeFileSync } from 'node:fs';
import { launchBrowser } from './browser.mjs'; import { launchBrowser } from './browser.mjs';
@@ -26,6 +15,14 @@ const SERVER_TIMEOUT_MS = 20_000;
/** Structural equality for the small plain values the checks compare (arrays of ids, selections). */ /** Structural equality for the small plain values the checks compare (arrays of ids, selections). */
export const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); export const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
/** Sum of the channel differences of two `#rrggbb` colours. */
export function colourDistance(a, b) {
const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
const [ar, ag, ab] = channels(a);
const [br, bg, bb] = channels(b);
return Math.abs(ar - br) + Math.abs(ag - bg) + Math.abs(ab - bb);
}
/** Keys the checks press, with the codes Chromium needs to route them like a real keyboard. */ /** Keys the checks press, with the codes Chromium needs to route them like a real keyboard. */
const KEYS = { const KEYS = {
Enter: { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, Enter: { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 },
@@ -131,8 +128,12 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
}; };
/** Evaluates an expression in the page and returns it as plain JSON; `undefined` comes back as null. */ /** Evaluates an expression in the page and returns it as plain JSON; `undefined` comes back as null. */
const json = async (expression) => JSON.parse(await evaluate(`JSON.stringify((${expression}) ?? null)`)); const json = async (expression) => JSON.parse(await evaluate(`JSON.stringify((${expression}) ?? null)`));
/** Like `locate`, but null instead of throwing – which a check for absence needs. */
const find = async (text, last = false) =>
(await evaluate(`window.__gm && window.__gm.locate(${JSON.stringify(text)}, ${last})`)) || null;
const has = async (text) => Boolean(await find(text));
const locate = async (text, last = false) => { const locate = async (text, last = false) => {
const p = await evaluate(`window.__gm && window.__gm.locate(${JSON.stringify(text)}, ${last})`); const p = await find(text, last);
if (!p) throw new Error(`widget with text "${text}" not found`); if (!p) throw new Error(`widget with text "${text}" not found`);
return p; return p;
}; };
@@ -176,10 +177,7 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
}; };
/** CDP reports held buttons as a bitmask, which is not the button name it takes elsewhere. */ /** CDP reports held buttons as a bitmask, which is not the button name it takes elsewhere. */
const BUTTON_MASK = { left: 1, right: 2, middle: 4 }; const BUTTON_MASK = { left: 1, right: 2, middle: 4 };
/** /** The fifth argument is a step count or `{ steps, button, modifiers }`. */
* Drags with the left button unless told otherwise. The fifth argument is either a step count
* (its long-standing meaning) or `{ steps, button, modifiers }`.
*/
const drag = async (x1, y1, x2, y2, options = {}) => { const drag = async (x1, y1, x2, y2, options = {}) => {
const { const {
steps = 12, steps = 12,
@@ -199,10 +197,8 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
}; };
/** /**
* Drags an asset out of the Assets panel onto a point in a view – the only way to place one * By id, not text: the container picker shows the same name as the row. Press and moves are separate
* since the context menu lost its entry. By id, not by text: the container picker shows the * events because the panel starts a drag only once the pointer has travelled.
* same name as the row. Unlike `drag()` it presses and moves in separate events on purpose,
* because the panel turns the press into a drag only once it has travelled.
*/ */
const dragAsset = async (assetId, x, y, steps = 8) => { const dragAsset = async (assetId, x, y, steps = 8) => {
const from = await json(`__gm.assetRow(${JSON.stringify(assetId)})`); const from = await json(`__gm.assetRow(${JSON.stringify(assetId)})`);
@@ -242,9 +238,8 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
await keyEvent('keyUp', name); await keyEvent('keyUp', name);
}; };
/** /**
* Typed per character to exercise the per-keystroke paths (live preview, search filters), then * Typed per character to exercise per-keystroke paths, then verified: a partial type is otherwise
* verified: a partial type is otherwise indistinguishable from a wrong value – a run once * indistinguishable from a wrong value.
* committed x=3 for "300" and the check blamed the value, not the lost characters.
*/ */
const typeText = async (text) => { const typeText = async (text) => {
for (const ch of text) await cdp.send('Input.insertText', { text: ch }); for (const ch of text) await cdp.send('Input.insertText', { text: ch });
@@ -330,6 +325,8 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
sleep, sleep,
evaluate, evaluate,
json, json,
find,
has,
locate, locate,
shot, shot,
mouse, mouse,
+5 -27
View File
@@ -1,30 +1,9 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* Writes the second example game: Tetris, built only from assets and node graphs. * Writes the Tetris example from assets and graphs only. The board is a list of 200 numbers (-1 empty,
* * else the resting shape); `piece`/`next` are shape/rot/x/y structs. The `Tetris` master owns the state
* The board is a list of 200 numbers, one per cell: -1 for empty, otherwise the shape that came to * and calls stateless behaviors as nodes, which lets `Fits` serve turning, moving and falling alike.
* rest there. Reading a cell is `Item at(grid, row·10 + column)` and clearing a full line is an * Board and piece are a fixed pool of named objects (`c0…c199`, `p0…p3`); nothing spawns while playing.
* empty row joined in front of everything above and below it – with no text encoding in between,
* so the numbers in the inspector are the numbers the game means.
*
* What travels as a bundle rather than as loose numbers: the falling piece (`piece`) and the one
* move being tried on it (`next`) are structs of shape/rot/x/y, and `shapes` is a list of `{x, y}`
* cells. That is what lets `Next move` hand back a whole candidate for `Fits` to judge and the
* master to accept in a single write, and it halves the index arithmetic in `Piece cell`.
*
* Where a step needs several variables at once, one `Get variables` / `Set variables` stands for the
* lot – the call to `Next move` has one node at each side of it instead of five. How far such a node
* may reach is bounded by the pulse cache; `graphBuilder` below says how.
*
* The game is ten behaviors rather than one graph. `Tetris` owns the state and does none of the
* work: every step is a behavior of its own, called as a node – `Spawn piece`, `Next move`,
* `Fits`, `Draw piece`, `Lock piece`, `Clear lines`, `Show lines`, `Draw board`, with `Piece cell`
* used by three of them. None of them holds state between calls: everything they need arrives
* through their inputs and every answer leaves through their outputs, which is what lets `Fits`
* serve turning, moving and falling alike.
*
* What the player sees is a pool of objects the graphs address by name: `c0…c199` for the board
* and `p0…p3` for the piece that is falling. Nothing is spawned or destroyed while playing.
* *
* Usage: node tools/seed-tetris.mjs [projectId] [baseUrl] * Usage: node tools/seed-tetris.mjs [projectId] [baseUrl]
*/ */
@@ -254,8 +233,7 @@ function objectOfRound(g, prefix, loop, namePrefix, column, row) {
/** /**
* Where one of the four cells of a piece sits: reads the rotation out of `shapes` and adds the * Where one of the four cells of a piece sits: reads the rotation out of `shapes` and adds the
* piece's own position. Used by the fit test and by the drawing, which is the whole point of it * piece's own position. A behavior so the fit test and the drawing share it.
* being a behavior – the two used to be the same twenty nodes twice.
*/ */
function pieceCellGraph() { function pieceCellGraph() {
const g = graphBuilder(); const g = graphBuilder();
+6 -6
View File
@@ -47,12 +47,12 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
stop, stop,
navigate, navigate,
exceptions, exceptions,
has,
} = t; } = t;
/** Variables of the "Game" behavior instance, live from the running world. */ /** Variables of the "Game" behavior instance, live from the running world. */
const gameVars = async () => (await entity('ogame')).props; const gameVars = async () => (await entity('ogame')).props;
const coins = async () => (await world()).entities.filter((e) => e.name === 'Coin').length; const coins = async () => (await world()).entities.filter((e) => e.name === 'Coin').length;
const visible = (text) => evaluate(`Boolean(__gm.locate(${JSON.stringify(text)}))`);
/** Clicks an object where the *player* sees it (through the camera), not in the scene view. */ /** Clicks an object where the *player* sees it (through the camera), not in the scene view. */
const clickObject = async (objectId) => { const clickObject = async (objectId) => {
const p = await json(`__gm.gameScreen(${JSON.stringify(objectId)})`); const p = await json(`__gm.gameScreen(${JSON.stringify(objectId)})`);
@@ -95,7 +95,7 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
await play(); await play();
await sleep(300); await sleep(300);
check('play opens the Game tab', (await evaluate('__gm.tab()')) === 'game'); check('play opens the Game tab', (await evaluate('__gm.tab()')) === 'game');
check('the title screen is up', await visible('JUMP & RUN')); check('the title screen is up', await has('JUMP & RUN'));
const level = await entity('L'); const level = await entity('L');
const title = await entity('T'); const title = await entity('T');
check( check(
@@ -174,7 +174,7 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
// Against the count the run actually reached, not a fixed 1: how many coins the walk sweeps up // Against the count the run actually reached, not a fixed 1: how many coins the walk sweeps up
// depends on machine speed – a faster host collects two. // depends on machine speed – a faster host collects two.
const hud = `Coins ${afterCoin.coins}/${afterCoin.needed}`; const hud = `Coins ${afterCoin.coins}/${afterCoin.needed}`;
check('the HUD shows the counter', await visible(hud), hud); check('the HUD shows the counter', await has(hud), hud);
check('the clock is counting down', afterCoin.timeLeft < 28, `timeLeft ${afterCoin.timeLeft}`); check('the clock is counting down', afterCoin.timeLeft < 28, `timeLeft ${afterCoin.timeLeft}`);
await shot('05-collected'); await shot('05-collected');
@@ -183,7 +183,7 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
await sleep(300); await sleep(300);
check( check(
'collecting everything wins the game', 'collecting everything wins the game',
(await visible('YOU WIN!')) && (await has('YOU WIN!')) &&
(await entity('W')).props.enabled === true && (await entity('W')).props.enabled === true &&
(await entity('L')).props.enabled === false, (await entity('L')).props.enabled === false,
); );
@@ -194,7 +194,7 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
await sleep(1200); await sleep(1200);
check( check(
'Play again restarts the scene at the title screen', 'Play again restarts the scene at the title screen',
(await visible('JUMP & RUN')) && (await evaluate('__gm.state.mode')) === 'play', (await has('JUMP & RUN')) && (await evaluate('__gm.state.mode')) === 'play',
); );
check('the restarted run has all ten coins back', (await coins()) === 10, `${await coins()} coins`); check('the restarted run has all ten coins back', (await coins()) === 10, `${await coins()} coins`);
@@ -205,7 +205,7 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
await sleep(600); await sleep(600);
check( check(
'the clock running out loses the game', 'the clock running out loses the game',
(await visible('TIME UP')) && (await entity('B')).props.enabled === true, (await has('TIME UP')) && (await entity('B')).props.enabled === true,
); );
await shot('07-lost'); await shot('07-lost');