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:
1 parent
9212e61c4c
commit
8f92a06c84
96 files changed
+999
-1261
No files matched your search
@@ -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. |
|
||||
| **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**. |
|
||||
| **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. |
|
||||
| **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. |
|
||||
@@ -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,
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
every held key go, so nothing keeps walking with nobody pressing anything. What the run does *not*
|
||||
get:
|
||||
@@ -85,7 +93,7 @@ get:
|
||||
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
|
||||
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
|
||||
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`
|
||||
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
|
||||
inspector would use for that kind – for a list of structs that is the inspector's own struct
|
||||
block, so a row is the bundle's fields under each other. The inspector stays visible beside the tab, so what the list
|
||||
inspector would use for that kind – for a list of structs a row is the item's fields as
|
||||
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
|
||||
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**
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
single field. All of it sits on **one column grid** with the rest of the inspector: mark (28 px),
|
||||
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.
|
||||
- **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
|
||||
@@ -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`,
|
||||
`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
|
||||
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`,
|
||||
`Substring`, `Find text`, `Equals`,
|
||||
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
|
||||
this costs nothing extra.
|
||||
- **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
|
||||
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
|
||||
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.
|
||||
|
||||
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
|
||||
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
|
||||
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.
|
||||
|
||||
`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/`.
|
||||
- **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
|
||||
keyboard, clipboard) while a canvas text field is focused.
|
||||
popups. The only lasting DOM element is a hidden `<input>` used as a keyboard bridge (IME,
|
||||
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
|
||||
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
|
||||
@@ -565,7 +576,7 @@ selected and switched back on.
|
||||
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
|
||||
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
|
||||
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)
|
||||
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/properties/ PropertySchema, resolve (the only override merge), collider/camera/text geometry, enabled
|
||||
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), 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/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/runtime/ World, Entity, System interface, Input tracker, CollisionSystem, BehaviorSystem
|
||||
src/ui/ widget toolkit (generic, knows nothing about assets)
|
||||
src/viewport/ view (transforms), items (what to draw), symbols (how to draw it),
|
||||
textureFill, TextureLibrary – no editor concepts, so tests reach it freely
|
||||
src/editor/ App → Editor (layout + ticker), PlaySession (the run), ProjectSession (load/create),
|
||||
state, actions, shortcuts, debugHook, autosave, framing (fit/marquee maths),
|
||||
AssetTabEditor (the shared shell of the three asset tabs)
|
||||
EditorRun (run in editor), state, actions, shortcuts, debugHook, autosave, textureImport,
|
||||
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/inspector/ Inspector panel, field factory, previewHandlers (scrub → preview → command),
|
||||
one section per selection kind (asset/object/node), objectQueries/vectorRows/parentRow
|
||||
src/editor/inspector/ Inspector panel, field factory, previewHandlers (scrub → preview → command), valueSpecs
|
||||
(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/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/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)
|
||||
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
|
||||
@@ -615,16 +631,16 @@ Node 22 is required (`/usr/bin/node`, in the default PATH).
|
||||
```bash
|
||||
npm install
|
||||
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 lint # ESLint, type-aware; --fix for the fixable half
|
||||
npm run format # Prettier over src, tests and tools
|
||||
npm run lint # ESLint (type-aware for src and tests); lint:fix for the fixable half
|
||||
npm run format # Prettier over the repo, minus what .prettierignore lists
|
||||
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
|
||||
# code is lost (tail's is reported), so a red run reads as green
|
||||
npm run build # writes dist/ and regenerates index.html
|
||||
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
|
||||
./e2e.sh start [GM_STEPS] # the same run detached, see below
|
||||
./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
|
||||
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
|
||||
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
|
||||
machine down. (`--use-angle=gl` and `--use-gl=egl` measure cheaper still, but get no WebGL context at
|
||||
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
|
||||
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.
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
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,
|
||||
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`,
|
||||
`object(id)`, `world()`, `mutate(js)` and friends, so a check reads like the interaction it
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
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.
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
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
|
||||
lets `Fits` answer for turning, moving and falling alike, and `Piece cell` serve the fit test
|
||||
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
|
||||
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
|
||||
@@ -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}]` |
|
||||
| POST | `projects` | `{name}` → project document (201) |
|
||||
| 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 |
|
||||
| GET | `textures` | `[{file,width,height,bytes,updatedAt}]` – what the image picker offers |
|
||||
| POST | `textures` | **raw image bytes** → `{file,width,height,bytes}` (201) |
|
||||
| DELETE | `textures/{file}` | 204 |
|
||||
|
||||
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.
|
||||
|
||||
There is no authentication yet; `data/` is world-readable. Both are phase-2 topics.
|
||||
|
||||
## 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 ·
|
||||
`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
|
||||
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
|
||||
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,
|
||||
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).
|
||||
|
||||
## Not yet
|
||||
|
||||
@@ -28,3 +28,8 @@ export class CompoundCommand implements Command {
|
||||
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);
|
||||
}
|
||||
@@ -20,11 +20,8 @@ import { nextAssetName } from './naming';
|
||||
import { requireObject, requireHolder, requireStructural } from './require';
|
||||
|
||||
/**
|
||||
* Turns the selected objects, with everything parented below them, into a container asset placed
|
||||
* in their stead. The origin is the centre of the selection's bounding box (z = 0), so the template
|
||||
* 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.
|
||||
* Turns the selected objects and their descendants into a container asset placed in their stead, the
|
||||
* template centred on the selection's bounding box (z = 0).
|
||||
*/
|
||||
export class CreateContainerCommand implements Command {
|
||||
readonly label = 'Create container';
|
||||
|
||||
@@ -9,14 +9,14 @@ import {
|
||||
type GraphNode,
|
||||
isTerminalNode,
|
||||
isValidPortName,
|
||||
requireNodeDef,
|
||||
RUN_BEHAVIOR,
|
||||
} from '../graph';
|
||||
import type { PropertyValue } from '../model/Properties';
|
||||
import type { Project } from '../model/Project';
|
||||
import { type Removed, removeFirst, removeWhere, restoreAt } from '../util/list';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireBehavior } from './require';
|
||||
import { nextFieldName } from './naming';
|
||||
import { requireBehavior, requireNode, requireNodeDef } from './require';
|
||||
|
||||
interface PortSite {
|
||||
graph: Graph;
|
||||
@@ -52,19 +52,17 @@ function edgesOnPort(site: PortSite, key: string): (edge: GraphEdge) => boolean
|
||||
: 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 {
|
||||
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 {
|
||||
const node = nodeAt(site);
|
||||
/** The port's edges follow its new key, and so does an input's typed-in value, which lives under it. */
|
||||
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];
|
||||
if (!node || value === undefined) return;
|
||||
node.params[to] = value;
|
||||
@@ -90,7 +88,7 @@ export class AddPortsCommand implements Command {
|
||||
}
|
||||
|
||||
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));
|
||||
for (const port of this.ports) {
|
||||
if (!isValidPortName(port.key)) throw new CommandError(INVALID_NAME);
|
||||
@@ -101,7 +99,7 @@ export class AddPortsCommand implements Command {
|
||||
}
|
||||
|
||||
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));
|
||||
node.ports = (node.ports ?? []).filter((p) => !added.has(p.key));
|
||||
}
|
||||
@@ -129,7 +127,7 @@ export class RemovePortCommand implements Command {
|
||||
}
|
||||
|
||||
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 ports = [...(node.ports ?? [])];
|
||||
const removed = removeFirst(ports, (p) => p.key === this.key);
|
||||
@@ -152,7 +150,7 @@ export class RemovePortCommand implements Command {
|
||||
|
||||
undo(project: Project): void {
|
||||
if (!this.removed) return;
|
||||
const node = requirePortNode(project, this.assetId, this.nodeId);
|
||||
const node = requireNode(project, this.assetId, this.nodeId);
|
||||
const ports = [...(node.ports ?? [])];
|
||||
restoreAt(ports, [this.removed]);
|
||||
node.ports = ports;
|
||||
@@ -187,22 +185,14 @@ export class RenamePortCommand implements Command {
|
||||
|
||||
private rename(project: Project, from: string, to: string): void {
|
||||
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);
|
||||
if (!isValidPortName(to)) throw new CommandError(INVALID_NAME);
|
||||
const ports = node.ports ?? [];
|
||||
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`);
|
||||
node.ports = ports.map((p) => (p.key === from ? { ...p, key: to } : p));
|
||||
for (const site of sites) {
|
||||
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);
|
||||
}
|
||||
for (const site of sites) carryPort(site, from, to);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,17 +200,12 @@ export class RenamePortCommand implements Command {
|
||||
function renamePortOn(graph: Graph, node: GraphNode, from: string, to: string): void {
|
||||
const ports = node.ports ?? [];
|
||||
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));
|
||||
for (const edge of graph.edges.filter(edgesOnPort(site, from))) {
|
||||
if (site.asInput) edge.to.port = to;
|
||||
else edge.from.port = to;
|
||||
}
|
||||
if (site.asInput) renameParam(site, from, to);
|
||||
carryPort(
|
||||
{ graph, nodeId: node.id, asInput: requireNodeDef(node.type).declaresPorts === 'inputs' },
|
||||
from,
|
||||
to,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,7 +227,7 @@ export class SetVariablePortCommand implements Command {
|
||||
}
|
||||
|
||||
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 current = ports.find((p) => p.key === this.from);
|
||||
if (!current) throw new CommandError(`Port "${this.from}" not found`);
|
||||
@@ -254,7 +239,7 @@ export class SetVariablePortCommand implements Command {
|
||||
|
||||
undo(project: Project): void {
|
||||
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 {
|
||||
@@ -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. */
|
||||
export function nextPortName(node: GraphNode, base: string): string {
|
||||
const taken = new Set((node.ports ?? []).map((p) => p.key));
|
||||
for (let i = 1; ; i++) if (!taken.has(`${base}${i}`)) return `${base}${i}`;
|
||||
return nextFieldName(node.ports ?? [], base);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { type NodeDef, nodeDef } from '../graph/NodeRegistry';
|
||||
import type { Asset, BehaviorAsset } from '../model/Asset';
|
||||
import { findObject, type ObjectTree } from '../model/hierarchy';
|
||||
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 { propDef, type PropertyDef } from '../properties/PropertySchema';
|
||||
import { CommandError } from './Command';
|
||||
@@ -51,8 +51,8 @@ export function requireStructural(project: Project, holder: ObjectTree, objectId
|
||||
}
|
||||
|
||||
export function requireBehavior(project: Project, assetId: string): BehaviorAsset {
|
||||
const asset = findAsset(project, assetId);
|
||||
if (!asset || asset.type !== 'behavior') throw new CommandError(`Behavior ${assetId} not found`);
|
||||
const asset = findAssetOf(project, assetId, 'behavior');
|
||||
if (!asset) throw new CommandError(`Behavior ${assetId} not found`);
|
||||
return asset;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
* (`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 {
|
||||
readonly label = 'Set parent';
|
||||
private previous: { parentId: string; position: Position } = {
|
||||
|
||||
@@ -12,7 +12,8 @@ import type { Project } from '../model/Project';
|
||||
import {
|
||||
coerceValue,
|
||||
emptyFieldValue,
|
||||
isCommonProp,
|
||||
isValidFieldName,
|
||||
isValidVariableName,
|
||||
type ListItemKind,
|
||||
type PrimitiveFieldDef,
|
||||
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_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. */
|
||||
interface FieldOwner {
|
||||
/** 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;
|
||||
}
|
||||
|
||||
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 {
|
||||
const def = requireBehavior(project, assetId).variables.find((v) => v.key === key);
|
||||
const def = variableOf(project, assetId, key);
|
||||
if (def?.kind === 'struct')
|
||||
return {
|
||||
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
|
||||
* each instance override – and for a list of structs, every item of each of those. A field is added
|
||||
* 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.
|
||||
* One struct-carrying value of a variable (default, own, override, or list item); a field change hits
|
||||
* all of them at once so none keeps a stale shape. `id` pairs a site up again on undo.
|
||||
*/
|
||||
interface StructSite {
|
||||
id: string;
|
||||
@@ -194,7 +187,7 @@ interface 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(
|
||||
// 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())),
|
||||
@@ -440,7 +433,7 @@ function requireListDef(
|
||||
assetId: string,
|
||||
key: string,
|
||||
): 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`);
|
||||
return def;
|
||||
}
|
||||
@@ -57,7 +57,6 @@ export class GraphInterpreter {
|
||||
return this.graph.nodes.filter((n) => n.type === type);
|
||||
}
|
||||
|
||||
/** Fires every event node of `type` passing `filter`, seeding its outputs with `outputs`. */
|
||||
fire(
|
||||
type: string,
|
||||
ctx: NodeContext,
|
||||
|
||||
@@ -82,10 +82,8 @@ export interface NodeDef {
|
||||
*/
|
||||
derivePorts?(source: PortSource): DerivedShape;
|
||||
/**
|
||||
* Pure data nodes are evaluated on demand when a downstream input needs their value; exec nodes
|
||||
* run when the flow reaches them. Event nodes have no `run` – the interpreter seeds their outputs.
|
||||
* `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.
|
||||
* Event nodes have no `run` – the interpreter seeds their outputs. `def` is resolved for the node at
|
||||
* hand, the only way a node with derived ports knows how many it has.
|
||||
*/
|
||||
run?(ctx: NodeContext, inputs: Record<string, Value>, params: PropertyBag, def: NodeDef): RunResult;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
/**
|
||||
* The auto-repeat of `On key down`, kept apart from the runtime so it can be checked without a
|
||||
* world: given how long the key has been held, how often does the node fire this frame?
|
||||
*
|
||||
* 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.
|
||||
* The auto-repeat of `On key down`, kept apart from the runtime so it can be tested without a world.
|
||||
* The browser's own repeat is dropped (`Input.press`): its delay and rate are an OS setting, not tunable.
|
||||
*/
|
||||
|
||||
/** Smallest repeat gap, mirroring `On timer`; 0 keeps its own meaning of "every frame". */
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { textOf } from '../model/Properties';
|
||||
import { isValidFieldName } from '../properties/PropertySchema';
|
||||
import { type DeclaredPort, declaredPortType, type Graph, type GraphNode } from './Graph';
|
||||
import type { BehaviorLookup } from './lookup';
|
||||
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. */
|
||||
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. */
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type PropertyValue,
|
||||
type StructValue,
|
||||
textOf,
|
||||
booleanOf,
|
||||
} from '../model/Properties';
|
||||
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 {
|
||||
if (typeof value === 'string') return value !== '' && value !== 'false' && value !== '0';
|
||||
return Boolean(value);
|
||||
return booleanOf(value);
|
||||
}
|
||||
|
||||
/** Entities read as their name, so a handle can be logged or joined into text directly. */
|
||||
|
||||
@@ -23,6 +23,15 @@ export function findAsset(project: Project, assetId: string): Asset | undefined
|
||||
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 {
|
||||
const asset = findAsset(project, sceneId);
|
||||
return asset && isSceneAsset(asset) ? asset : undefined;
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** The declarations cap depth: `list → struct → primitive` is the deepest a stored value goes. */
|
||||
export type ListValue = readonly (number | string | boolean | StructValue)[];
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export function sameValue(a: PropertyValue | undefined, b: PropertyValue | undefined): boolean {
|
||||
if (isList(a) || isList(b)) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BehaviorLookup } from '../graph/lookup';
|
||||
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;
|
||||
@@ -8,8 +8,8 @@ import { findAsset, type Project } from './Project';
|
||||
*/
|
||||
export function behaviorLookup(project: Project): BehaviorLookup {
|
||||
return (assetId) => {
|
||||
const asset = findAsset(project, assetId);
|
||||
if (!asset || asset.type !== 'behavior') return null;
|
||||
const asset = findAssetOf(project, assetId, 'behavior');
|
||||
if (!asset) return null;
|
||||
return { name: asset.name, graph: asset.graph, defaults: resolveAssetProps(asset) };
|
||||
};
|
||||
}
|
||||
@@ -1,14 +1,8 @@
|
||||
/**
|
||||
* A container placed in a scene is not copied into it. The scene stores the container object
|
||||
* alone; its content is resolved from the template on every read, so a template edit reaches
|
||||
* 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.
|
||||
* A placed container's content is resolved from its template on every read. Per-instance changes live
|
||||
* in sparse `instanceEdits` keyed by template-id path; derived ids `<instance>~<path>` are never stored.
|
||||
*/
|
||||
import type { Asset, ContainerAsset } from './Asset';
|
||||
import type { ContainerAsset } from './Asset';
|
||||
import type { ObjectTree } from './hierarchy';
|
||||
import {
|
||||
type Axis,
|
||||
@@ -19,7 +13,7 @@ import {
|
||||
setBagValue,
|
||||
type Vec2,
|
||||
} 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';
|
||||
|
||||
/** 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 {
|
||||
const asset: Asset | undefined = findAsset(project, object.assetId);
|
||||
return asset?.type === 'container' ? asset : undefined;
|
||||
return findAssetOf(project, object.assetId, 'container');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -203,12 +196,8 @@ export interface InstanceTarget {
|
||||
asset: ContainerAsset;
|
||||
template: SceneObject;
|
||||
/**
|
||||
* What the object would show without this instance's edit: the template with the edits of every
|
||||
* container in between already woven in. Only this is a base a value can fall back to, so it –
|
||||
* 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.
|
||||
* The template with the edits of every container in between woven in – this, not `template`, decides
|
||||
* whether an edit deviates. Built fresh on every read, since "apply" writes into `template` first.
|
||||
*/
|
||||
readonly base: SceneObject;
|
||||
}
|
||||
@@ -219,8 +208,6 @@ export function instanceTargetOf(
|
||||
holder: ObjectTree,
|
||||
objectId: string,
|
||||
): 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;
|
||||
const split = splitInstanceId(objectId);
|
||||
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:
|
||||
* a placed object's own, or the deviations a container instance keeps for its content. Anything
|
||||
* 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.
|
||||
* One sparse override bag with the asset whose schema governs it: a placed object's own, or a container
|
||||
* instance's edits for its content. Project-wide rewrites must cover both or they miss container content.
|
||||
*/
|
||||
export interface OverrideSite {
|
||||
holder: ObjectHolder;
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
type PropertyValue,
|
||||
type StructValue,
|
||||
textOf,
|
||||
booleanOf,
|
||||
colorOf,
|
||||
} from '../model/Properties';
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** A list held by a struct field; its items are primitives only, which keeps storable shapes finite. */
|
||||
export interface ListFieldDef {
|
||||
key: string;
|
||||
kind: 'list';
|
||||
@@ -116,6 +113,16 @@ export function isCommonProp(key: string): boolean {
|
||||
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. */
|
||||
const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
scene: [{ key: 'backgroundColor', label: 'Background', kind: 'color', default: 0x2a2d34 }],
|
||||
@@ -262,10 +269,6 @@ const EMPTY_FIELD: Record<FieldKind, number | string | boolean> = {
|
||||
color: 0,
|
||||
};
|
||||
|
||||
export function emptyField(kind: FieldKind): number | string | boolean {
|
||||
return EMPTY_FIELD[kind];
|
||||
}
|
||||
|
||||
export function emptyFieldValue(field: StructFieldDef): FieldValue {
|
||||
return field.kind === 'list' ? [] : EMPTY_FIELD[field.kind];
|
||||
}
|
||||
@@ -283,7 +286,6 @@ export function structOf(fields: readonly StructFieldDef[], from: PropertyValue
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Clamps / coerces a raw value so it satisfies the definition. */
|
||||
export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyValue {
|
||||
switch (def.kind) {
|
||||
case 'number': {
|
||||
@@ -293,12 +295,10 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal
|
||||
if (def.max !== undefined) n = Math.min(def.max, n);
|
||||
return n;
|
||||
}
|
||||
case 'color': {
|
||||
const n = typeof value === 'number' ? value : parseInt(textOf(value).replace('#', ''), 16);
|
||||
return Number.isFinite(n) ? Math.max(0, Math.min(0xffffff, Math.floor(n))) : def.default;
|
||||
}
|
||||
case 'color':
|
||||
return colorOf(value, def.default);
|
||||
case 'boolean':
|
||||
return Boolean(value);
|
||||
return booleanOf(value);
|
||||
case 'text':
|
||||
case 'key':
|
||||
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. */
|
||||
export function coerceField(value: PropertyValue, items: FieldKind): number | string | boolean {
|
||||
if (items === 'text') return textOf(value);
|
||||
if (items === 'boolean') {
|
||||
if (typeof value === 'boolean') return value;
|
||||
// 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;
|
||||
}
|
||||
if (items === 'boolean') return typeof value === 'boolean' ? value : booleanOf(textOf(value));
|
||||
if (items === 'color') return colorOf(value, 0);
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
@@ -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
|
||||
* relative tolerance, because fitting one side and then asking about the other must not answer
|
||||
* "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).
|
||||
* Cross product with a relative tolerance, because 3135 of the 40 000 sizes up to 200 × 200 fail an
|
||||
* exact test right after their own fit (rounding).
|
||||
*/
|
||||
export function matchesAspect(size: PixelSize, props: PropertyBag): boolean {
|
||||
const w = Number(props.width);
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** A behavior's `runInEditor` switch: its writes are real, saved and not undoable, hence off by default. */
|
||||
import type { ObjectTree } from '../model/hierarchy';
|
||||
import { resolveObjects } from '../model/instance';
|
||||
import type { PropertyBag } from '../model/Properties';
|
||||
|
||||
@@ -26,10 +26,6 @@ export function resolveSceneObjectProps(asset: Asset, object: SceneObject): Prop
|
||||
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. */
|
||||
export function isModified(asset: Asset, key: string): boolean {
|
||||
const def = propDef(asset, key);
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
COMMON_PROPS,
|
||||
FIELD_KINDS,
|
||||
type FieldKind,
|
||||
isCommonProp,
|
||||
isValidFieldName,
|
||||
isValidVariableName,
|
||||
LIST_ITEM_KINDS,
|
||||
type ListItemKind,
|
||||
type PropertyDef,
|
||||
@@ -174,7 +175,7 @@ function normalizeVariables(raw: unknown): PropertyDef[] {
|
||||
? (v.kind as VariableKind)
|
||||
: 'number';
|
||||
// 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);
|
||||
const items = (LIST_ITEM_KINDS as readonly string[]).includes(String(v.items))
|
||||
? (v.items as ListItemKind)
|
||||
@@ -193,7 +194,7 @@ function normalizeStructFields(raw: unknown): StructFieldDef[] {
|
||||
for (const item of Array.isArray(raw) ? raw : []) {
|
||||
const f = asRecord(item);
|
||||
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);
|
||||
if (!field) continue;
|
||||
seen.add(key);
|
||||
|
||||
@@ -30,12 +30,8 @@ export interface AssetTabOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* The Graph, Texture and Container tabs. Each follows the selection but keeps showing its last
|
||||
* asset when the selection moves elsewhere – clicking a sprite must not empty the tab being
|
||||
* 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.
|
||||
* The Graph, Texture and Container tabs; each keeps its last asset when the selection moves elsewhere.
|
||||
* A subclass must call `mount()`: header and canvas depend on each other, so the base cannot build them.
|
||||
*/
|
||||
export abstract class AssetTabEditor<
|
||||
A extends Asset,
|
||||
|
||||
@@ -239,7 +239,6 @@ export class Editor implements PlayHost {
|
||||
this.editorRun.tick(dt);
|
||||
}
|
||||
|
||||
/** Refreshes the toolbar readout twice a second. */
|
||||
private measureFps(dt: number): void {
|
||||
this.fpsFrames += 1;
|
||||
this.fpsElapsed += dt;
|
||||
|
||||
+4
-14
@@ -1,13 +1,6 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Runs "run in editor" behaviors outside play mode, on a world built from the saved model, not a copy. */
|
||||
import { hasEditorRun, runsInEditor } from '../core/properties/editorRun';
|
||||
import { BehaviorSystem } from '../runtime/systems/BehaviorSystem';
|
||||
import { CollisionSystem } from '../runtime/systems/CollisionSystem';
|
||||
import { addGameSystems, type BehaviorSystem, behaviorError } from '../runtime/systems/BehaviorSystem';
|
||||
import { Input } from '../runtime/systems/Input';
|
||||
import { type ModelWrite, World } from '../runtime/World';
|
||||
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
|
||||
// here: the content runs, but resolved from its template (see `WorldOptions.bakeContainers`).
|
||||
const fresh = World.instantiate(project, scene.id, { bakeContainers: false });
|
||||
const collisions = new CollisionSystem();
|
||||
this.behaviors = new BehaviorSystem(project, this.input, collisions, (e) => runsInEditor(e.props));
|
||||
fresh.addSystem(collisions);
|
||||
fresh.addSystem(this.behaviors);
|
||||
this.behaviors = addGameSystems(fresh, project, this.input, (e) => runsInEditor(e.props));
|
||||
this.world = fresh;
|
||||
this.hadFocus = this.hooks.focused();
|
||||
this.input.attach(this.hooks.focused);
|
||||
@@ -116,7 +106,7 @@ export class EditorRun {
|
||||
return true;
|
||||
}
|
||||
if (!world.stopRequested) return false;
|
||||
const error = world.log.find((line) => line.startsWith('Error in behavior'));
|
||||
const error = behaviorError(world);
|
||||
this.stop();
|
||||
this.state.setNotice(error ?? 'A behavior running in the editor stopped it.');
|
||||
return true;
|
||||
|
||||
@@ -4,8 +4,12 @@
|
||||
* from the runtime copy `state.enterPlay()` swaps in and `exitPlay()` throws away.
|
||||
*/
|
||||
import { worldPositionOf } from '../core/graph';
|
||||
import { BehaviorSystem, type Trace } from '../runtime/systems/BehaviorSystem';
|
||||
import { CollisionSystem } from '../runtime/systems/CollisionSystem';
|
||||
import {
|
||||
addGameSystems,
|
||||
type BehaviorSystem,
|
||||
behaviorError,
|
||||
type Trace,
|
||||
} from '../runtime/systems/BehaviorSystem';
|
||||
import { Input } from '../runtime/systems/Input';
|
||||
import { World } from '../runtime/World';
|
||||
import { ui } from '../ui';
|
||||
@@ -83,11 +87,7 @@ export class PlaySession {
|
||||
const world = World.instantiate(runtime, scene.id);
|
||||
this.world = world;
|
||||
this.input.attach();
|
||||
// Collisions are detected first so behaviors see this frame's enter/stay/exit events.
|
||||
const collisions = new CollisionSystem();
|
||||
this.behaviors = new BehaviorSystem(runtime, this.input, collisions);
|
||||
world.addSystem(collisions);
|
||||
world.addSystem(this.behaviors);
|
||||
this.behaviors = addGameSystems(world, runtime, this.input);
|
||||
this.host.gameView.setWorld(world);
|
||||
this.host.showGame();
|
||||
this.state.setNotice(this.playNotice());
|
||||
@@ -111,7 +111,7 @@ export class PlaySession {
|
||||
this.host.gameView.setWorld(null);
|
||||
this.state.exitPlay();
|
||||
this.host.leaveGame();
|
||||
const lastError = world?.log.find((line) => line.startsWith('Error in behavior'));
|
||||
const lastError = world && behaviorError(world);
|
||||
this.state.setNotice(lastError ?? '');
|
||||
}
|
||||
|
||||
|
||||
+16
-5
@@ -4,11 +4,13 @@
|
||||
*/
|
||||
import { DeleteAssetCommand } from '../core/commands/assetCommands';
|
||||
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 { RemoveNodeCommand } from '../core/commands/graphCommands';
|
||||
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 { findPropertyUsages, findUsages } from '../core/model/usages';
|
||||
import { Dialog } from '../ui';
|
||||
@@ -22,6 +24,17 @@ export function createContainer(state: EditorState, holderId: string, objectIds:
|
||||
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. */
|
||||
export function addCollider(state: EditorState, holderId: string, objectId: string): boolean {
|
||||
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. */
|
||||
export function removeNodes(state: EditorState, assetId: string, nodeIds: readonly string[]): boolean {
|
||||
const commands = nodeIds.map((id) => new RemoveNodeCommand(assetId, id));
|
||||
if (commands.length === 0) return false;
|
||||
if (commands.length === 1) return state.execute(commands[0]!);
|
||||
return state.execute(new CompoundCommand(`Delete ${commands.length} nodes`, commands));
|
||||
return commands.length > 0 && state.execute(oneStep(`Delete ${commands.length} nodes`, commands));
|
||||
}
|
||||
|
||||
/** Asks before deleting an asset, spelling out what goes with it. */
|
||||
|
||||
@@ -171,9 +171,9 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
viewportInfo: () => viewport.debugInfo(),
|
||||
/** Where a dragged asset would land in the Scene tab, and why not when it may not. */
|
||||
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. */
|
||||
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),
|
||||
assetRowColor: (id: string) => assets.rowColor(id),
|
||||
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');
|
||||
return `#${hex(pixels[0])}${hex(pixels[1])}${hex(pixels[2])}`;
|
||||
},
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** The filter the GPU sampler actually got, next to the model's – only the driver knows the former. */
|
||||
textureFilter: (file: string) => {
|
||||
const texture = textureLibrary.peek(file);
|
||||
if (!texture) return null;
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Geometry of the graph view (rubber band, fit, drag), kept out of the canvas so tests can reach it. */
|
||||
|
||||
import { snapToStep } from '../ui/math';
|
||||
|
||||
|
||||
@@ -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
@@ -1,13 +1,11 @@
|
||||
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
|
||||
import { textOf } from '../../core/model/Properties';
|
||||
import type { Command } from '../../core/commands/Command';
|
||||
import { CompoundCommand } from '../../core/commands/CompoundCommand';
|
||||
import { oneStep } from '../../core/commands/CompoundCommand';
|
||||
import {
|
||||
AddNodeCommand,
|
||||
CollapseNodesCommand,
|
||||
ConnectCommand,
|
||||
DisconnectCommand,
|
||||
FoldEdgeCommand,
|
||||
MoveNodeCommand,
|
||||
} from '../../core/commands/graphCommands';
|
||||
import {
|
||||
@@ -19,12 +17,10 @@ import {
|
||||
edgesInto,
|
||||
edgesOutOf,
|
||||
firstPortFor,
|
||||
type GraphEdge,
|
||||
type GraphNode,
|
||||
type BehaviorLookup,
|
||||
type PortSide,
|
||||
type PortType,
|
||||
portsCompatible,
|
||||
portTypeOf,
|
||||
resolveDef,
|
||||
RUN_BEHAVIOR,
|
||||
@@ -37,34 +33,24 @@ import {
|
||||
activeDrag,
|
||||
clamp,
|
||||
type DragPayload,
|
||||
drawBand,
|
||||
drawGrid,
|
||||
endDrag,
|
||||
Menu,
|
||||
type MenuItem,
|
||||
onDragChange,
|
||||
pressCount,
|
||||
type Size,
|
||||
Theme,
|
||||
textStyle,
|
||||
truncate,
|
||||
Widget,
|
||||
zoomFactor,
|
||||
} from '../../ui';
|
||||
import { removeNodes } from '../actions';
|
||||
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 { bezier, EdgeLayer } from './EdgeLayer';
|
||||
import { openLinkMenu, openNodeMenu } from './graphMenus';
|
||||
import { NodePalette } from './NodePalette';
|
||||
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. */
|
||||
const GRID_STEP = 24;
|
||||
@@ -78,21 +64,6 @@ const NODE_HEIGHT_ESTIMATE = 80;
|
||||
const FIT_MARGIN = 24;
|
||||
/** Pointer travel (screen pixels) before a press turns into a drag rather than a click. */
|
||||
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`),
|
||||
@@ -103,16 +74,7 @@ export class GraphCanvas extends Widget {
|
||||
private readonly grid = new Graphics();
|
||||
private readonly clip = new Graphics();
|
||||
private readonly canvas = new Container();
|
||||
private readonly edges = new Graphics();
|
||||
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 edgeLayer = new EdgeLayer();
|
||||
private readonly nodeLayer = new Container();
|
||||
private readonly tempEdge = new Graphics();
|
||||
/** 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) {
|
||||
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.canvas.mask = this.clip;
|
||||
this.cursor = 'default';
|
||||
@@ -320,22 +282,25 @@ export class GraphCanvas extends Widget {
|
||||
private onCanvasPointerDown(e: FederatedPointerEvent): void {
|
||||
// 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.
|
||||
if (e.button === 1) {
|
||||
const startPan = () => {
|
||||
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();
|
||||
return;
|
||||
}
|
||||
if (e.target !== this) return;
|
||||
// Shift-drag on empty space pans as well, as it does in the scene view.
|
||||
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;
|
||||
}
|
||||
const local = this.toLocal(e.global);
|
||||
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 (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);
|
||||
return;
|
||||
}
|
||||
@@ -358,8 +323,15 @@ export class GraphCanvas extends Widget {
|
||||
const local = view.toLocal(e.global);
|
||||
const hit = view.portAt(local.x, local.y);
|
||||
if (e.button === 2) {
|
||||
if (hit) this.openPortMenu(view, hit.port.key, hit.isOutput, e.global.x, e.global.y);
|
||||
else this.openNodeMenu(view, e.global.x, e.global.y);
|
||||
const { id } = view.node;
|
||||
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;
|
||||
}
|
||||
if (e.button !== 0) return;
|
||||
@@ -519,8 +491,7 @@ export class GraphCanvas extends Widget {
|
||||
node.y = item.start.y;
|
||||
moves.push(new MoveNodeCommand(this.assetId, item.nodeId, next.x, next.y));
|
||||
}
|
||||
if (moves.length === 1) this.state.execute(moves[0]!);
|
||||
else if (moves.length > 1) this.state.execute(new CompoundCommand('Move nodes', moves));
|
||||
if (moves.length > 0) this.state.execute(oneStep('Move nodes', moves));
|
||||
}
|
||||
const connecting = this.connecting;
|
||||
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 {
|
||||
if (!this.assetId) return;
|
||||
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 },
|
||||
),
|
||||
);
|
||||
if (this.assetId) this.state.execute(connectSides(this.assetId, from, to));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -639,15 +602,13 @@ export class GraphCanvas extends Widget {
|
||||
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 {
|
||||
if (!this.assetId) return;
|
||||
const command = new AddNodeCommand(
|
||||
this.assetId,
|
||||
entry.def.type,
|
||||
Math.round(at.x),
|
||||
Math.round(at.y),
|
||||
entry.params,
|
||||
);
|
||||
const command = this.newNode(entry, at);
|
||||
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 },
|
||||
): void {
|
||||
if (!this.assetId) return;
|
||||
const add = new AddNodeCommand(
|
||||
this.assetId,
|
||||
entry.def.type,
|
||||
Math.round(at.x),
|
||||
Math.round(at.y),
|
||||
entry.params,
|
||||
);
|
||||
const add = this.newNode(entry, at);
|
||||
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];
|
||||
if (port)
|
||||
commands.push(
|
||||
new ConnectCommand(
|
||||
this.assetId,
|
||||
{ node: out.nodeId, port: out.port },
|
||||
{ node: inp.nodeId, port: inp.port },
|
||||
),
|
||||
connectSides(this.assetId, from, { nodeId: add.node.id, port, isOutput: !from.isOutput }),
|
||||
);
|
||||
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]));
|
||||
}
|
||||
|
||||
private openNodeMenu(view: NodeView, globalX: number, globalY: number): void {
|
||||
const assetId = this.assetId;
|
||||
if (!assetId) return;
|
||||
// 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,
|
||||
private openLinks(
|
||||
pick: (graph: BehaviorAsset['graph']) => BehaviorAsset['graph']['edges'],
|
||||
at: { x: number; y: number },
|
||||
): void {
|
||||
const asset = this.asset();
|
||||
if (!asset) return;
|
||||
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);
|
||||
const graph = this.asset()?.graph;
|
||||
if (graph) openLinkMenu(this.state, this.assetId, pick(graph), at.x, at.y);
|
||||
}
|
||||
|
||||
override layout(): void {
|
||||
@@ -814,43 +674,29 @@ export class GraphCanvas extends Widget {
|
||||
view.redraw();
|
||||
}
|
||||
|
||||
this.edges.clear();
|
||||
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.edgeLayer.draw(asset.graph.edges, this.views, (id) => isHot(trace?.edges, id), selectedNodes);
|
||||
|
||||
this.tempEdge.clear();
|
||||
this.tempEdgeShown = false;
|
||||
const drag = this.connecting ?? this.pendingDrop;
|
||||
if (drag) {
|
||||
const view = this.views.get(drag.from.nodeId);
|
||||
const p = view?.portPosition(drag.from.port, drag.from.isOutput);
|
||||
if (view && p) {
|
||||
const a = { x: view.x + p.x, y: view.y + p.y };
|
||||
// Snapping to the resolved port makes the prediction the ring shows unmistakable.
|
||||
const b = this.anchorPoint(target) ?? drag.pointer;
|
||||
const [from, to] = drag.from.isOutput ? [a, b] : [b, a];
|
||||
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;
|
||||
}
|
||||
const a = drag && this.anchorPoint(drag.from);
|
||||
if (drag && a) {
|
||||
// Snapping to the resolved port makes the prediction the ring shows unmistakable.
|
||||
const b = this.anchorPoint(target) ?? drag.pointer;
|
||||
const [from, to] = drag.from.isOutput ? [a, b] : [b, a];
|
||||
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();
|
||||
const m = this.marquee;
|
||||
if (m?.active) {
|
||||
const a = this.graphToScreen(m.start.x, m.start.y);
|
||||
const b = this.graphToScreen(m.current.x, m.current.y);
|
||||
this.band
|
||||
.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: c.marquee, alpha: 0.12 })
|
||||
.stroke({ width: 1, color: c.marquee });
|
||||
drawBand(
|
||||
this.band,
|
||||
this.graphToScreen(m.start.x, m.start.y),
|
||||
this.graphToScreen(m.current.x, m.current.y),
|
||||
);
|
||||
}
|
||||
const drop = this.incoming;
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
this.grid.clear();
|
||||
const step = GRID_STEP * this.zoom;
|
||||
@@ -992,14 +751,15 @@ export class GraphCanvas extends Widget {
|
||||
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 {
|
||||
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 }[] {
|
||||
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. */
|
||||
@@ -1007,41 +767,24 @@ export class GraphCanvas extends Widget {
|
||||
edgeId: string,
|
||||
isOutput: boolean,
|
||||
): { 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;
|
||||
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 };
|
||||
}
|
||||
|
||||
/** A node's drawn rectangle on screen, and the canvas' own – "fit" is a claim about both. */
|
||||
nodeRect(nodeId: string): { x: number; y: number; w: number; h: number } | null {
|
||||
nodeRect(nodeId: string): Rect | null {
|
||||
const view = this.views.get(nodeId);
|
||||
if (!view) return 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,
|
||||
};
|
||||
return view ? globalRect(view, view.w, view.h) : null;
|
||||
}
|
||||
|
||||
canvasRect(): { x: number; y: number; w: number; h: number } {
|
||||
const topLeft = this.toGlobal({ x: 0, y: 0 });
|
||||
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,
|
||||
};
|
||||
canvasRect(): Rect {
|
||||
return globalRect(this, this.w, this.h);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** A nested-behavior node takes its title from the behavior it runs. */
|
||||
nodeLabel(nodeId: string): string | null {
|
||||
return this.views.get(nodeId)?.def.label ?? null;
|
||||
}
|
||||
@@ -1069,8 +812,20 @@ export class GraphCanvas extends Widget {
|
||||
}
|
||||
}
|
||||
|
||||
/** Horizontal S-curve between two points. */
|
||||
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);
|
||||
type Rect = { x: number; y: number; w: number; h: number };
|
||||
|
||||
function globalRect(target: Container, w: number, h: number): Rect {
|
||||
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 },
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
* hidden links out of the same port stack their stub, dot and name on the very same pixels.
|
||||
* 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.
|
||||
* One mark per port (per side on a collapsed node), so hidden links of one port don't stack on the same
|
||||
* pixels. The separator is NUL: a printable one merges node "a b" + port "c" with node "a" + port "b c".
|
||||
*/
|
||||
export function foldMarkKey(
|
||||
side: { node: string; port: string },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { PropertyBag } from '../../core/model/Properties';
|
||||
import {
|
||||
@@ -23,7 +23,7 @@ export type SizeValues = Partial<Record<SizeAxis, number>>;
|
||||
/** One undo step whether the action wrote one side or both. */
|
||||
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));
|
||||
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. */
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
SetAssetPropertyCommand,
|
||||
} from '../../core/commands/assetCommands';
|
||||
import { nextFieldName, nextVariableName } from '../../core/commands/naming';
|
||||
import { structShapeOf } from '../../core/graph';
|
||||
import {
|
||||
AddStructFieldCommand,
|
||||
AddVariableCommand,
|
||||
@@ -22,7 +23,6 @@ import {
|
||||
type StructFieldDef,
|
||||
type StructFieldKind,
|
||||
VARIABLE_KINDS,
|
||||
type VariableKind,
|
||||
} from '../../core/properties/PropertySchema';
|
||||
import { markedAssetSchema } from '../../core/properties/deviation';
|
||||
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 { createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory';
|
||||
import { addNote, addTitle, type InspectorHost } from './host';
|
||||
import { buildVariable, type VariableEditing } from './variableRows';
|
||||
import { listOpener, type PreviewedValue, previewingHandlers, structFields } from './previewHandlers';
|
||||
import { buildVariable, KIND_LABELS, type VariableEditing } from './variableRows';
|
||||
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. */
|
||||
const newField = (key: string, kind: StructFieldKind): StructFieldDef =>
|
||||
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 {
|
||||
const fields = declaredFields(def)
|
||||
.map((f) => `${f.key}:${f.kind}`)
|
||||
.join('+');
|
||||
const fields = (structShapeOf(def) ?? []).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.
|
||||
const items = def.kind === 'list' ? `/${def.items}` : '';
|
||||
return `${def.key}:${def.kind}${items}${fields ? `(${fields})` : ''}`;
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
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. */
|
||||
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 {
|
||||
...previewingHandlers<Asset>(host, spec),
|
||||
...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' });
|
||||
add.onClick = () => {
|
||||
const items = VARIABLE_KINDS.map((kind) => ({
|
||||
label: VARIABLE_KIND_LABELS[kind],
|
||||
label: KIND_LABELS[kind],
|
||||
onSelect: () => {
|
||||
const current = findAsset(host.state.project, asset.id);
|
||||
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)),
|
||||
removeField: (field) => run(new RemoveStructFieldCommand(asset.id, def.key, field.key)),
|
||||
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;
|
||||
host.fields.set(def.key, buildVariable(host, def, value, assetHandlers(host, asset.id, def), { edit }));
|
||||
|
||||
@@ -110,10 +110,7 @@ export function labelledRow(text: string): { row: HStack; label: Label } {
|
||||
return { row, label };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Adds the trailing ↺/↑/extra buttons and returns their refresh; `buildVariable` uses it for its first row too. */
|
||||
export function attachRowActions(row: HStack, rowActions: RowActions): () => void {
|
||||
const actions: { button: Button; glyph: string; available: FieldActions }[] = [];
|
||||
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.
|
||||
* The inspector is entirely generated through this function: adding a new property
|
||||
* 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 (↺ ▾ / ↑ ▾).
|
||||
* Maps one schema entry to a labelled row. Action groups are asked again on each refresh: none means
|
||||
* no button, one a button, several a menu.
|
||||
*/
|
||||
export function createFieldRow(
|
||||
ctx: FieldContext,
|
||||
@@ -174,8 +166,7 @@ export function createFieldRow(
|
||||
): FieldRow {
|
||||
const { row, label } = labelledRow(def.label);
|
||||
const field = createField(ctx, def, value, handlers);
|
||||
row.add(field.widget, field.grow ? 1 : 0);
|
||||
if (!field.grow) row.add(new Spacer(), 1);
|
||||
addValue(row, field);
|
||||
|
||||
const refreshActions = attachRowActions(row, rowActions);
|
||||
|
||||
@@ -198,6 +189,19 @@ interface Field {
|
||||
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. */
|
||||
export function primitiveDef(key: string, kind: FieldKind, label = key): PrimitiveDef {
|
||||
switch (kind) {
|
||||
@@ -263,18 +267,13 @@ export function createField(
|
||||
case 'struct': {
|
||||
// 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.
|
||||
const per = handlers.structFieldHandlers;
|
||||
if (!per) {
|
||||
const summary = new Label(textOf(value), { style: 'muted' });
|
||||
return { widget: summary, grow: true, setValue: (v) => summary.setText(textOf(v)) };
|
||||
}
|
||||
const per = handlers.structFieldHandlers ?? (() => READ_ONLY);
|
||||
const stack = new VStack({ gap: Theme.gap });
|
||||
const fields = new Map<string, Field>();
|
||||
for (const field of def.fields) {
|
||||
const { row } = labelledRow(field.key);
|
||||
const sub = createField(ctx, fieldDef(field), structFieldValue(value, field), per(field));
|
||||
row.add(sub.widget, sub.grow ? 1 : 0);
|
||||
if (!sub.grow) row.add(new Spacer(), 1);
|
||||
addValue(row, sub);
|
||||
fields.set(field.key, sub);
|
||||
stack.add(row);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
SetFrameRectCommand,
|
||||
} from '../../core/commands/frameCommands';
|
||||
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 { Button, TextField } from '../../ui';
|
||||
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 {
|
||||
const asset = findAsset(project, assetId);
|
||||
return asset?.type === 'texture' ? asset : undefined;
|
||||
return findAssetOf(project, assetId, 'texture');
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { RemoveNodeCommand, SetNodeParamCommand } from '../../core/commands/graphCommands';
|
||||
import { RemoveNodeCommand } from '../../core/commands/graphCommands';
|
||||
import {
|
||||
callProblem,
|
||||
declaresVariablePorts,
|
||||
type Graph,
|
||||
edgesInto,
|
||||
type GraphNode,
|
||||
inlineInputDef,
|
||||
@@ -12,18 +11,19 @@ import {
|
||||
} from '../../core/graph';
|
||||
import type { Asset } from '../../core/model/Asset';
|
||||
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 { Button, Label } from '../../ui';
|
||||
import { createFieldRow, type FieldHandlers } from './fieldFactory';
|
||||
import { addGap, addNote, addTitle, type InspectorHost } from './host';
|
||||
import { buildPortSection, buildVariablePortSection } from './portSection';
|
||||
import { listOpener, previewingHandlers } from './previewHandlers';
|
||||
import { currentNode, nodeParamValue } from './valueSpecs';
|
||||
|
||||
/** Structure-defining facts of a node selection: ports, connected inputs, declared variables. */
|
||||
export function nodeSignature(project: Project, assetId: string, nodeId: string): string {
|
||||
const asset = findAsset(project, assetId);
|
||||
if (!asset || asset.type !== 'behavior') return 'missing';
|
||||
const asset = findAssetOf(project, assetId, 'behavior');
|
||||
if (!asset) return 'missing';
|
||||
const node = asset.graph.nodes.find((n) => n.id === nodeId);
|
||||
if (!node) return 'missing';
|
||||
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. */
|
||||
export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: string): void {
|
||||
const asset = findAsset(host.state.project, assetId);
|
||||
if (!asset || asset.type !== 'behavior') return;
|
||||
const asset = findAssetOf(host.state.project, assetId, 'behavior');
|
||||
if (!asset) return;
|
||||
const node = asset.graph.nodes.find((n) => n.id === nodeId);
|
||||
const def = node && resolveDef(node, asset.graph, behaviorLookup(host.state.project));
|
||||
if (!node || !def) return;
|
||||
@@ -126,25 +126,7 @@ function paramHandlers(
|
||||
): FieldHandlers {
|
||||
const key = def.key;
|
||||
return {
|
||||
...previewingHandlers<GraphNode>(host, {
|
||||
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),
|
||||
}),
|
||||
...previewingHandlers<GraphNode>(host, nodeParamValue(host.state, 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 };
|
||||
}
|
||||
@@ -38,14 +38,15 @@ import {
|
||||
resolveSceneObjectProps,
|
||||
} from '../../core/properties/resolve';
|
||||
import { Button, TextField } from '../../ui';
|
||||
import { addCollider, createContainer, removeObjects } from '../actions';
|
||||
import { addCollider, canAddCollider, createContainer, removeObjects } from '../actions';
|
||||
import { aspectActions, sizeCommand } from './aspectAction';
|
||||
import { defSignature } from './assetSection';
|
||||
import { createFieldRow, type FieldRow, labelledRow, type RowActions } from './fieldFactory';
|
||||
import { addGap, addNote, addTitle, type InspectorHost } from './host';
|
||||
import type { ListSlot } from '../state';
|
||||
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 { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf } from './objectQueries';
|
||||
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 !== 'collider' && !target) {
|
||||
const stored = findObjectHolder(project, holderId);
|
||||
if (stored && canAddCollider(project, stored, object)) {
|
||||
addGap(host);
|
||||
host.body.add(
|
||||
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
|
||||
* or, inside a container, to the template) and ↑ (apply upwards).
|
||||
*
|
||||
* 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.
|
||||
* One editable property of a placed object. Reset always goes to the nearest base (the template inside
|
||||
* a container), while "Apply to asset" reaches past it, dropping the template's override.
|
||||
*/
|
||||
function propertyRow(
|
||||
host: InspectorHost,
|
||||
@@ -309,14 +307,7 @@ function propertyRow(
|
||||
variable = false,
|
||||
): FieldRow {
|
||||
const bases = () => deviations(host.state, holderId, objectId, def.key);
|
||||
const overrideSpec: PreviewedValue<ObjectRef> = {
|
||||
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 overrideSpec = overrideValue(host.state, holderId, objectId, def.key);
|
||||
const fieldSlot = (field: string): ListSlot => ({
|
||||
kind: 'object',
|
||||
holderId,
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
BEHAVIOR_START,
|
||||
DECLARED_PORT_KINDS,
|
||||
type DeclaredPort,
|
||||
type DeclaredPortKind,
|
||||
type GraphNode,
|
||||
isTerminalNode,
|
||||
missingFieldPorts,
|
||||
@@ -18,26 +17,20 @@ import {
|
||||
variablePortKind,
|
||||
} from '../../core/graph';
|
||||
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';
|
||||
|
||||
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: '⊞',
|
||||
};
|
||||
import { KIND_LABELS, kindMark, markLabel } from './variableRows';
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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' });
|
||||
choose.onClick = () => {
|
||||
const items = pick(port.key, (next) =>
|
||||
@@ -87,16 +75,7 @@ export function buildVariablePortSection(
|
||||
);
|
||||
new Menu(items).openBelow(choose);
|
||||
};
|
||||
row.add(choose, 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);
|
||||
portRow(host, assetId, node, port, choose);
|
||||
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) {
|
||||
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);
|
||||
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);
|
||||
const name = new TextField(port.key, {
|
||||
width: NAME_WIDTH,
|
||||
onChange: (next) => host.state.execute(new RenamePortCommand(assetId, node.id, port.key, next.trim())),
|
||||
});
|
||||
portRow(host, assetId, node, port, name);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
* typing them again. Only offered where there is a shape to copy; a field the node already has
|
||||
|
||||
@@ -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
|
||||
* committing. A command records the value it finds at execute time, so committing right away
|
||||
* 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`.
|
||||
* Row handlers that preview into the model and restore the pre-preview value (or its absence, via
|
||||
* `remove`) before committing, since a command records whatever it finds at execute time as its undo target.
|
||||
*/
|
||||
export function previewingHandlers<Target, Model = PropertyValue>(
|
||||
host: { state: EditorState },
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DeclaredPortKind } from '../../core/graph';
|
||||
import type { PropertyValue } from '../../core/model/Properties';
|
||||
import {
|
||||
FIELD_KINDS,
|
||||
@@ -8,10 +9,12 @@ import {
|
||||
type StructDef,
|
||||
type StructFieldDef,
|
||||
type StructFieldKind,
|
||||
type VariableKind,
|
||||
} from '../../core/properties/PropertySchema';
|
||||
import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme, type Widget } from '../../ui';
|
||||
import type { ListDef } from '../list/slot';
|
||||
import {
|
||||
addValue,
|
||||
attachRowActions,
|
||||
createField,
|
||||
FIELD_INDENT,
|
||||
@@ -20,26 +23,34 @@ import {
|
||||
type FieldRow,
|
||||
MARK_WIDTH,
|
||||
NAME_WIDTH,
|
||||
READ_ONLY,
|
||||
removeButton,
|
||||
type RowActions,
|
||||
structFieldValue,
|
||||
} from './fieldFactory';
|
||||
import { addNote, type InspectorHost } from './host';
|
||||
|
||||
// Keyed by the wide `PropertyDef['kind']` because variables are `PropertyDef`s, but only the
|
||||
// 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: '⊞',
|
||||
};
|
||||
type ShownKind = VariableKind | ListItemKind | StructFieldKind | DeclaredPortKind;
|
||||
|
||||
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: '#',
|
||||
boolean: '✓',
|
||||
text: 'Aa',
|
||||
color: '◧',
|
||||
list: '≡',
|
||||
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> = {
|
||||
@@ -50,21 +61,10 @@ const LIST_ITEM_LABELS: Record<ListItemKind, string> = {
|
||||
struct: 'Structs',
|
||||
};
|
||||
|
||||
const FIELD_KIND_MARKS: Record<StructFieldKind, string> = {
|
||||
number: '#',
|
||||
boolean: '✓',
|
||||
text: 'Aa',
|
||||
color: '◧',
|
||||
list: '≡',
|
||||
};
|
||||
|
||||
const FIELD_KIND_LABELS: Record<StructFieldKind, string> = {
|
||||
number: 'Number',
|
||||
boolean: 'Boolean',
|
||||
text: 'Text',
|
||||
color: 'Color',
|
||||
list: 'List',
|
||||
};
|
||||
/** Variables are `PropertyDef`s, so the kind is typed wide; only the shown kinds ever reach it. */
|
||||
export function kindMark(kind: string): string {
|
||||
return (KIND_MARKS as Partial<Record<string, string>>)[kind] ?? '?';
|
||||
}
|
||||
|
||||
/** Changing a declaration – offered only on the asset that declares it. */
|
||||
export interface VariableEditing {
|
||||
@@ -82,8 +82,6 @@ export interface VariableOptions {
|
||||
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
|
||||
* 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 {
|
||||
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. */
|
||||
@@ -194,22 +192,13 @@ function nameCell(text: string, depth: number, rename: ((next: string) => void)
|
||||
: 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 {
|
||||
if (def.kind !== 'list') return markLabel(VARIABLE_KIND_MARKS[def.kind] ?? '?');
|
||||
const glyph = `≡${LIST_ITEM_MARKS[def.items]}`;
|
||||
if (def.kind !== 'list') return markLabel(kindMark(def.kind));
|
||||
const glyph = `≡${KIND_MARKS[def.items]}`;
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -244,12 +233,8 @@ function addFieldMenu(kinds: readonly StructFieldKind[], edit: VariableEditing):
|
||||
const add = new Dropdown('+ Field', { variant: 'ghost' });
|
||||
add.onClick = () => {
|
||||
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);
|
||||
};
|
||||
return add;
|
||||
}
|
||||
|
||||
function removeButton(onClick: () => void): Button {
|
||||
return new Button('×', { variant: 'ghost', width: Theme.controlHeight, onClick });
|
||||
}
|
||||
@@ -6,7 +6,14 @@
|
||||
import { Button, HStack, Label, ScrollContainer, Spacer, Theme, VStack, type Widget } from '../../ui';
|
||||
import { textOf, type ListValue, type PropertyValue } from '../../core/model/Properties';
|
||||
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 ListAccess, type ListDef, resolveList } from './slot';
|
||||
|
||||
@@ -124,15 +131,8 @@ export class ListEditor extends VStack {
|
||||
if (tall) label.minHeight = Theme.rowHeight;
|
||||
row.add(label);
|
||||
const field = createField(this.ctx, itemDef(access.def, index), item, access.itemHandlers(index));
|
||||
row.add(field.widget, field.grow ? 1 : 0);
|
||||
if (!field.grow) row.add(new Spacer(), 1);
|
||||
row.add(
|
||||
new Button('×', {
|
||||
variant: 'ghost',
|
||||
width: Theme.controlHeight,
|
||||
onClick: () => access.replace(access.value.filter((_, i) => i !== index)),
|
||||
}),
|
||||
);
|
||||
addValue(row, field);
|
||||
row.add(removeButton(() => access.replace(access.value.filter((_, i) => i !== index))));
|
||||
this.setters.push(field.setValue);
|
||||
this.fields.push({ widget: field.widget, parts: field.parts });
|
||||
return row;
|
||||
|
||||
+12
-42
@@ -1,14 +1,8 @@
|
||||
/**
|
||||
* Resolves a `ListSlot` to the list in it and to the two ways of writing it back. Each of the
|
||||
* three cases is the spec the matching inspector section already uses, so an item edited in the
|
||||
* 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.
|
||||
* Resolves a `ListSlot` to its list and write-back, reusing each inspector section's spec so a List tab
|
||||
* edit gets the same command, coercion and undo step. A `field` slot still writes the whole struct.
|
||||
*/
|
||||
import { SetAssetPropertyCommand } from '../../core/commands/assetCommands';
|
||||
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
|
||||
import { SetOverrideCommand } from '../../core/commands/sceneObjectCommands';
|
||||
import { edgesInto, findNode, type Graph, type GraphNode } from '../../core/graph/Graph';
|
||||
import { edgesInto, type Graph, type GraphNode } from '../../core/graph/Graph';
|
||||
import { resolveDef } from '../../core/graph/nested';
|
||||
import { inlineInputDef, inlineInputs, type NodeDef } from '../../core/graph/NodeRegistry';
|
||||
import type { Asset } from '../../core/model/Asset';
|
||||
@@ -28,6 +22,7 @@ import { resolveAssetProps } from '../../core/properties/resolve';
|
||||
import type { FieldHandlers } from '../inspector/fieldFactory';
|
||||
import { currentProps, currentRef } from '../inspector/objectQueries';
|
||||
import { previewingHandlers, type PreviewedValue } from '../inspector/previewHandlers';
|
||||
import { assetValue, currentNode, nodeParamValue, overrideValue } from '../inspector/valueSpecs';
|
||||
import type { EditorState, ListSlot } from '../state';
|
||||
|
||||
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 def = asset && schemaForAsset(asset).find((d) => d.key === slot.key);
|
||||
if (!asset || !def) return null;
|
||||
const at = () => findAsset(state.project, slot.assetId);
|
||||
return {
|
||||
def,
|
||||
title: `${asset.name} · ${def.label}`,
|
||||
current: () => {
|
||||
const now = at();
|
||||
return now ? resolveAssetProps(now)[slot.key] : undefined;
|
||||
},
|
||||
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),
|
||||
const now = findAsset(state.project, slot.assetId);
|
||||
return now && resolveAssetProps(now)[slot.key];
|
||||
},
|
||||
spec: assetValue(state, slot.assetId, slot.key),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -186,18 +175,12 @@ function objectOwner(
|
||||
def,
|
||||
title: `${ref.object.name} · ${def.label}`,
|
||||
current: () => currentProps(state, slot.holderId, slot.objectId)?.[slot.key],
|
||||
spec: {
|
||||
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),
|
||||
},
|
||||
spec: overrideValue(state, slot.holderId, slot.objectId, slot.key),
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
const nodeDef = resolveDef(found.node, found.graph, behaviorLookup(state.project));
|
||||
const def =
|
||||
@@ -206,14 +189,9 @@ function nodeOwner(state: EditorState, slot: Extract<ListSlot, { kind: 'node' }>
|
||||
return {
|
||||
def,
|
||||
title: `${nodeDef.label} · ${def.label}`,
|
||||
current: () => nodeIn(state, slot)?.node.params[slot.key] ?? def.default,
|
||||
spec: {
|
||||
target: () => nodeIn(state, slot)?.node,
|
||||
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),
|
||||
},
|
||||
current: () =>
|
||||
currentNode(state.project, slot.assetId, slot.nodeId)?.node.params[slot.key] ?? def.default,
|
||||
spec: nodeParamValue(state, slot.assetId, slot.nodeId, slot.key),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -228,11 +206,3 @@ function inlineListDef(def: NodeDef, found: { node: GraphNode; graph: Graph }, k
|
||||
const inline = inlineInputDef(port);
|
||||
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;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { ReorderObjectCommand, SetParentCommand } from '../../core/commands/sceneObjectCommands';
|
||||
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 { resolveSceneObjectProps } from '../../core/properties/resolve';
|
||||
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';
|
||||
|
||||
/**
|
||||
* Objects of the scene or container being edited, as a tree (children indented below their parent).
|
||||
* Click selects, Ctrl/Shift-click adds to or removes from the selection. Drag a row onto
|
||||
* 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.
|
||||
* Objects of the scene or container being edited, as a tree; dragging re-parents or reorders and always
|
||||
* keeps world position. Fold state is view state kept here, never in the project.
|
||||
*/
|
||||
export class HierarchyPanel extends Panel {
|
||||
private readonly list = new ListView();
|
||||
@@ -72,12 +62,7 @@ export class HierarchyPanel extends Panel {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** A move that leaves the order as it is does nothing, so a drag back to its start costs no undo step. */
|
||||
private drop(draggedId: string, spot: DropSpot): void {
|
||||
const holder = this.holder();
|
||||
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));
|
||||
commands.push(new ReorderObjectCommand(holder.id, draggedId, beforeId));
|
||||
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;
|
||||
const inScene = holder.type === 'scene';
|
||||
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
|
||||
// structure cannot (see `model/instance.ts`).
|
||||
const source = this.source();
|
||||
@@ -181,7 +165,7 @@ export class HierarchyPanel extends Panel {
|
||||
},
|
||||
{
|
||||
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),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { EditorState, SaveStatus } from '../state';
|
||||
export interface ToolbarActions {
|
||||
/** The "Project" dropdown: list, create and delete projects. `anchor` is the button to open the menu under. */
|
||||
openProjectMenu(anchor: Widget): void;
|
||||
/** The scene dropdown: switch the active scene. */
|
||||
openSceneMenu(anchor: Widget): void;
|
||||
/** Play in edit mode, Stop in play mode. */
|
||||
togglePlay(): void;
|
||||
|
||||
+11
-26
@@ -6,6 +6,7 @@ import { resolvedHolder } from '../core/model/instance';
|
||||
import {
|
||||
createEmptyProject,
|
||||
findAsset,
|
||||
findAssetOf,
|
||||
findObjectHolder,
|
||||
findScene,
|
||||
type Project,
|
||||
@@ -89,13 +90,8 @@ export type EditorEvent =
|
||||
type Listener = () => void;
|
||||
|
||||
/**
|
||||
* Single source of truth for the editor: the project, undo stack, selection, mode and
|
||||
* save status. Panels subscribe to coarse events and refresh themselves; there is no
|
||||
* 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.
|
||||
* Single source of truth for the editor. Play mode swaps `project` for a deep clone (same ids)
|
||||
* with its own undo stack; panels and commands keep working on it, and `exitPlay()` discards it.
|
||||
*/
|
||||
export class EditorState {
|
||||
project: Project = createEmptyProject('', 'Untitled');
|
||||
@@ -105,9 +101,8 @@ export class EditorState {
|
||||
listSlot: ListSlot | null = null;
|
||||
mode: EditorMode = 'edit';
|
||||
/**
|
||||
* Play is on but the world is not being ticked. A sub-state of `mode === 'play'` rather than a
|
||||
* third mode: everything that distinguishes playing from editing (the runtime copy, the
|
||||
* keyboard, edits not being saved) is just as true while paused.
|
||||
* A sub-state of play rather than a third mode: the runtime copy, the keyboard and unsaved edits all
|
||||
* still apply.
|
||||
*/
|
||||
paused = false;
|
||||
saveStatus: SaveStatus = 'saved';
|
||||
@@ -118,7 +113,6 @@ export class EditorState {
|
||||
|
||||
private readonly listeners = new Map<EditorEvent, Set<Listener>>();
|
||||
|
||||
/** Subscribes to an event; returns the unsubscribe function. */
|
||||
on(event: EditorEvent, listener: Listener): () => void {
|
||||
let set = this.listeners.get(event);
|
||||
if (!set) this.listeners.set(event, (set = new Set()));
|
||||
@@ -147,10 +141,7 @@ export class EditorState {
|
||||
this.emit('mode');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Returns the runtime copy; the selection survives because ids are identical. */
|
||||
enterPlay(): Project {
|
||||
if (this.mode === 'play') return this.project;
|
||||
const copy = deserializeProject(serializeProject(this.project), this.project.id);
|
||||
@@ -171,7 +162,6 @@ export class EditorState {
|
||||
this.emit('mode');
|
||||
}
|
||||
|
||||
/** Leaves play mode: the runtime copy is discarded, the saved model comes back unchanged. */
|
||||
exitPlay(): void {
|
||||
if (this.mode !== 'play') return;
|
||||
if (this.edit) {
|
||||
@@ -285,7 +275,6 @@ export class EditorState {
|
||||
this.emit('selection');
|
||||
}
|
||||
|
||||
/** Ctrl/Shift-click: adds the object to the selection or removes it again. */
|
||||
toggleObject(holderId: string, objectId: string): void {
|
||||
const s = this.selection;
|
||||
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));
|
||||
}
|
||||
|
||||
/** Ctrl/Shift-click: adds the node to the selection or removes it again. */
|
||||
toggleNode(assetId: string, nodeId: string): void {
|
||||
const s = this.selection;
|
||||
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 });
|
||||
}
|
||||
|
||||
/** Ids of the selected scene objects (empty unless objects are selected). */
|
||||
get selectedObjectIds(): string[] {
|
||||
return this.selection?.kind === 'object' ? this.selection.objectIds : [];
|
||||
}
|
||||
|
||||
/** Ids of the selected graph nodes (empty unless nodes are selected). */
|
||||
get selectedNodeIds(): string[] {
|
||||
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 (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).
|
||||
if (!asset || asset.type !== 'behavior') this.select(null);
|
||||
if (!asset) this.select(null);
|
||||
else {
|
||||
const alive = s.nodeIds.filter((id) => asset.graph.nodes.some((n) => n.id === id));
|
||||
if (alive.length !== s.nodeIds.length)
|
||||
@@ -337,9 +323,9 @@ export class EditorState {
|
||||
}
|
||||
}
|
||||
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.
|
||||
if (!asset || asset.type !== 'texture') this.select(null);
|
||||
if (!asset) this.select(null);
|
||||
else if (!asset.frames.some((f) => f.id === s.frameId))
|
||||
this.select({ kind: 'asset', assetId: s.assetId });
|
||||
}
|
||||
@@ -350,8 +336,7 @@ export class EditorState {
|
||||
const s = this.selection;
|
||||
if (!s) return null;
|
||||
if (s.kind !== 'asset' && s.kind !== 'frame') return null;
|
||||
const asset = findAsset(this.project, s.assetId);
|
||||
return asset && asset.type === 'texture' ? asset.id : null;
|
||||
return findAssetOf(this.project, s.assetId, 'texture')?.id ?? null;
|
||||
}
|
||||
|
||||
/** The behavior asset the current selection refers to (asset, placed object or node), if any. */
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 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 { CompoundCommand } from '../../core/commands/CompoundCommand';
|
||||
import { oneStep } from '../../core/commands/CompoundCommand';
|
||||
import { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
|
||||
import { isSymbolType } from '../../core/model/AssetTypes';
|
||||
import { findObject, subtreeRoots } from '../../core/model/hierarchy';
|
||||
@@ -18,6 +18,7 @@ import { type EditorState, objectSelection } from '../state';
|
||||
import {
|
||||
activeDrag,
|
||||
clamp,
|
||||
drawBand,
|
||||
drawGrid,
|
||||
endDrag,
|
||||
onDragChange,
|
||||
@@ -299,8 +300,7 @@ export class SceneViewport extends WorldViewport {
|
||||
ref.setPosition(item.start);
|
||||
moves.push(new MoveSceneObjectCommand(drag.holderId, item.objectId, next));
|
||||
}
|
||||
if (moves.length === 1) this.state.execute(moves[0]!);
|
||||
else if (moves.length > 1) this.state.execute(new CompoundCommand('Move objects', moves));
|
||||
if (moves.length > 0) this.state.execute(oneStep('Move objects', moves));
|
||||
}
|
||||
|
||||
private onWheel(e: FederatedWheelEvent): void {
|
||||
@@ -489,15 +489,6 @@ export class SceneViewport extends WorldViewport {
|
||||
}
|
||||
this.drawDropPreview(view);
|
||||
const m = this.marquee;
|
||||
if (m?.active) {
|
||||
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 });
|
||||
}
|
||||
if (m?.active) drawBand(this.gizmos, m.start, m.current);
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,6 @@ export function mountPlayground(app: Application, root: VStack): void {
|
||||
|
||||
const row = new HStack({ gap: Theme.padding, padding: Theme.padding });
|
||||
|
||||
// Column 1: inputs
|
||||
const inputs = new Panel({ title: 'Inputs' });
|
||||
inputs.content.add(new Label('Text field'));
|
||||
inputs.content.add(
|
||||
@@ -67,7 +66,6 @@ export function mountPlayground(app: Application, root: VStack): void {
|
||||
inputs.content.add(checkRow);
|
||||
row.add(inputs, 1);
|
||||
|
||||
// Column 2: buttons + popups
|
||||
const actions = new Panel({ title: 'Buttons & popups' });
|
||||
actions.content.add(new Button('Default', { onClick: () => log('default 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);
|
||||
|
||||
// Column 3: list in a scroll container
|
||||
const listPanel = new Panel({ title: 'List (scroll)', padding: 0 });
|
||||
const scroll = new ScrollContainer();
|
||||
const list = new ListView();
|
||||
|
||||
@@ -2,12 +2,7 @@ import type { ENTITY_HANDLE } from '../core/graph/NodeContext';
|
||||
import type { AssetType } from '../core/model/AssetTypes';
|
||||
import type { PropertyBag } from '../core/model/Properties';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** A live instance of a scene object; its resolved props are mutable runtime state the world mirrors back. */
|
||||
export interface Entity {
|
||||
/** What tells a graph this object is a live entity and not a struct value. */
|
||||
readonly [ENTITY_HANDLE]: true;
|
||||
|
||||
+5
-19
@@ -29,13 +29,8 @@ export interface ModelWrite {
|
||||
}
|
||||
|
||||
/**
|
||||
* The running game: entities instantiated from one scene plus the systems ticking them.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export class World {
|
||||
readonly entities: Entity[] = [];
|
||||
@@ -82,12 +77,7 @@ export class World {
|
||||
return this.elapsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Makes the entities match the scene; props are updated in place so systems holding a bag stay current. */
|
||||
syncFromModel(): void {
|
||||
const scene = findScene(this.project, this.sceneId);
|
||||
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
|
||||
* the scene of the runtime copy – so the panels list it and a full sync keeps it – and its
|
||||
* 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.
|
||||
* Adds the object to the scene and creates only its entity, leaving the others alone (they may have
|
||||
* moved this tick already). Position is local to `parent`, world when there is none.
|
||||
*/
|
||||
spawn(
|
||||
assetId: string,
|
||||
@@ -279,7 +266,6 @@ export class World {
|
||||
return this.entities.filter((e) => e.parent === entity);
|
||||
}
|
||||
|
||||
/** Everything parented below an entity, at any depth. */
|
||||
descendantsOf(entity: Entity): Entity[] {
|
||||
return descendantsBelow(
|
||||
entity,
|
||||
|
||||
@@ -10,10 +10,10 @@ import {
|
||||
import { stepKeyRepeat } from '../../core/graph/keyRepeat';
|
||||
import { behaviorLookup } from '../../core/model/behaviorPrograms';
|
||||
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 { 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 { System } from './System';
|
||||
|
||||
@@ -36,6 +36,27 @@ interface Instance {
|
||||
* 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.
|
||||
*/
|
||||
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 {
|
||||
private instances: Instance[] = [];
|
||||
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
|
||||
* interpreter and their `On start`; instances whose entity is gone are dropped. A behavior
|
||||
* 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.
|
||||
* One instance per behavior entity. A switched-off behavior gets its instance and `On start` only when
|
||||
* switched on, which is what lets a hidden screen wake up when shown.
|
||||
*/
|
||||
private syncInstances(world: World): void {
|
||||
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)
|
||||
)
|
||||
continue;
|
||||
const asset = findAsset(this.project, entity.assetId);
|
||||
if (!asset || asset.type !== 'behavior') continue;
|
||||
const asset = findAssetOf(this.project, entity.assetId, 'behavior');
|
||||
if (!asset) continue;
|
||||
const trace: Trace = { edges: new Map(), nodes: new Map() };
|
||||
const interpreter = new GraphInterpreter(asset.graph, behaviorLookup(this.project));
|
||||
interpreter.trace = {
|
||||
@@ -175,7 +194,6 @@ export class BehaviorSystem implements System {
|
||||
fire('OnCollisionExit', this.collisions.exits);
|
||||
}
|
||||
|
||||
/** Object ids of the behaviors this system is currently running. */
|
||||
runningIds(): string[] {
|
||||
return this.instances.map((i) => i.entity.objectId);
|
||||
}
|
||||
@@ -210,7 +228,7 @@ export class BehaviorSystem implements System {
|
||||
body(instance.interpreter, ctx);
|
||||
} catch (error) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -24,10 +24,8 @@ export interface CollisionPair {
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects overlaps between collider entities once per tick and sorts them into enter /
|
||||
* stay / exit lists for the frame. Detection only – nothing is pushed apart. Colliders
|
||||
* of the same body (same parent) never collide with each other. Runs before the
|
||||
* BehaviorSystem so graphs see this frame's events.
|
||||
* Sorts collider overlaps into enter / stay / exit per tick, detection only; colliders of one body never
|
||||
* collide. Runs before the BehaviorSystem so graphs see this frame's events.
|
||||
*/
|
||||
export class CollisionSystem implements System {
|
||||
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
|
||||
* each axis step its solid colliders are pushed back to just touch whatever solid collider
|
||||
* 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.
|
||||
* Moves one axis at a time, stopping each at contact so the object slides along walls. An existing
|
||||
* overlap is never resolved (no jolt) but never deepened, so an object can leave a wall but not burrow in.
|
||||
*/
|
||||
moveAndSlide(
|
||||
world: World,
|
||||
@@ -114,7 +107,6 @@ export class CollisionSystem implements System {
|
||||
delta: number,
|
||||
): boolean {
|
||||
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(
|
||||
MAX_STEPS,
|
||||
Math.max(1, Math.ceil(Math.abs(delta) / stepLimit(own, obstacles, axis))),
|
||||
|
||||
@@ -66,12 +66,10 @@ export class Input {
|
||||
return this.down.has(key);
|
||||
}
|
||||
|
||||
/** Keys that went down since the last frame. */
|
||||
get justPressed(): ReadonlySet<string> {
|
||||
return this.pressed;
|
||||
}
|
||||
|
||||
/** Keys that went up since the last frame. */
|
||||
get justReleased(): ReadonlySet<string> {
|
||||
return this.released;
|
||||
}
|
||||
|
||||
@@ -5,11 +5,9 @@
|
||||
*/
|
||||
|
||||
export interface TextEditTarget {
|
||||
/** Called whenever value or selection changed. */
|
||||
onBridgeInput(value: string, selectionStart: number, selectionEnd: number): void;
|
||||
/** Called for navigation keys (Enter, Escape, Tab, arrows …). Return true to consume. */
|
||||
onBridgeKey(event: KeyboardEvent): boolean;
|
||||
/** The bridge lost DOM focus. */
|
||||
onBridgeBlur(): void;
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,6 @@ export abstract class Widget extends Container {
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
// Focus hooks – called by the FocusManager.
|
||||
onFocus(): void {
|
||||
this.focused = true;
|
||||
this.invalidate();
|
||||
|
||||
+2
-7
@@ -17,13 +17,8 @@ interface LastPress {
|
||||
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
|
||||
* clicks only for the `click`/`pointertap` it synthesises and leaves `detail` at 0 on
|
||||
* `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.
|
||||
* Press count in a row (2 on a double-click), since Pixi leaves `detail` at 0 on `pointerdown`. `key`
|
||||
* must survive a rebuild of its widget: the first click's selection can recreate the list rows.
|
||||
*/
|
||||
export function pressCount(e: FederatedPointerEvent, key: unknown): number {
|
||||
const gap = e.timeStamp - last.time;
|
||||
|
||||
+2
-4
@@ -22,10 +22,8 @@ let session: Session | null = null;
|
||||
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
|
||||
* source starts it and forgets it; whoever is under the pointer reads `activeDrag()` and, on
|
||||
* `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.
|
||||
* A drag that left its source widget; whoever is under the pointer reads `activeDrag()` on `pointerup`.
|
||||
* The session ends via a stage listener, which fires last, so a drop target still sees the payload.
|
||||
*/
|
||||
export function startDrag(payload: DragPayload, label: string): void {
|
||||
endDrag();
|
||||
|
||||
@@ -50,3 +50,10 @@ export function drawGrid(g: Graphics, options: GridOptions): void {
|
||||
}
|
||||
|
||||
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
@@ -2,7 +2,7 @@ export { Widget, Box, type Size } from './Widget';
|
||||
export { HStack, VStack, Spacer, Stack, type StackOptions } from './layout/Stack';
|
||||
export { Theme, textStyle, type TextStyleToken } from './Theme';
|
||||
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 { activeDrag, type DragPayload, endDrag, onDragChange, startDrag } from './drag';
|
||||
export { Label, truncate } from './widgets/Label';
|
||||
|
||||
@@ -11,10 +11,8 @@ export interface MatrixValues {
|
||||
}
|
||||
|
||||
/**
|
||||
* The `matrix` a Graphics fill needs so that only `rect` of a `sourceWidth`×`sourceHeight` image
|
||||
* covers the filled shape. A Pixi v8 Graphics fill ignores a `Texture`'s `frame` and draws the
|
||||
* 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.
|
||||
* Pixi v8 Graphics fills ignore a `Texture`'s `frame`, so a frame must be a fill matrix. Pixi inverts it
|
||||
* against the shape's bounds, hence the inverse of "unit square onto the frame".
|
||||
*/
|
||||
export function frameFillMatrix(
|
||||
rect: FrameRect,
|
||||
|
||||
@@ -121,8 +121,7 @@ describe('assetRefChoices', () => {
|
||||
});
|
||||
|
||||
describe('node asset params', () => {
|
||||
// Guards against the old second notion of an asset reference: an enum with empty options
|
||||
// that the graph inspector filled in.
|
||||
// An asset param uses the asset kind, never an enum with empty options filled in by the inspector.
|
||||
it('use the asset kind and offer every placeable asset', () => {
|
||||
const params = BUILT_IN_NODES.flatMap((def) => def.params).filter((p) => p.key === 'asset');
|
||||
expect(params.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -217,8 +217,7 @@ describe('move and slide', () => {
|
||||
if (i > 60) expect(blockedY).toBe(true);
|
||||
}
|
||||
expect(crateA.y).toBeGreaterThan(240 - SKIN);
|
||||
// A step larger than the obstacle used to jump clean over it: after the move nothing
|
||||
// overlapped any more, so nothing pushed back and the mover was through the wall.
|
||||
// A step larger than the obstacle must not jump over it: past the wall nothing overlaps to push back.
|
||||
crateA.y = 0;
|
||||
expect(collisions.moveAndSlide(world, crateA, 0, 5000)).toEqual({ blockedX: false, blockedY: true });
|
||||
expect(crateA.y).toBeCloseTo(240, 6);
|
||||
|
||||
@@ -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. Tetris carries the same
|
||||
* state today as eight parallel ports wired by hand, which is the pain this replaces.
|
||||
*/
|
||||
/** The point of a struct: a bundle crosses a behavior boundary on ONE edge. */
|
||||
describe('a struct travelling through a nested behavior', () => {
|
||||
it('goes in as one port and comes back out as its parts', () => {
|
||||
const fixture = makeProject();
|
||||
|
||||
@@ -4,9 +4,9 @@ import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'
|
||||
import { sameValue, textOf } from '../../src/core/model/Properties';
|
||||
import { findAsset, objectHolders, type Project } from '../../src/core/model/Project';
|
||||
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 { asEntity, asStruct, writeTyped } from '../../src/core/graph';
|
||||
import { asBoolean, asEntity, asStruct, writeTyped } from '../../src/core/graph';
|
||||
import { fakeEntity, makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers';
|
||||
|
||||
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', () => {
|
||||
// Instance edits are read from an untyped slot: the old filter kept number/string/boolean and
|
||||
// dropped everything else, which would have swallowed the list without a word.
|
||||
// Instance edits are read from an untyped slot, so a primitives-only filter would drop the list silently.
|
||||
const loaded = deserializeProject(
|
||||
{
|
||||
name: 'P',
|
||||
@@ -139,3 +138,14 @@ describe('a struct as a value', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -261,6 +261,14 @@ describe('declared ports', () => {
|
||||
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', () => {
|
||||
const fixture = makeProject();
|
||||
const callee = doubler(fixture);
|
||||
|
||||
@@ -55,7 +55,7 @@ const pointer = (overrides: Partial<NodeContext['pointer']>): Partial<NodeContex
|
||||
});
|
||||
|
||||
const CASES: Record<string, Case[]> = {
|
||||
// --- flow ------------------------------------------------------------------------------------
|
||||
// flow
|
||||
|
||||
Branch: [
|
||||
{ name: 'true takes then', inputs: { condition: true }, next: 'then' },
|
||||
@@ -103,7 +103,7 @@ const CASES: Record<string, Case[]> = {
|
||||
},
|
||||
],
|
||||
|
||||
// --- variables -------------------------------------------------------------------------------
|
||||
// variables
|
||||
|
||||
GetVariable: [
|
||||
{ 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 } }],
|
||||
Owner: [
|
||||
@@ -237,7 +237,7 @@ const CASES: Record<string, Case[]> = {
|
||||
},
|
||||
],
|
||||
|
||||
// --- literals and math -------------------------------------------------------------------------
|
||||
// literals and math
|
||||
|
||||
Number: [
|
||||
{ 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: [
|
||||
{ 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' },
|
||||
},
|
||||
],
|
||||
// --- lists -----------------------------------------------------------------------------------
|
||||
// lists
|
||||
|
||||
List: [
|
||||
{ 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 } },
|
||||
],
|
||||
|
||||
// --- vectors and input --------------------------------------------------------------------------
|
||||
// vectors and input
|
||||
|
||||
Length: [
|
||||
{ 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: [
|
||||
{
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
deviatesFromTemplate,
|
||||
inheritedValue,
|
||||
isModified,
|
||||
isOverridden,
|
||||
resolveAssetProps,
|
||||
resolveSceneObjectProps,
|
||||
} from '../../src/core/properties/resolve';
|
||||
@@ -70,8 +69,6 @@ describe('resolve', () => {
|
||||
|
||||
object.overrides.color = 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);
|
||||
});
|
||||
|
||||
@@ -92,7 +89,6 @@ describe('resolve', () => {
|
||||
expect(deviatesFromAsset(object, sprite, 'width')).toBe(false); // no override at all
|
||||
|
||||
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);
|
||||
|
||||
object.overrides.width = 300;
|
||||
|
||||
@@ -68,7 +68,7 @@ describe('cycle-safe walks in the runtime', () => {
|
||||
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', () => {
|
||||
const f = makeSpriteScene();
|
||||
const moverId = place(f, f.spriteId, { x: 0, y: 0 });
|
||||
|
||||
@@ -124,10 +124,7 @@ describe('the address a list is edited through', () => {
|
||||
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,
|
||||
* so it is the same slot – before this it offered a number field instead.
|
||||
*/
|
||||
/** An unconnected `list` input carries its inline value in the same params bag a setting does. */
|
||||
it('reaches the inline value of an unconnected list input', () => {
|
||||
const add = new AddNodeCommand(f.behavior.id, 'Count', 0, 0);
|
||||
expect(f.state.execute(add)).toBe(true);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
/**
|
||||
* A variable is drawn by one builder on the asset that declares it and on a placed object, and
|
||||
* every row of the inspector shares one column grid. The asset may rename and reshape; a placed
|
||||
* 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.
|
||||
* One builder draws a variable on its asset (renamable) and on a placed object (names are labels).
|
||||
* `NumberField extends TextField`, so the tests go by the name's widget rather than counting by type.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createEmptyProject } from '../../src/core/model/Project';
|
||||
|
||||
@@ -39,8 +39,7 @@ describe('formatNumber', () => {
|
||||
});
|
||||
|
||||
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
|
||||
// committing that text wrote the rounded value back into the project.
|
||||
// A step-1 field showing 100.234 as "100" would write the rounded value back on commit.
|
||||
expect(formatNumber(100.234)).toBe('100.234');
|
||||
expect(formatNumber(0.001)).toBe('0.001');
|
||||
});
|
||||
@@ -71,8 +70,7 @@ describe('formatNumber', () => {
|
||||
|
||||
describe('roundTo', () => {
|
||||
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
|
||||
// "641" while the project still held 640.5.
|
||||
// The field rounds, not just formats, so a `decimals: 0` field never shows "641" over a stored 640.5.
|
||||
expect(roundTo(640.5, 0)).toBe(641);
|
||||
expect(roundTo(32.4, 0)).toBe(32);
|
||||
expect(roundTo(1.2345)).toBe(1.234);
|
||||
@@ -90,8 +88,7 @@ describe('roundTo', () => {
|
||||
|
||||
describe('snapToStep', () => {
|
||||
it('lands on whole numbers for a step-1 field', () => {
|
||||
// The bug: a scrub added fractional device pixels to an already fractional value, so
|
||||
// dragging X produced values like 100.667 instead of 101.
|
||||
// A scrub adds fractional device pixels; without snapping X would land on 100.667 instead of 101.
|
||||
expect(snapToStep(100.667, 1)).toBe(101);
|
||||
expect(snapToStep(100.333, 1)).toBe(100);
|
||||
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
|
||||
@@ -121,7 +121,6 @@ export async function inspector(t) {
|
||||
check('viewport drag moved the red crate', o1Now.x > -150, JSON.stringify(o1Now));
|
||||
await shot('06-dragged');
|
||||
|
||||
// 5. Autosave → server.
|
||||
await sleep(1800);
|
||||
const saved = await api(`projects/${projectId}`);
|
||||
const renamed = sceneOf(saved).objects.find((o) => o.id === 'o2');
|
||||
|
||||
+24
-3
@@ -8,6 +8,7 @@ export async function graph(t) {
|
||||
sleep,
|
||||
evaluate,
|
||||
json,
|
||||
find,
|
||||
shot,
|
||||
click,
|
||||
clickText,
|
||||
@@ -70,9 +71,8 @@ export async function graph(t) {
|
||||
`${axisUndone.x},${axisUndone.y}`,
|
||||
);
|
||||
|
||||
// 13b. Regression: typing a query and then *clicking* a row must add exactly one node.
|
||||
// (The search field's blur-commit once picked the first match and the row click picked
|
||||
// it again → two nodes.)
|
||||
// 13b. Typing a query and then *clicking* a row adds exactly one node: the search field's
|
||||
// blur-commit must not pick the first match as well.
|
||||
const gcenter = await json('__gm.graph.center()');
|
||||
const beforeClickPick = await graphCounts();
|
||||
await click(gcenter.x + 250, gcenter.y + 250, 'right');
|
||||
@@ -742,6 +742,27 @@ export async function graph(t) {
|
||||
await undo();
|
||||
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
|
||||
// link lands on it until it is expanded, and the state is saved with the project.
|
||||
await select({ kind: 'asset', assetId: 'mover' });
|
||||
|
||||
+22
-13
@@ -2,13 +2,8 @@ import { instanceChild } from '../lib/builders.mjs';
|
||||
import { same } from '../lib/harness.mjs';
|
||||
|
||||
/**
|
||||
* Multi-selection and placed containers.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export async function containers(t) {
|
||||
const {
|
||||
@@ -16,6 +11,7 @@ export async function containers(t) {
|
||||
sleep,
|
||||
evaluate,
|
||||
json,
|
||||
has,
|
||||
shot,
|
||||
click,
|
||||
clickText,
|
||||
@@ -41,7 +37,6 @@ export async function containers(t) {
|
||||
projectId,
|
||||
} = t;
|
||||
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. */
|
||||
const setField = async (label, value) => {
|
||||
const row = await locate(label);
|
||||
@@ -129,10 +124,8 @@ export async function containers(t) {
|
||||
await undo();
|
||||
check('undo restores all of them', (await objects()).length === listedBeforeDelete);
|
||||
|
||||
// 16d. Create container: both crates, with their colliders and the Mover, become the content
|
||||
// of a new container asset and *leave the scene*. The scene stores one object in their
|
||||
// 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`.
|
||||
// 16d. Create container: both crates, their colliders and the Mover *leave the scene*; one stored
|
||||
// container object stands in their stead and the five are resolved from its template.
|
||||
const assetsBefore = (await json('__gm.state.project.assets')).length;
|
||||
const storedBefore = (await storedObjects()).length;
|
||||
const listedBefore = (await objects()).length;
|
||||
@@ -306,7 +299,23 @@ export async function containers(t) {
|
||||
await selectObject(redA);
|
||||
check('the inspector offers no collider for resolved content', !(await has('+ Add collider')));
|
||||
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);
|
||||
let noticeAfter = await notice();
|
||||
await press('Delete');
|
||||
|
||||
@@ -5,6 +5,7 @@ export async function textAndEnabled(t) {
|
||||
sleep,
|
||||
evaluate,
|
||||
json,
|
||||
has,
|
||||
shot,
|
||||
click,
|
||||
clickText,
|
||||
@@ -16,7 +17,6 @@ export async function textAndEnabled(t) {
|
||||
stop,
|
||||
typeText,
|
||||
} = 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.
|
||||
await clickText('+');
|
||||
|
||||
@@ -43,10 +43,8 @@ export async function menus(t) {
|
||||
await shot('31-long-parent-menu');
|
||||
|
||||
// 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
|
||||
// find an inspector field behind the menu instead – one that never moves. Which filler is asked
|
||||
// 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.
|
||||
// Anchored on a filler row read off the open menu: "None" could match an inspector field behind it,
|
||||
// and which fillers are drawn depends on what earlier steps left in the scene.
|
||||
const visibleFillers = (await json('__gm.overlayTexts()')).filter((e) => e.startsWith('Filler '));
|
||||
const anchor = visibleFillers[visibleFillers.length - 1];
|
||||
check('the picker lists the filler objects', Boolean(anchor), JSON.stringify(visibleFillers));
|
||||
|
||||
@@ -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
|
||||
* 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 }));
|
||||
})()`;
|
||||
|
||||
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) {
|
||||
const { check, sleep, json, evaluate, shot, mutate, press } = t;
|
||||
const { api, click, clickText, doubleClick, locate, undo } = t;
|
||||
@@ -143,8 +138,7 @@ export async function textures(t) {
|
||||
await shot('32-textured-sprite');
|
||||
|
||||
// 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
|
||||
// GPU sampler behind it that used to survive the change.
|
||||
// screen rather than off `scaleMode`, because the GPU sampler can miss a correctly assigned value.
|
||||
const enlarge = (w, h) => `
|
||||
const sprite = __gm.state.project.assets.find((a) => a.id === 'tex-sprite');
|
||||
sprite.props.width = ${w};
|
||||
@@ -162,10 +156,8 @@ export async function textures(t) {
|
||||
}
|
||||
return points;
|
||||
};
|
||||
// Read the pixels first, then ask the driver what it actually has: a failure then says by
|
||||
// itself whether the model never switched, the switch missed the GPU sampler, or the pixels
|
||||
// were measured wrong. This check has fallen over sporadically without any of the three ever
|
||||
// being distinguishable from the message.
|
||||
// Pixels plus what the driver has, so a failure says whether the model, the GPU sampler or the
|
||||
// measurement is wrong.
|
||||
const sample = async (value) => {
|
||||
await mutate(
|
||||
`__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');
|
||||
// The drift — that the picture actually changed — is what makes this a test of the switch.
|
||||
// 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:
|
||||
// The drift proves the switch: with a hung one, "some pixel is blended" alone would pass. Measured:
|
||||
// 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);
|
||||
check(
|
||||
@@ -219,10 +209,8 @@ export async function textures(t) {
|
||||
);
|
||||
}
|
||||
|
||||
// 19e. "+ → Texture" offers the pictures the server already holds instead of the machine's
|
||||
// file system. Nothing collects unreferenced files, so a picture one project dropped is
|
||||
// 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.
|
||||
// 19e. "+ → Texture" offers the pictures the server already holds, unreferenced ones included:
|
||||
// paste a second picture, undo the asset (the file stays), then pick that file in the dialog.
|
||||
await evaluate(PASTE_SPARE);
|
||||
await sleep(2500);
|
||||
const two = await json("__gm.state.project.assets.filter((a) => a.type === 'texture').map((a) => a.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.
|
||||
*
|
||||
@@ -5,13 +7,6 @@
|
||||
* 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. */
|
||||
const PALETTE = {
|
||||
red: '#e63c3c',
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
/**
|
||||
* Labels must be shortened to fit their control, not written past it. Runs last because it
|
||||
* renames 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.
|
||||
* Labels must be shortened to fit their control, not written past it. Runs last because it renames
|
||||
* assets to absurd lengths to provoke the case.
|
||||
*/
|
||||
|
||||
/** 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)`);
|
||||
await sleep(600);
|
||||
|
||||
// 21. The pickers keep the width they were given. `width` used to set only a minimum, so a
|
||||
// picker grew with whatever name it happened to show and the header reflowed on load.
|
||||
// 21. The pickers keep the width they were given instead of growing with the name they show.
|
||||
const after = await pickerWidths();
|
||||
for (const tab of ['graph', 'texture', 'container']) {
|
||||
check(
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
/**
|
||||
* The ↔ / ↕ buttons beside a sprite's Width and Height: fit that side to the proportions of the
|
||||
* picture the sprite wears, set both sides to its pixel size, and not exist at all while it wears
|
||||
* 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 ↔ / ↕ buttons beside a sprite's Width and Height (absent without a picture). Runs on the imported
|
||||
* 64 × 48 sheet plus a frame of a different shape, so "sheet or frame?" shows in the numbers.
|
||||
*/
|
||||
|
||||
/** The rows are read as numbers, so a float from a division needs a tolerance. */
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { instanceChild } from '../lib/builders.mjs';
|
||||
|
||||
/**
|
||||
* The Container tab: editing a container's template with the scene view's own tools.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const NAME = 'Crate pair';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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
|
||||
@@ -49,7 +50,6 @@ export async function editorRun(t) {
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
const scene = __gm.state.activeScene;
|
||||
const proto = scene.objects[0];
|
||||
const asset = structuredClone(p.assets.find((a) => a.id === 'mover'));
|
||||
Object.assign(asset, {
|
||||
id: 'drifter',
|
||||
@@ -59,20 +59,8 @@ export async function editorRun(t) {
|
||||
graph: ${JSON.stringify(driftGraph)},
|
||||
});
|
||||
p.assets.push(asset);
|
||||
const make = (id, assetId, parentId, y) => {
|
||||
const o = structuredClone(proto);
|
||||
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));
|
||||
${MAKE_OBJECT}
|
||||
scene.objects.push(make('er-box', 'crate', '', 400, 'Drift box'), make('er-logic', 'drifter', 'er-box', 0, 'Drifter'));
|
||||
`);
|
||||
await sleep(400);
|
||||
|
||||
@@ -166,10 +154,8 @@ export async function editorRun(t) {
|
||||
`${JSON.stringify(stopped)} x=${stoppedAt}`,
|
||||
);
|
||||
|
||||
// 18f. Undo covers the two clicks on the switch – but not a millimetre of what the behavior did
|
||||
// while it was on. That is the price of editing the model directly, better stated here than
|
||||
// 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.
|
||||
// 18f. Undo covers the two clicks on the switch but nothing the behavior did while on. The first
|
||||
// undo re-enables it, so it briefly runs again: undo rewinds the switch, never the clock.
|
||||
await undo();
|
||||
await undo();
|
||||
await sleep(300);
|
||||
@@ -188,20 +174,7 @@ export async function editorRun(t) {
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
const scene = __gm.state.activeScene;
|
||||
const proto = scene.objects[0];
|
||||
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;
|
||||
};
|
||||
${MAKE_OBJECT}
|
||||
p.assets.push({
|
||||
id: 'er-level',
|
||||
type: 'container',
|
||||
@@ -246,26 +219,13 @@ export async function editorRun(t) {
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
const scene = __gm.state.activeScene;
|
||||
const proto = scene.objects[0];
|
||||
const asset = structuredClone(p.assets.find((a) => a.id === 'drifter'));
|
||||
Object.assign(asset, { id: 'walker', name: 'Walker', graph: ${JSON.stringify(walkGraph)} });
|
||||
p.assets.push(asset);
|
||||
const make = (id, assetId, parentId, y, overrides) => {
|
||||
const o = structuredClone(proto);
|
||||
Object.assign(o, {
|
||||
id,
|
||||
assetId,
|
||||
name: '',
|
||||
position: { x: 0, y, z: 0 },
|
||||
parentId,
|
||||
overrides,
|
||||
instanceEdits: {},
|
||||
});
|
||||
return o;
|
||||
};
|
||||
${MAKE_OBJECT}
|
||||
scene.objects.push(
|
||||
make('er-walkbox', 'crate', '', 1200, {}),
|
||||
make('er-walker', 'walker', 'er-walkbox', 0, { runInEditor: true }),
|
||||
make('er-walkbox', 'crate', '', 1200),
|
||||
Object.assign(make('er-walker', 'walker', 'er-walkbox', 0), { overrides: { runInEditor: true } }),
|
||||
);
|
||||
`);
|
||||
await sleep(600);
|
||||
|
||||
+5
-10
@@ -2,12 +2,8 @@ import { edge, node } from '../lib/builders.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
|
||||
* arrives while it is held is dropped instead of firing all at once on resume.
|
||||
*
|
||||
* 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.
|
||||
* Pausing freezes world and clock and drops input instead of replaying it on resume. Brings its own
|
||||
* self-drifting box, since the fixture's Mover only moves on arrow keys and a pause would look like idling.
|
||||
*/
|
||||
|
||||
const DRIFT_PER_SECOND = 100;
|
||||
@@ -35,7 +31,7 @@ const pauseGraph = {
|
||||
};
|
||||
|
||||
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 boxX = async () => (await entity('p-box')).x;
|
||||
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
|
||||
// the hook, which answers with null.
|
||||
const labelShown = (text) => json(`__gm.locate(${JSON.stringify(text)}) !== null`);
|
||||
check(
|
||||
'the button offers to resume while it is held, and no longer to pause',
|
||||
(await labelShown('▶ Resume')) === true && (await labelShown('❚❚ Pause')) === false,
|
||||
`resume=${await labelShown('▶ Resume')} pause=${await labelShown('❚❚ Pause')}`,
|
||||
(await has('▶ Resume')) === true && (await has('❚❚ Pause')) === false,
|
||||
`resume=${await has('▶ Resume')} pause=${await has('❚❚ Pause')}`,
|
||||
);
|
||||
await shot('36-paused');
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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
|
||||
@@ -72,7 +73,6 @@ export async function nestedBehavior(t) {
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
const scene = __gm.state.activeScene;
|
||||
const proto = scene.objects[0];
|
||||
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
|
||||
// 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-caller', 'Caller', ${JSON.stringify(callerGraph)});
|
||||
behavior('nb-empty', 'Empty', { nodes: [], edges: [] });
|
||||
const make = (id, assetId, parentId, name) => {
|
||||
const o = structuredClone(proto);
|
||||
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'));
|
||||
${MAKE_OBJECT}
|
||||
scene.objects.push(make('nb-box', 'crate', '', 700, 'Nested box'), make('nb-logic', 'nb-caller', 'nb-box', 700, 'Caller'));
|
||||
`);
|
||||
await sleep(400);
|
||||
|
||||
@@ -235,8 +227,7 @@ export async function nestedBehavior(t) {
|
||||
`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
|
||||
// says why nothing happens instead of sitting there looking healthy.
|
||||
// 19h. A behavior with no start node can still be added, but says why nothing happens.
|
||||
const beforeEmpty = await nodeIds();
|
||||
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);
|
||||
@@ -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)}`,
|
||||
);
|
||||
|
||||
// 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')`);
|
||||
check('the graph shown is still the caller', (await evaluate('__gm.graph.current()')) === 'nb-caller');
|
||||
await t.doubleClick(callNode.x, callNode.y);
|
||||
|
||||
@@ -2,12 +2,8 @@ import { edge, node } from '../lib/builders.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
|
||||
* interval, which is what lets a Tetris piece slide sideways on one press.
|
||||
*
|
||||
* 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.
|
||||
* The auto-repeat of `On key down`. A plain and a repeating node on the same key are measured in one
|
||||
* hold, so "it repeated" cannot be confused with "the key arrived more often than expected".
|
||||
*/
|
||||
|
||||
const PLAIN = 'plain hit';
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
* tab, and there a row per item edits, adds and removes them with the inspector's own widgets.
|
||||
*/
|
||||
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?". */
|
||||
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
|
||||
// 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');
|
||||
await shot('01-count-button');
|
||||
|
||||
// 2. Clicking it opens the List tab on that very list.
|
||||
await click(button.x, button.y);
|
||||
await sleep(200);
|
||||
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),
|
||||
);
|
||||
|
||||
// 5. Adding and removing whole items.
|
||||
await clickText('+ Item');
|
||||
await sleep(200);
|
||||
info = await json('__gm.list.info()');
|
||||
@@ -249,8 +246,8 @@ export async function listTab(t) {
|
||||
JSON.stringify(await cellsOf()),
|
||||
);
|
||||
|
||||
// 9. A node's unconnected `list` input carries its value in the very same params bag a setting
|
||||
// does, so it is a count button too – before this it offered a number field.
|
||||
// 9. A node's unconnected `list` input lives in the same params bag as a setting, so it is a count
|
||||
// button too.
|
||||
await mutate(`
|
||||
const a = __gm.state.project.assets.find((x) => x.id === 'celly');
|
||||
a.graph.nodes.push({ id: 'counter', type: 'Count', x: 40, y: 40, params: {} });
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
* same row, and writing one field keeps the others – the trap a shared snapshot would fall into.
|
||||
*/
|
||||
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?". */
|
||||
const find = (text) => json(`__gm.locate(${JSON.stringify(text)}, false)`);
|
||||
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. */
|
||||
const leftsOf = (labels) =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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
|
||||
@@ -56,7 +57,6 @@ export async function variableNodes(t) {
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
const scene = __gm.state.activeScene;
|
||||
const proto = scene.objects[0];
|
||||
const mover = p.assets.find((a) => a.id === 'mover');
|
||||
const a = structuredClone(mover);
|
||||
Object.assign(a, {
|
||||
@@ -67,16 +67,8 @@ export async function variableNodes(t) {
|
||||
graph: ${JSON.stringify(logicGraph)},
|
||||
});
|
||||
p.assets.unshift(a);
|
||||
const make = (id, assetId, parentId, name) => {
|
||||
const o = structuredClone(proto);
|
||||
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'));
|
||||
${MAKE_OBJECT}
|
||||
scene.objects.push(make('vp-box', 'crate', '', 900, 'Speedy box'), make('vp-brain', 'vp-logic', 'vp-box', 900, 'Speeds'));
|
||||
`);
|
||||
await sleep(400);
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
/**
|
||||
* Steps 17–20 each need the same throwaway rig in the fixture scene: a behavior asset cloned off
|
||||
* `mover` carrying the step's own 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.
|
||||
* The throwaway rig steps 17–20 share in the fixture scene: a behavior asset cloned off `mover` with the
|
||||
* step's graph, a crate to hold it, and a logic child that runs it.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -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
|
||||
* and returns what is left – so a step's cleanup cannot fall out of step with what it made.
|
||||
*
|
||||
* `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`.
|
||||
* Returns the created ids plus `remove()`, so a step's cleanup matches what it made. `front` puts the
|
||||
* asset at the head of the list: a row appended at the end scrolls out of `dragAsset`'s reach.
|
||||
*/
|
||||
export async function scaffoldBehavior(t, options) {
|
||||
const {
|
||||
|
||||
+7
-18
@@ -1,13 +1,6 @@
|
||||
/**
|
||||
* Where the headless browser for a check run lives.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
import { spawn, execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -21,10 +14,8 @@ const FLAGS = [
|
||||
'--headless',
|
||||
'--hide-scrollbars',
|
||||
/**
|
||||
* Measured on this host (idle editor page, 20 s): software WebGL costs 373% of a core at 8 fps,
|
||||
* the Intel HD 630 via ANGLE/Vulkan costs 69% at 56 fps. There is deliberately no software
|
||||
* 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.
|
||||
* Measured (idle editor, 20 s): software WebGL 373% of a core at 8 fps, HD 630 via ANGLE/Vulkan 69% at
|
||||
* 56 fps; no software fallback on purpose. `--use-angle=gl` and `--use-gl=egl` get no WebGL context at all.
|
||||
*/
|
||||
'--use-angle=vulkan',
|
||||
'--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');
|
||||
// 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' });
|
||||
|
||||
const proc = spawn(
|
||||
@@ -101,10 +92,8 @@ export function launchBrowser({ port, width, height }) {
|
||||
return {
|
||||
proc,
|
||||
/**
|
||||
* The launcher notices a finished run on its own, but only after its idle window – a browser
|
||||
* lingering a minute on someone else's desktop is rude, so end it now and let that window be
|
||||
* 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.
|
||||
* Ends the browser now rather than after the launcher's idle window. Kill only this pid: the host is
|
||||
* someone's desktop, and killing by name would close their browser.
|
||||
*/
|
||||
stop: () => {
|
||||
const pidFile = `$env:TEMP\\gm-browser-${port}.pid`;
|
||||
|
||||
@@ -26,7 +26,6 @@ export const sceneObject = (
|
||||
*/
|
||||
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) => ({
|
||||
id,
|
||||
from: { node: from, port: fromPort },
|
||||
|
||||
+23
-26
@@ -1,18 +1,7 @@
|
||||
/**
|
||||
* Headless-Chromium harness for the check scripts (e2e.mjs, smoke-jumprun.mjs).
|
||||
*
|
||||
* Starts chromium with remote debugging, opens a CDP session and hands a set of helpers to
|
||||
* 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.
|
||||
* 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
|
||||
* the summary line is missing, since chromium can die halfway under memory pressure.
|
||||
*/
|
||||
import { writeFileSync } from 'node:fs';
|
||||
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). */
|
||||
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. */
|
||||
const KEYS = {
|
||||
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. */
|
||||
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 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`);
|
||||
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. */
|
||||
const BUTTON_MASK = { left: 1, right: 2, middle: 4 };
|
||||
/**
|
||||
* Drags with the left button unless told otherwise. The fifth argument is either a step count
|
||||
* (its long-standing meaning) or `{ steps, button, modifiers }`.
|
||||
*/
|
||||
/** The fifth argument is a step count or `{ steps, button, modifiers }`. */
|
||||
const drag = async (x1, y1, x2, y2, options = {}) => {
|
||||
const {
|
||||
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
|
||||
* since the context menu lost its entry. By id, not by text: the container picker shows the
|
||||
* 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.
|
||||
* By id, not text: the container picker shows the same name as the row. Press and moves are separate
|
||||
* events because the panel starts a drag only once the pointer has travelled.
|
||||
*/
|
||||
const dragAsset = async (assetId, x, y, steps = 8) => {
|
||||
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);
|
||||
};
|
||||
/**
|
||||
* Typed per character to exercise the per-keystroke paths (live preview, search filters), then
|
||||
* verified: a partial type is otherwise indistinguishable from a wrong value – a run once
|
||||
* committed x=3 for "300" and the check blamed the value, not the lost characters.
|
||||
* Typed per character to exercise per-keystroke paths, then verified: a partial type is otherwise
|
||||
* indistinguishable from a wrong value.
|
||||
*/
|
||||
const typeText = async (text) => {
|
||||
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,
|
||||
evaluate,
|
||||
json,
|
||||
find,
|
||||
has,
|
||||
locate,
|
||||
shot,
|
||||
mouse,
|
||||
|
||||
+5
-27
@@ -1,30 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Writes the second example game: Tetris, built only from assets and node graphs.
|
||||
*
|
||||
* The board is a list of 200 numbers, one per cell: -1 for empty, otherwise the shape that came to
|
||||
* rest there. Reading a cell is `Item at(grid, row·10 + column)` and clearing a full line is an
|
||||
* 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.
|
||||
* 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
|
||||
* and calls stateless behaviors as nodes, which lets `Fits` serve turning, moving and falling alike.
|
||||
* Board and piece are a fixed pool of named objects (`c0…c199`, `p0…p3`); nothing spawns while playing.
|
||||
*
|
||||
* 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
|
||||
* piece's own position. Used by the fit test and by the drawing, which is the whole point of it
|
||||
* being a behavior – the two used to be the same twenty nodes twice.
|
||||
* piece's own position. A behavior so the fit test and the drawing share it.
|
||||
*/
|
||||
function pieceCellGraph() {
|
||||
const g = graphBuilder();
|
||||
|
||||
@@ -47,12 +47,12 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
|
||||
stop,
|
||||
navigate,
|
||||
exceptions,
|
||||
has,
|
||||
} = t;
|
||||
|
||||
/** Variables of the "Game" behavior instance, live from the running world. */
|
||||
const gameVars = async () => (await entity('ogame')).props;
|
||||
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. */
|
||||
const clickObject = async (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 sleep(300);
|
||||
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 title = await entity('T');
|
||||
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
|
||||
// depends on machine speed – a faster host collects two.
|
||||
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}`);
|
||||
await shot('05-collected');
|
||||
|
||||
@@ -183,7 +183,7 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
|
||||
await sleep(300);
|
||||
check(
|
||||
'collecting everything wins the game',
|
||||
(await visible('YOU WIN!')) &&
|
||||
(await has('YOU WIN!')) &&
|
||||
(await entity('W')).props.enabled === true &&
|
||||
(await entity('L')).props.enabled === false,
|
||||
);
|
||||
@@ -194,7 +194,7 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
|
||||
await sleep(1200);
|
||||
check(
|
||||
'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`);
|
||||
|
||||
@@ -205,7 +205,7 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
|
||||
await sleep(600);
|
||||
check(
|
||||
'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');
|
||||
|
||||
|
||||
Reference in new issue
Block a user