From 1eb28314cfd639ff903b0f1de4217d9f658895c6 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 8 Sep 2026 08:37:22 +0200 Subject: [PATCH] Refactoring: one file per node, consistent comments, shared helpers, split large modules - Graph: nodes.ts split into nodes//.ts (62 files) with shared.ts factories and index.ts registry; NodeContext/values/NodeRegistry separated, writeTyped() replaces duplicated typed writes - Comments: one style (JSDoc on non-obvious exports, // for decisions and traps), wrong/stale comments fixed or removed, ids.ts now real 10 base-36 chars - Core: chainOf, shared worldPosition/editorPosition core, commands/require.ts, commands/naming.ts, util/list.ts, setBagValue/toPosition/propDef; dead code removed (isBehaviorAsset, SCENE_OBJECT_SCHEMA, CommandStack.subscribe, Input.axis, setMode, titleText, World.worldPosition, unused ctor params) - Editor: Inspector split into inspector/ sections, App.ts into Editor.ts + debugHook.ts + shortcuts.ts, Viewport into items.ts + symbols.ts, shared "Add collider" action, unified type labels/swatches - UI toolkit: Widget.trackHover/onPointerRelease, drawControlBox, math helpers, Popup.openBelow, Menu/Dialog/ColorPicker/ListRow in own files, named Theme sizes - Tools: seed-demo/smoke-demo and demo-check project removed, tools/lib (harness, projects, builders), e2e.mjs as runner over tools/e2e/*.mjs sections - Tests: tests/helpers.ts replaces copied fixtures and NodeContext doubles Co-Authored-By: Claude Fable 5.1 --- README.md | 57 +- src/core/commands/Command.ts | 5 +- src/core/commands/CommandStack.ts | 17 +- src/core/commands/applyCommands.ts | 45 +- src/core/commands/assetCommands.ts | 53 +- src/core/commands/colliderCommands.ts | 17 +- src/core/commands/containerCommands.ts | 26 +- src/core/commands/graphCommands.ts | 66 +- src/core/commands/naming.ts | 21 + src/core/commands/projectCommands.ts | 10 - src/core/commands/require.ts | 49 ++ src/core/commands/sceneObjectCommands.ts | 136 +-- src/core/commands/variableCommands.ts | 39 +- src/core/graph/GraphInterpreter.ts | 4 +- src/core/graph/NodeContext.ts | 98 +++ src/core/graph/NodeRegistry.ts | 133 +-- src/core/graph/index.ts | 2 + src/core/graph/nodes.ts | 572 ------------ src/core/graph/nodes/action/Destroy.ts | 18 + src/core/graph/nodes/action/Log.ts | 17 + src/core/graph/nodes/action/Move.ts | 27 + src/core/graph/nodes/action/RestartGame.ts | 16 + src/core/graph/nodes/action/SetEnabled.ts | 20 + src/core/graph/nodes/action/SetPosition.ts | 21 + src/core/graph/nodes/action/SetProperty.ts | 19 + src/core/graph/nodes/action/SetText.ts | 18 + src/core/graph/nodes/action/Spawn.ts | 22 + src/core/graph/nodes/action/StopGame.ts | 16 + src/core/graph/nodes/data/Abs.ts | 3 + src/core/graph/nodes/data/Add.ts | 3 + src/core/graph/nodes/data/And.ts | 3 + src/core/graph/nodes/data/AxisInput.ts | 24 + src/core/graph/nodes/data/Boolean.ts | 14 + src/core/graph/nodes/data/Ceil.ts | 3 + src/core/graph/nodes/data/Clamp.ts | 16 + src/core/graph/nodes/data/Compare.ts | 34 + src/core/graph/nodes/data/Distance.ts | 18 + src/core/graph/nodes/data/Divide.ts | 3 + src/core/graph/nodes/data/Equals.ts | 19 + src/core/graph/nodes/data/Floor.ts | 3 + src/core/graph/nodes/data/GetPosition.ts | 19 + src/core/graph/nodes/data/GetProperty.ts | 16 + src/core/graph/nodes/data/IsAsset.ts | 17 + src/core/graph/nodes/data/IsEnabled.ts | 19 + src/core/graph/nodes/data/IsHovered.ts | 18 + src/core/graph/nodes/data/IsTouching.ts | 20 + src/core/graph/nodes/data/Join.ts | 14 + src/core/graph/nodes/data/KeyIsDown.ts | 13 + src/core/graph/nodes/data/Length.ts | 14 + src/core/graph/nodes/data/Max.ts | 3 + src/core/graph/nodes/data/Min.ts | 3 + src/core/graph/nodes/data/Modulo.ts | 17 + src/core/graph/nodes/data/MouseButton.ts | 13 + src/core/graph/nodes/data/MousePosition.ts | 13 + src/core/graph/nodes/data/Multiply.ts | 3 + src/core/graph/nodes/data/NameOf.ts | 14 + src/core/graph/nodes/data/Normalize.ts | 14 + src/core/graph/nodes/data/Not.ts | 14 + src/core/graph/nodes/data/Number.ts | 14 + src/core/graph/nodes/data/ObjectByName.ts | 13 + src/core/graph/nodes/data/Or.ts | 3 + src/core/graph/nodes/data/Owner.ts | 13 + src/core/graph/nodes/data/Parent.ts | 14 + src/core/graph/nodes/data/Random.ts | 17 + src/core/graph/nodes/data/Round.ts | 3 + src/core/graph/nodes/data/Self.ts | 13 + src/core/graph/nodes/data/Sqrt.ts | 3 + src/core/graph/nodes/data/String.ts | 14 + src/core/graph/nodes/data/Subtract.ts | 3 + src/core/graph/nodes/data/Time.ts | 13 + src/core/graph/nodes/data/ToText.ts | 18 + src/core/graph/nodes/events/OnClick.ts | 14 + .../graph/nodes/events/OnCollisionEnter.ts | 12 + .../graph/nodes/events/OnCollisionExit.ts | 12 + .../graph/nodes/events/OnCollisionStay.ts | 12 + src/core/graph/nodes/events/OnKeyDown.ts | 12 + src/core/graph/nodes/events/OnKeyHeld.ts | 12 + src/core/graph/nodes/events/OnKeyUp.ts | 12 + src/core/graph/nodes/events/OnStart.ts | 12 + src/core/graph/nodes/events/OnTimer.ts | 12 + src/core/graph/nodes/events/OnUpdate.ts | 12 + src/core/graph/nodes/flow/Branch.ts | 14 + src/core/graph/nodes/flow/Sequence.ts | 13 + src/core/graph/nodes/index.ts | 95 ++ src/core/graph/nodes/shared.ts | 79 ++ src/core/graph/nodes/variable/GetVariable.ts | 13 + src/core/graph/nodes/variable/SetVariable.ts | 18 + src/core/graph/values.ts | 42 + src/core/ids.ts | 12 +- src/core/model/Asset.ts | 4 - src/core/model/AssetTypes.ts | 16 + src/core/model/Project.ts | 25 +- src/core/model/Properties.ts | 15 +- src/core/model/SceneObject.ts | 4 +- src/core/model/container.ts | 49 +- src/core/model/hierarchy.ts | 99 +-- src/core/properties/PropertySchema.ts | 39 +- src/core/properties/bounds.ts | 18 +- src/core/properties/camera.ts | 7 +- src/core/properties/collider.ts | 42 +- src/core/properties/enabled.ts | 7 +- src/core/properties/resolve.ts | 4 +- src/core/store/HttpProjectStore.ts | 1 + src/core/util/list.ts | 20 + src/editor/App.ts | 405 +-------- src/editor/Editor.ts | 301 +++++++ src/editor/actions.ts | 16 +- src/editor/debugHook.ts | 61 ++ src/editor/graph/GraphEditor.ts | 3 +- src/editor/graph/NodeView.ts | 64 +- src/editor/inspector/Inspector.ts | 98 +++ src/editor/inspector/assetSection.ts | 134 +++ src/editor/inspector/fieldFactory.ts | 27 +- src/editor/inspector/host.ts | 25 + src/editor/inspector/nodeSection.ts | 115 +++ src/editor/inspector/objectSection.ts | 339 +++++++ src/editor/panels/AssetPanel.ts | 27 +- src/editor/panels/HierarchyPanel.ts | 59 +- src/editor/panels/Inspector.ts | 685 --------------- src/editor/panels/Toolbar.ts | 4 + src/editor/shortcuts.ts | 60 ++ src/editor/state.ts | 39 +- src/playground.ts | 34 +- src/runtime/World.ts | 56 +- src/runtime/systems/BehaviorSystem.ts | 50 +- src/runtime/systems/CollisionSystem.ts | 66 +- src/runtime/systems/Input.ts | 7 +- src/runtime/systems/System.ts | 2 +- src/ui/FocusManager.ts | 12 +- src/ui/KeyboardBridge.ts | 16 +- src/ui/Theme.ts | 25 +- src/ui/UIContext.ts | 8 +- src/ui/Widget.ts | 43 +- src/ui/color.ts | 38 +- src/ui/draw.ts | 21 + src/ui/index.ts | 13 +- src/ui/layout/Stack.ts | 4 + src/ui/math.ts | 9 + src/ui/widgets/Button.ts | 41 +- src/ui/widgets/Checkbox.ts | 29 +- src/ui/widgets/ColorField.ts | 149 +--- src/ui/widgets/ColorPicker.ts | 102 +++ src/ui/widgets/Dialog.ts | 31 + src/ui/widgets/Label.ts | 10 +- src/ui/widgets/ListRow.ts | 98 +++ src/ui/widgets/ListView.ts | 172 ++-- src/ui/widgets/Menu.ts | 23 + src/ui/widgets/NumberField.ts | 43 +- src/ui/widgets/Panel.ts | 1 + src/ui/widgets/Popup.ts | 98 +-- src/ui/widgets/ScrollContainer.ts | 23 +- src/ui/widgets/Splitter.ts | 21 +- src/ui/widgets/TextField.ts | 53 +- src/viewport/Viewport.ts | 555 +++++------- src/viewport/items.ts | 66 ++ src/viewport/symbols.ts | 92 ++ tests/core/apply.test.ts | 68 +- tests/core/collider.test.ts | 178 ++-- tests/core/commands.test.ts | 98 +-- tests/core/container.test.ts | 92 +- tests/core/enabled.test.ts | 276 +----- tests/core/graph.test.ts | 420 +++------ tests/core/hierarchy.test.ts | 112 +-- tests/core/nodes.test.ts | 111 +++ tests/core/serialize.test.ts | 24 +- tests/core/text-and-pointer.test.ts | 56 ++ tests/core/world-sync.test.ts | 54 +- tests/helpers.ts | 165 ++++ tools/e2e.mjs | 827 +----------------- tools/e2e/01-hierarchy.mjs | 32 + tools/e2e/02-inspector.mjs | 66 ++ tools/e2e/03-play.mjs | 104 +++ tools/e2e/04-assets.mjs | 37 + tools/e2e/05-drawing.mjs | 35 + tools/e2e/06-graph.mjs | 134 +++ tools/e2e/07-parenting.mjs | 70 ++ tools/e2e/08-colliders.mjs | 77 ++ tools/e2e/09-containers.mjs | 205 +++++ tools/e2e/10-text-and-enabled.mjs | 50 ++ tools/e2e/index.mjs | 17 + tools/harness.mjs | 144 --- tools/lib/builders.mjs | 50 ++ tools/lib/harness.mjs | 240 +++++ tools/{ => lib}/projects.mjs | 28 +- tools/seed-demo.mjs | 125 --- tools/seed-e2e.mjs | 81 +- tools/seed-jumprun.mjs | 304 +++---- tools/smoke-demo.mjs | 102 --- tools/smoke-jumprun.mjs | 89 +- 189 files changed, 6231 insertions(+), 5543 deletions(-) create mode 100644 src/core/commands/naming.ts create mode 100644 src/core/commands/require.ts create mode 100644 src/core/graph/NodeContext.ts delete mode 100644 src/core/graph/nodes.ts create mode 100644 src/core/graph/nodes/action/Destroy.ts create mode 100644 src/core/graph/nodes/action/Log.ts create mode 100644 src/core/graph/nodes/action/Move.ts create mode 100644 src/core/graph/nodes/action/RestartGame.ts create mode 100644 src/core/graph/nodes/action/SetEnabled.ts create mode 100644 src/core/graph/nodes/action/SetPosition.ts create mode 100644 src/core/graph/nodes/action/SetProperty.ts create mode 100644 src/core/graph/nodes/action/SetText.ts create mode 100644 src/core/graph/nodes/action/Spawn.ts create mode 100644 src/core/graph/nodes/action/StopGame.ts create mode 100644 src/core/graph/nodes/data/Abs.ts create mode 100644 src/core/graph/nodes/data/Add.ts create mode 100644 src/core/graph/nodes/data/And.ts create mode 100644 src/core/graph/nodes/data/AxisInput.ts create mode 100644 src/core/graph/nodes/data/Boolean.ts create mode 100644 src/core/graph/nodes/data/Ceil.ts create mode 100644 src/core/graph/nodes/data/Clamp.ts create mode 100644 src/core/graph/nodes/data/Compare.ts create mode 100644 src/core/graph/nodes/data/Distance.ts create mode 100644 src/core/graph/nodes/data/Divide.ts create mode 100644 src/core/graph/nodes/data/Equals.ts create mode 100644 src/core/graph/nodes/data/Floor.ts create mode 100644 src/core/graph/nodes/data/GetPosition.ts create mode 100644 src/core/graph/nodes/data/GetProperty.ts create mode 100644 src/core/graph/nodes/data/IsAsset.ts create mode 100644 src/core/graph/nodes/data/IsEnabled.ts create mode 100644 src/core/graph/nodes/data/IsHovered.ts create mode 100644 src/core/graph/nodes/data/IsTouching.ts create mode 100644 src/core/graph/nodes/data/Join.ts create mode 100644 src/core/graph/nodes/data/KeyIsDown.ts create mode 100644 src/core/graph/nodes/data/Length.ts create mode 100644 src/core/graph/nodes/data/Max.ts create mode 100644 src/core/graph/nodes/data/Min.ts create mode 100644 src/core/graph/nodes/data/Modulo.ts create mode 100644 src/core/graph/nodes/data/MouseButton.ts create mode 100644 src/core/graph/nodes/data/MousePosition.ts create mode 100644 src/core/graph/nodes/data/Multiply.ts create mode 100644 src/core/graph/nodes/data/NameOf.ts create mode 100644 src/core/graph/nodes/data/Normalize.ts create mode 100644 src/core/graph/nodes/data/Not.ts create mode 100644 src/core/graph/nodes/data/Number.ts create mode 100644 src/core/graph/nodes/data/ObjectByName.ts create mode 100644 src/core/graph/nodes/data/Or.ts create mode 100644 src/core/graph/nodes/data/Owner.ts create mode 100644 src/core/graph/nodes/data/Parent.ts create mode 100644 src/core/graph/nodes/data/Random.ts create mode 100644 src/core/graph/nodes/data/Round.ts create mode 100644 src/core/graph/nodes/data/Self.ts create mode 100644 src/core/graph/nodes/data/Sqrt.ts create mode 100644 src/core/graph/nodes/data/String.ts create mode 100644 src/core/graph/nodes/data/Subtract.ts create mode 100644 src/core/graph/nodes/data/Time.ts create mode 100644 src/core/graph/nodes/data/ToText.ts create mode 100644 src/core/graph/nodes/events/OnClick.ts create mode 100644 src/core/graph/nodes/events/OnCollisionEnter.ts create mode 100644 src/core/graph/nodes/events/OnCollisionExit.ts create mode 100644 src/core/graph/nodes/events/OnCollisionStay.ts create mode 100644 src/core/graph/nodes/events/OnKeyDown.ts create mode 100644 src/core/graph/nodes/events/OnKeyHeld.ts create mode 100644 src/core/graph/nodes/events/OnKeyUp.ts create mode 100644 src/core/graph/nodes/events/OnStart.ts create mode 100644 src/core/graph/nodes/events/OnTimer.ts create mode 100644 src/core/graph/nodes/events/OnUpdate.ts create mode 100644 src/core/graph/nodes/flow/Branch.ts create mode 100644 src/core/graph/nodes/flow/Sequence.ts create mode 100644 src/core/graph/nodes/index.ts create mode 100644 src/core/graph/nodes/shared.ts create mode 100644 src/core/graph/nodes/variable/GetVariable.ts create mode 100644 src/core/graph/nodes/variable/SetVariable.ts create mode 100644 src/core/graph/values.ts create mode 100644 src/core/util/list.ts create mode 100644 src/editor/Editor.ts create mode 100644 src/editor/debugHook.ts create mode 100644 src/editor/inspector/Inspector.ts create mode 100644 src/editor/inspector/assetSection.ts create mode 100644 src/editor/inspector/host.ts create mode 100644 src/editor/inspector/nodeSection.ts create mode 100644 src/editor/inspector/objectSection.ts delete mode 100644 src/editor/panels/Inspector.ts create mode 100644 src/editor/shortcuts.ts create mode 100644 src/ui/draw.ts create mode 100644 src/ui/math.ts create mode 100644 src/ui/widgets/ColorPicker.ts create mode 100644 src/ui/widgets/Dialog.ts create mode 100644 src/ui/widgets/ListRow.ts create mode 100644 src/ui/widgets/Menu.ts create mode 100644 src/viewport/items.ts create mode 100644 src/viewport/symbols.ts create mode 100644 tests/core/nodes.test.ts create mode 100644 tests/core/text-and-pointer.test.ts create mode 100644 tests/helpers.ts create mode 100644 tools/e2e/01-hierarchy.mjs create mode 100644 tools/e2e/02-inspector.mjs create mode 100644 tools/e2e/03-play.mjs create mode 100644 tools/e2e/04-assets.mjs create mode 100644 tools/e2e/05-drawing.mjs create mode 100644 tools/e2e/06-graph.mjs create mode 100644 tools/e2e/07-parenting.mjs create mode 100644 tools/e2e/08-colliders.mjs create mode 100644 tools/e2e/09-containers.mjs create mode 100644 tools/e2e/10-text-and-enabled.mjs create mode 100644 tools/e2e/index.mjs delete mode 100644 tools/harness.mjs create mode 100644 tools/lib/builders.mjs create mode 100644 tools/lib/harness.mjs rename tools/{ => lib}/projects.mjs (58%) delete mode 100644 tools/seed-demo.mjs delete mode 100644 tools/smoke-demo.mjs diff --git a/README.md b/README.md index 4521b54c..a8cab735 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ shown on the node and editable in the inspector. (inspector → Parent, or drag it onto that object in the hierarchy; dashed line in the viewport). The `Owner` node returns it; `Self` returns the behavior itself; `Parent` walks up from any object. -- **Node set** (`src/core/graph/nodes.ts`): events `On start`, `On update`, `On key down/up/held`, +- **Node set** (`src/core/graph/nodes/`, one file per node): events `On start`, `On update`, `On key down/up/held`, `On timer`, `On collision enter/stay/exit`, `On click` (the mouse pressed on the owner – anywhere when it has none) · flow `Branch`, `Sequence` · 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`, @@ -65,7 +65,8 @@ shown on the node and editable in the inspector. normalized direction, so diagonals are not faster), `Compare`, `And/Or/Not`, `Key is down`, `Mouse position`, `Mouse button`, `Is hovered`, `Is touching` · actions `Move` (dx, dy, scale, dt – connect an event's dt for per-second movement), `Set position`, `Set enabled`, `Set text`, `Spawn` - (asset setting + x/y/z, optional parent → outputs the new object), `Destroy`, `Log`, `Stop game`, `Restart game`. A node type is one `registerNode()` call; palette, ports, validation and + (asset setting + x/y/z, optional parent → outputs the new object), `Destroy`, `Log`, `Stop game`, `Restart game`. A node type is one file exporting a `NodeDef` + (ports, settings, `run`), listed in `nodes/index.ts`; palette, ports, validation and inspector fields are generated from it. A port may declare a `default` for its inline value (e.g. `Move.scale = 1`), shown on the node and in the inspector. - **Cross-object state**: a behavior's variables *are* the properties of its object, so another @@ -186,16 +187,21 @@ selected and switched back on. ## Layout ``` -web/index.html Vite source page (dev) index.html built page (generated, served) -src/core/ model, property schema, resolve, commands, store – no Pixi imports -src/core/graph/ graph model, node registry + built-in nodes, interpreter (headless, unit-tested) -src/runtime/ World, Entity, System interface, Input tracker, CollisionSystem, BehaviorSystem -src/ui/ widget toolkit (generic, knows nothing about assets) -src/viewport/ scene renderer: Scene tab (model, editable) and Game tab (world through its camera) -src/editor/ App composition, state, panels, inspector field factory -src/editor/graph/ GraphEditor (canvas), NodeView, NodePalette -api/ PHP backend data/projects/ persisted projects -tests/ Vitest unit tests tools/ harness/projects + e2e, smoke-demo, seed-* +web/index.html Vite source page (dev) index.html built page (generated, served) +src/core/model/ Project, Asset, SceneObject, hierarchy (parent chain, world positions), container templates +src/core/properties/ PropertySchema, resolve (the only override merge), collider/camera/text geometry, enabled +src/core/commands/ undoable commands, CommandStack, require.ts (validated lookups), naming.ts +src/core/graph/ graph model, NodeRegistry/NodeContext/values, interpreter; nodes//.ts +src/core/store/ serialize (untrusted JSON → Project), HttpProjectStore +src/runtime/ World, Entity, System interface, Input tracker, CollisionSystem, BehaviorSystem +src/ui/ widget toolkit (generic, knows nothing about assets) +src/viewport/ Viewport (Scene and Game tab), items (what to draw), symbols (how to draw it) +src/editor/ App → Editor composition, state, actions, shortcuts, debugHook, autosave +src/editor/inspector/ Inspector panel, field factory, one section per selection kind (asset/object/node) +src/editor/panels/ AssetPanel, HierarchyPanel, Toolbar +src/editor/graph/ GraphEditor (canvas), NodeView, NodePalette +api/ PHP backend data/projects/ persisted projects +tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/), smoke-jumprun, lib/ ``` The toolbar's right edge shows the frame rate (frames counted over a 0.5 s window, updated @@ -204,7 +210,9 @@ twice a second) in edit and play mode alike; `__gm.fps()` exposes the same numbe Rules that keep it organised: `core/` imports nothing from other layers; `ui/` never sees assets; position exists only on scene objects; overrides are merged in exactly one place (`core/properties/resolve.ts`); the inspector is generated from `PropertySchema` – a new -property is one schema line. +property is one schema line; a new node is one file. Comments state contracts and reasons, not +what the code does: a `/** */` on every export whose name does not say it all, `//` only for a +decision or gotcha. ## Development @@ -216,17 +224,18 @@ npm run dev # http://localhost:5175/game-maker/dist/ – API calls are npm test # unit tests (core) npm run typecheck npm run build # writes dist/ and regenerates index.html -node tools/seed-e2e.mjs && node tools/e2e.mjs # 100+ editor checks, screenshots in /tmp/gm-e2e-*.png -node tools/seed-demo.mjs \ - && node tools/smoke-demo.mjs # the shipped demo scene still loads and plays -node tools/seed-demo.mjs # rewrite the playground with the demo scene +node tools/seed-e2e.mjs && node tools/e2e.mjs # 140 editor checks, screenshots in /tmp/gm-e2e-*.png node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # the example game, played end to end ``` -Each script writes to its own project (`tools/projects.mjs`): the **demo** project is the -playground and is only ever touched by `seed-demo.mjs`, the **fixture** project is rewritten by -every e2e run, and the **demo-check** project is a throwaway copy for the smoke test. Pointing a -destructive script at the demo project aborts, so a test run can never eat scene work. +Each script writes to its own project (`tools/lib/projects.mjs`): the **fixture** project is +rewritten by every e2e run and the **jump-run** project by its seeder. The **playground** +project is the author's own and is maintained in the editor – every tool refuses 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) 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. **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 @@ -237,12 +246,6 @@ and picking one up destroys the container, not just the sprite the collider repo (start, jump, collect, win, restart, lose) – it is the regression test for everything screens, text, mouse and platformer physics need. -The split lets the two sides move independently: `e2e.mjs` asserts editor behaviour against a -stable fixture with uniquely named objects, while `smoke-demo.mjs` only checks that the shipped -demo – a "Player" container (green sprite, collider, Mover behavior), two "Enemy" container -instances, a camera at zoom 0.5 and a Spawner that drops another enemy on Space – still loads, -plays, collides and spawns. `tools/harness.mjs` holds the shared CDP driver. - The workspace folder itself is what nginx serves, so `npm run build` is the deployment. ## API diff --git a/src/core/commands/Command.ts b/src/core/commands/Command.ts index e4307c03..3e8a73d1 100644 --- a/src/core/commands/Command.ts +++ b/src/core/commands/Command.ts @@ -2,8 +2,8 @@ import type { Project } from '../model/Project'; /** * An undoable edit of the project. Commands mutate the project in place and remember - * whatever they need to revert. They must be self-validating: `execute` throws on an - * invalid operation (e.g. placing a scene into a scene) before touching anything. + * whatever they need to revert. They validate first: `execute` throws a `CommandError` on + * an invalid operation (e.g. placing a scene into a scene) before it changes anything. */ export interface Command { readonly label: string; @@ -11,6 +11,7 @@ export interface Command { undo(project: Project): void; } +/** A rejected edit with a message fit for the user; the editor shows it as a notice. */ export class CommandError extends Error { constructor(message: string) { super(message); diff --git a/src/core/commands/CommandStack.ts b/src/core/commands/CommandStack.ts index 4785368c..941c77df 100644 --- a/src/core/commands/CommandStack.ts +++ b/src/core/commands/CommandStack.ts @@ -1,13 +1,10 @@ import type { Project } from '../model/Project'; import type { Command } from './Command'; -export type CommandListener = (command: Command, action: 'execute' | 'undo' | 'redo') => void; - /** Linear undo/redo history. Every project change in the editor goes through here. */ export class CommandStack { private readonly undoStack: Command[] = []; private readonly redoStack: Command[] = []; - private readonly listeners = new Set(); constructor(private readonly project: Project, private readonly limit = 200) {} @@ -16,24 +13,23 @@ export class CommandStack { this.undoStack.push(command); if (this.undoStack.length > this.limit) this.undoStack.shift(); this.redoStack.length = 0; - this.emit(command, 'execute'); } + /** Reverts the last command; false when there is none. */ undo(): boolean { const command = this.undoStack.pop(); if (!command) return false; command.undo(this.project); this.redoStack.push(command); - this.emit(command, 'undo'); return true; } + /** Re-applies the last undone command; false when there is none. */ redo(): boolean { const command = this.redoStack.pop(); if (!command) return false; command.execute(this.project); this.undoStack.push(command); - this.emit(command, 'redo'); return true; } @@ -44,13 +40,4 @@ export class CommandStack { get canRedo(): boolean { return this.redoStack.length > 0; } - - subscribe(listener: CommandListener): () => void { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } - - private emit(command: Command, action: 'execute' | 'undo' | 'redo'): void { - for (const listener of this.listeners) listener(command, action); - } } diff --git a/src/core/commands/applyCommands.ts b/src/core/commands/applyCommands.ts index a8df12b7..5a32e679 100644 --- a/src/core/commands/applyCommands.ts +++ b/src/core/commands/applyCommands.ts @@ -1,13 +1,3 @@ -import { instancesOf, type TemplateLink, templateLinkOf } from '../model/container'; -import type { Position, PropertyValue } from '../model/Properties'; -import { findScene, type Project } from '../model/Project'; -import type { SceneObject } from '../model/SceneObject'; -import { resolveSceneObjectProps } from '../properties/resolve'; -import { schemaForAsset } from '../properties/PropertySchema'; -import { type Command, CommandError } from './Command'; -import { requireAsset } from './assetCommands'; -import { requireObject } from './sceneObjectCommands'; - /** * "Apply" is the opposite direction of an override: a value the user changed on a placed * object is written back to where it came from – the asset, or (for objects that are part @@ -15,8 +5,15 @@ import { requireObject } from './sceneObjectCommands'; * prefab semantics: copies that were still at the template's value follow the change, * copies the user modified on their own keep their value. */ +import { instancesOf, type TemplateLink, templateLinkOf } from '../model/container'; +import { type Axis, type Position, type PropertyValue, setBagValue } from '../model/Properties'; +import { findScene, type Project } from '../model/Project'; +import type { SceneObject } from '../model/SceneObject'; +import { resolveSceneObjectProps } from '../properties/resolve'; +import { type Command, CommandError } from './Command'; +import { requireAsset, requireObject, requirePropDef } from './require'; -type Axis = 'x' | 'y' | 'z'; +const ALL_AXES: readonly Axis[] = ['x', 'y', 'z']; function requireLink(project: Project, sceneId: string, object: SceneObject): TemplateLink { const scene = findScene(project, sceneId); @@ -25,11 +22,6 @@ function requireLink(project: Project, sceneId: string, object: SceneObject): Te return link; } -function setOverride(object: SceneObject, key: string, value: PropertyValue | undefined): void { - if (value === undefined) delete object.overrides[key]; - else object.overrides[key] = value; -} - /** Remembered override values of several objects, for undo. */ interface OverrideMemo { object: SceneObject; @@ -46,14 +38,14 @@ function updateTemplateOverride(project: Project, link: TemplateLink, key: strin for (const { object } of instancesOf(project, link.template.id)) { if (object.overrides[key] !== previous) continue; memos.push({ object, previous: object.overrides[key] }); - setOverride(object, key, next); + setBagValue(object.overrides, key, next); } - setOverride(link.template, key, next); + setBagValue(link.template.overrides, key, next); return memos; } function restoreOverrides(memos: OverrideMemo[], key: string): void { - for (const memo of [...memos].reverse()) setOverride(memo.object, key, memo.previous); + for (const memo of [...memos].reverse()) setBagValue(memo.object.overrides, key, memo.previous); } /** @@ -75,7 +67,7 @@ export class ApplyOverrideToAssetCommand implements Command { const scene = findScene(project, this.sceneId); const object = requireObject(project, this.sceneId, this.objectId); const asset = requireAsset(project, object.assetId); - if (!schemaForAsset(asset).some((d) => d.key === this.key)) throw new CommandError(`Property "${this.key}" is not defined for ${asset.type}`); + requirePropDef(asset, this.key); const value = resolveSceneObjectProps(asset, object)[this.key]; this.previousAssetValue = asset.props[this.key]; this.previousOverride = object.overrides[this.key]; @@ -89,9 +81,8 @@ export class ApplyOverrideToAssetCommand implements Command { const object = requireObject(project, this.sceneId, this.objectId); const asset = requireAsset(project, object.assetId); restoreOverrides(this.templateMemos, this.key); - setOverride(object, this.key, this.previousOverride); - if (this.previousAssetValue === undefined) delete asset.props[this.key]; - else asset.props[this.key] = this.previousAssetValue; + setBagValue(object.overrides, this.key, this.previousOverride); + setBagValue(asset.props, this.key, this.previousAssetValue); } } @@ -128,11 +119,11 @@ export class RevertOverrideToTemplateCommand implements Command { const object = requireObject(project, this.sceneId, this.objectId); const link = requireLink(project, this.sceneId, object); this.previous = object.overrides[this.key]; - setOverride(object, this.key, link.template.overrides[this.key]); + setBagValue(object.overrides, this.key, link.template.overrides[this.key]); } undo(project: Project): void { - setOverride(requireObject(project, this.sceneId, this.objectId), this.key, this.previous); + setBagValue(requireObject(project, this.sceneId, this.objectId).overrides, this.key, this.previous); } } @@ -157,7 +148,7 @@ export class ApplyPositionToContainerCommand implements Command { execute(project: Project): void { const object = requireObject(project, this.sceneId, this.objectId); const link = requireLink(project, this.sceneId, object); - const axes: Axis[] = this.axis ? [this.axis] : ['x', 'y', 'z']; + const axes = this.axis ? [this.axis] : ALL_AXES; const before = { ...link.template.position }; this.memos = [{ object: link.template, previous: before }]; for (const instance of instancesOf(project, link.template.id)) { @@ -187,7 +178,7 @@ export class RevertPositionToTemplateCommand implements Command { const object = requireObject(project, this.sceneId, this.objectId); const link = requireLink(project, this.sceneId, object); this.previous = { ...object.position }; - for (const a of this.axis ? [this.axis] : (['x', 'y', 'z'] as Axis[])) object.position[a] = link.template.position[a]; + for (const a of this.axis ? [this.axis] : ALL_AXES) object.position[a] = link.template.position[a]; } undo(project: Project): void { diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index a8a3df81..7bd21864 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -1,13 +1,17 @@ import { createEmptyGraph } from '../graph/Graph'; +import { newId } from '../ids'; import type { Asset, ContainerAsset, SceneAsset } from '../model/Asset'; import type { AssetType } from '../model/AssetTypes'; -import type { PropertyValue } from '../model/Properties'; -import { findAsset, type Project } from '../model/Project'; -import { coerceValue, defaultsFor, schemaForAsset } from '../properties/PropertySchema'; -import { newId } from '../ids'; +import { removeSubtrees } from '../model/hierarchy'; +import { type PropertyValue, setBagValue } from '../model/Properties'; +import type { Project } from '../model/Project'; +import type { SceneObject } from '../model/SceneObject'; +import { coerceValue, defaultsFor } from '../properties/PropertySchema'; +import { type Removed, restoreAt } from '../util/list'; import { type Command, CommandError } from './Command'; -import { type RemovedObject, removeSubtrees, restoreObjects } from './sceneObjectCommands'; +import { requireAsset, requirePropDef } from './require'; +/** A new asset of the given type with default properties (and empty content for scenes, behaviors, containers). */ export function buildAsset(type: AssetType, name: string, id = newId()): Asset { const base = { id, name, props: defaultsFor(type) }; switch (type) { @@ -28,6 +32,7 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset { } } +/** Adds a new asset; the first scene of a project becomes the active scene. */ export class CreateAssetCommand implements Command { readonly label: string; readonly asset: Asset; @@ -40,7 +45,6 @@ export class CreateAssetCommand implements Command { execute(project: Project): void { project.assets.push(this.asset); - // The first scene of a project becomes active automatically. this.previousActiveScene = project.activeSceneId; if (this.asset.type === 'scene' && !project.activeSceneId) project.activeSceneId = this.asset.id; } @@ -57,15 +61,13 @@ export class CreateAssetCommand implements Command { * referencing the asset lose those objects the same way. */ export class DeleteAssetCommand implements Command { - readonly label: string; - private removed?: { asset: Asset; index: number }; + readonly label = 'Delete asset'; + private removed?: Removed; /** Objects removed per scene or container asset (both hold a flat object list). */ - private removedObjects: { holderId: string; removed: RemovedObject[] }[] = []; + private removedObjects: { holderId: string; removed: Removed[] }[] = []; private previousActiveScene = ''; - constructor(private readonly assetId: string) { - this.label = 'Delete asset'; - } + constructor(private readonly assetId: string) {} execute(project: Project): void { const index = project.assets.findIndex((a) => a.id === this.assetId); @@ -77,7 +79,7 @@ export class DeleteAssetCommand implements Command { if (roots.length === 0) continue; this.removedObjects.push({ holderId: holder.id, removed: removeSubtrees(holder, roots) }); } - this.removed = { asset, index }; + this.removed = { item: asset, index }; project.assets.splice(index, 1); this.previousActiveScene = project.activeSceneId; if (project.activeSceneId === this.assetId) { @@ -88,10 +90,10 @@ export class DeleteAssetCommand implements Command { undo(project: Project): void { if (!this.removed) return; - project.assets.splice(this.removed.index, 0, this.removed.asset); + project.assets.splice(this.removed.index, 0, this.removed.item); for (const entry of this.removedObjects) { const holder = objectHolders(project).find((h) => h.id === entry.holderId); - if (holder) restoreObjects(holder, entry.removed); + if (holder) restoreAt(holder.objects, entry.removed); } project.activeSceneId = this.previousActiveScene; } @@ -119,6 +121,7 @@ export class RenameAssetCommand implements Command { } } +/** Sets an asset property (coerced against its schema entry). */ export class SetAssetPropertyCommand implements Command { readonly label: string; private previous: PropertyValue | undefined; @@ -129,16 +132,13 @@ export class SetAssetPropertyCommand implements Command { execute(project: Project): void { const asset = requireAsset(project, this.assetId); - const def = schemaForAsset(asset).find((d) => d.key === this.key); - if (!def) throw new CommandError(`Property "${this.key}" is not defined for ${asset.type}`); + const def = requirePropDef(asset, this.key); this.previous = asset.props[this.key]; asset.props[this.key] = coerceValue(def, this.value); } undo(project: Project): void { - const asset = requireAsset(project, this.assetId); - if (this.previous === undefined) delete asset.props[this.key]; - else asset.props[this.key] = this.previous; + setBagValue(requireAsset(project, this.assetId).props, this.key, this.previous); } } @@ -153,16 +153,13 @@ export class ResetAssetPropertyCommand implements Command { execute(project: Project): void { const asset = requireAsset(project, this.assetId); - const def = schemaForAsset(asset).find((d) => d.key === this.key); - if (!def) throw new CommandError(`Property "${this.key}" is not defined for ${asset.type}`); + const def = requirePropDef(asset, this.key); this.previous = asset.props[this.key]; asset.props[this.key] = def.default; } undo(project: Project): void { - const asset = requireAsset(project, this.assetId); - if (this.previous === undefined) delete asset.props[this.key]; - else asset.props[this.key] = this.previous; + setBagValue(requireAsset(project, this.assetId).props, this.key, this.previous); } } @@ -183,9 +180,3 @@ export class SetActiveSceneCommand implements Command { project.activeSceneId = this.previous; } } - -export function requireAsset(project: Project, assetId: string): Asset { - const asset = findAsset(project, assetId); - if (!asset) throw new CommandError(`Asset ${assetId} not found`); - return asset; -} diff --git a/src/core/commands/colliderCommands.ts b/src/core/commands/colliderCommands.ts index 15263156..0037c8a8 100644 --- a/src/core/commands/colliderCommands.ts +++ b/src/core/commands/colliderCommands.ts @@ -1,11 +1,11 @@ -import { findScene, type Project } from '../model/Project'; +import { newId } from '../ids'; +import type { Project } from '../model/Project'; import { createSceneObject } from '../model/SceneObject'; import { resolveSceneObjectProps } from '../properties/resolve'; -import { newId } from '../ids'; -import { buildAsset, requireAsset } from './assetCommands'; +import { buildAsset } from './assetCommands'; import { type Command, CommandError } from './Command'; -import { nextAssetName } from './projectCommands'; -import { requireObject } from './sceneObjectCommands'; +import { nextAssetName } from './naming'; +import { requireAsset, requireObject, requireScene } from './require'; /** * The one-click way to give an object a collider: creates a box collider asset sized like @@ -20,8 +20,7 @@ export class AddColliderCommand implements Command { constructor(private readonly sceneId: string, private readonly targetId: string) {} execute(project: Project): void { - const scene = findScene(project, this.sceneId); - if (!scene) throw new CommandError(`Scene ${this.sceneId} not found`); + const scene = requireScene(project, this.sceneId); const target = requireObject(project, this.sceneId, this.targetId); const targetAsset = requireAsset(project, target.assetId); if (targetAsset.type === 'collider') throw new CommandError('A collider cannot have a collider'); @@ -37,8 +36,8 @@ export class AddColliderCommand implements Command { } undo(project: Project): void { - const scene = findScene(project, this.sceneId); - if (scene) scene.objects = scene.objects.filter((o) => o.id !== this.objectId); + const scene = requireScene(project, this.sceneId); + scene.objects = scene.objects.filter((o) => o.id !== this.objectId); project.assets = project.assets.filter((a) => a.id !== this.assetId); } } diff --git a/src/core/commands/containerCommands.ts b/src/core/commands/containerCommands.ts index 76c42986..75b75ad4 100644 --- a/src/core/commands/containerCommands.ts +++ b/src/core/commands/containerCommands.ts @@ -1,12 +1,13 @@ +import { newId } from '../ids'; import type { ContainerAsset } from '../model/Asset'; -import { descendantsOf, localPositionFor, subtreeRoots, treeOrder, worldPosition } from '../model/hierarchy'; +import { findObject, localPositionFor, subtreeIds, subtreeRoots, treeOrder, worldPosition } from '../model/hierarchy'; import type { Position } from '../model/Properties'; import { findScene, type Project } from '../model/Project'; import { createSceneObject } from '../model/SceneObject'; -import { newId } from '../ids'; import { buildAsset } from './assetCommands'; import { type Command, CommandError } from './Command'; -import { nextAssetName } from './projectCommands'; +import { nextAssetName } from './naming'; +import { requireObject, requireScene } from './require'; /** * Turns the selected objects (with everything parented below them) into a new container @@ -26,13 +27,8 @@ export class CreateContainerCommand implements Command { constructor(private readonly sceneId: string, private readonly objectIds: readonly string[]) {} execute(project: Project): void { - const scene = findScene(project, this.sceneId); - if (!scene) throw new CommandError(`Scene ${this.sceneId} not found`); - const selected = [...new Set(this.objectIds)].map((id) => { - const object = scene.objects.find((o) => o.id === id); - if (!object) throw new CommandError(`Scene object ${id} not found`); - return object; - }); + const scene = requireScene(project, this.sceneId); + const selected = [...new Set(this.objectIds)].map((id) => requireObject(project, this.sceneId, id)); if (selected.length === 0) throw new CommandError('Select at least one object to create a container'); // Centre of the selection in world space – the container's origin. @@ -45,13 +41,9 @@ export class CreateContainerCommand implements Command { // The subtrees that move into the container, in tree order (parents first). const roots = new Set(subtreeRoots(scene, selected.map((o) => o.id))); - const memberIds = new Set(); - for (const id of roots) { - memberIds.add(id); - for (const child of descendantsOf(scene, id)) memberIds.add(child.id); - } + const memberIds = subtreeIds(scene, roots); const members = treeOrder(scene).map((e) => e.object).filter((o) => memberIds.has(o.id)); - const rootParents = new Set([...roots].map((id) => scene.objects.find((o) => o.id === id)!.parentId)); + const rootParents = new Set([...roots].map((id) => findObject(scene, id)!.parentId)); const containerParentId = rootParents.size === 1 ? [...rootParents][0]! : ''; // Template: same structure with private ids, roots relative to the centre. @@ -92,7 +84,7 @@ export class CreateContainerCommand implements Command { const scene = findScene(project, this.sceneId); if (scene) { for (const entry of this.previous) { - const object = scene.objects.find((o) => o.id === entry.id); + const object = findObject(scene, entry.id); if (!object) continue; object.parentId = entry.parentId; object.position = { ...entry.position }; diff --git a/src/core/commands/graphCommands.ts b/src/core/commands/graphCommands.ts index a51d275a..8ffa08c2 100644 --- a/src/core/commands/graphCommands.ts +++ b/src/core/commands/graphCommands.ts @@ -1,11 +1,12 @@ -import { edgesInto, edgesOfNode, findNode, type GraphEdge, type GraphNode, portsCompatible } from '../graph/Graph'; +import { edgesInto, findNode, type GraphEdge, type GraphNode, portsCompatible } from '../graph/Graph'; import { requireNodeDef } from '../graph/NodeRegistry'; -import type { PropertyBag, PropertyValue } from '../model/Properties'; +import { newId } from '../ids'; +import { type PropertyBag, type PropertyValue, setBagValue } from '../model/Properties'; import type { Project } from '../model/Project'; import { coerceValue, defaultsOf } from '../properties/PropertySchema'; -import { newId } from '../ids'; +import { type Removed, removeWhere, restoreAt } from '../util/list'; import { type Command, CommandError } from './Command'; -import { requireBehavior } from './variableCommands'; +import { requireBehavior, requireNode } from './require'; /** Adds a node of the given type at graph coordinates. Params start at their defaults. */ export class AddNodeCommand implements Command { @@ -31,7 +32,7 @@ export class AddNodeCommand implements Command { /** Removes a node and every edge touching it. */ export class RemoveNodeCommand implements Command { readonly label = 'Remove node'; - private removed?: { node: GraphNode; index: number; edges: { edge: GraphEdge; index: number }[] }; + private removed?: { node: Removed; edges: Removed[] }; constructor(private readonly assetId: string, private readonly nodeId: string) {} @@ -40,27 +41,28 @@ export class RemoveNodeCommand implements Command { const index = graph.nodes.findIndex((n) => n.id === this.nodeId); const node = graph.nodes[index]; if (!node) throw new CommandError(`Node ${this.nodeId} not found`); - const edges = edgesOfNode(graph, this.nodeId).map((edge) => ({ edge, index: graph.edges.indexOf(edge) })); - this.removed = { node, index, edges }; + const edges = removeWhere(graph.edges, (e) => e.from.node === this.nodeId || e.to.node === this.nodeId); graph.nodes.splice(index, 1); - graph.edges = graph.edges.filter((e) => e.from.node !== this.nodeId && e.to.node !== this.nodeId); + this.removed = { node: { item: node, index }, edges }; } undo(project: Project): void { if (!this.removed) return; const graph = requireBehavior(project, this.assetId).graph; - graph.nodes.splice(this.removed.index, 0, this.removed.node); - for (const { edge, index } of [...this.removed.edges].sort((a, b) => a.index - b.index)) graph.edges.splice(index, 0, edge); + restoreAt(graph.nodes, [this.removed.node]); + restoreAt(graph.edges, this.removed.edges); } } +/** + * Sets a node's graph position. A caller that previewed the drag must restore the node + * before executing: the previous position is read at execution time. + */ export class MoveNodeCommand implements Command { readonly label = 'Move node'; private previous = { x: 0, y: 0 }; - constructor(private readonly assetId: string, private readonly nodeId: string, private readonly x: number, private readonly y: number, previous?: { x: number; y: number }) { - if (previous) this.previous = { ...previous }; - } + constructor(private readonly assetId: string, private readonly nodeId: string, private readonly x: number, private readonly y: number) {} execute(project: Project): void { const node = requireNode(project, this.assetId, this.nodeId); @@ -87,17 +89,14 @@ export class SetNodeParamCommand implements Command { execute(project: Project): void { const node = requireNode(project, this.assetId, this.nodeId); - const def = requireNodeDef(node.type); - const param = def.params.find((p) => p.key === this.key); + const param = requireNodeDef(node.type).params.find((p) => p.key === this.key); this.previous = node.params[this.key]; // Inline input values are stored as-is; declared params are coerced against their definition. node.params[this.key] = param && param.kind !== 'enum' ? coerceValue(param, this.value) : this.value; } undo(project: Project): void { - const node = requireNode(project, this.assetId, this.nodeId); - if (this.previous === undefined) delete node.params[this.key]; - else node.params[this.key] = this.previous; + setBagValue(requireNode(project, this.assetId, this.nodeId).params, this.key, this.previous); } } @@ -108,7 +107,7 @@ export class SetNodeParamCommand implements Command { export class ConnectCommand implements Command { readonly label = 'Connect'; readonly edge: GraphEdge; - private replaced: { edge: GraphEdge; index: number }[] = []; + private replaced: Removed[] = []; constructor(private readonly assetId: string, from: { node: string; port: string }, to: { node: string; port: string }) { this.edge = { id: newId(), from: { ...from }, to: { ...to } }; @@ -125,49 +124,36 @@ export class ConnectCommand implements Command { if (!out || !inp) throw new CommandError('Port not found'); if (!portsCompatible(out.type, inp.type)) throw new CommandError(`Cannot connect ${out.type} to ${inp.type}`); if (graph.edges.some((e) => sameEnds(e, this.edge))) throw new CommandError('Already connected'); - this.replaced = []; - if (inp.type !== 'exec') { - for (const existing of edgesInto(graph, this.edge.to.node, this.edge.to.port)) { - this.replaced.push({ edge: existing, index: graph.edges.indexOf(existing) }); - } - graph.edges = graph.edges.filter((e) => !this.replaced.some((r) => r.edge === e)); - } + const occupied = inp.type === 'exec' ? [] : edgesInto(graph, this.edge.to.node, this.edge.to.port); + this.replaced = removeWhere(graph.edges, (e) => occupied.includes(e)); graph.edges.push(this.edge); } undo(project: Project): void { const graph = requireBehavior(project, this.assetId).graph; graph.edges = graph.edges.filter((e) => e.id !== this.edge.id); - for (const { edge, index } of [...this.replaced].sort((a, b) => a.index - b.index)) graph.edges.splice(index, 0, edge); + restoreAt(graph.edges, this.replaced); } } +/** Removes one edge. */ export class DisconnectCommand implements Command { readonly label = 'Disconnect'; - private removed?: { edge: GraphEdge; index: number }; + private removed: Removed[] = []; constructor(private readonly assetId: string, private readonly edgeId: string) {} execute(project: Project): void { const graph = requireBehavior(project, this.assetId).graph; - const index = graph.edges.findIndex((e) => e.id === this.edgeId); - const edge = graph.edges[index]; - if (!edge) throw new CommandError(`Edge ${this.edgeId} not found`); - this.removed = { edge, index }; - graph.edges.splice(index, 1); + this.removed = removeWhere(graph.edges, (e) => e.id === this.edgeId); + if (this.removed.length === 0) throw new CommandError(`Edge ${this.edgeId} not found`); } undo(project: Project): void { - if (this.removed) requireBehavior(project, this.assetId).graph.edges.splice(this.removed.index, 0, this.removed.edge); + restoreAt(requireBehavior(project, this.assetId).graph.edges, this.removed); } } -function requireNode(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 sameEnds(a: GraphEdge, b: GraphEdge): boolean { return a.from.node === b.from.node && a.from.port === b.from.port && a.to.node === b.to.node && a.to.port === b.to.port; } diff --git a/src/core/commands/naming.ts b/src/core/commands/naming.ts new file mode 100644 index 00000000..319adfb0 --- /dev/null +++ b/src/core/commands/naming.ts @@ -0,0 +1,21 @@ +import type { BehaviorAsset } from '../model/Asset'; +import type { Project } from '../model/Project'; + +/** The first of `candidate(from)`, `candidate(from + 1)`, … that is not taken. */ +function firstFree(taken: ReadonlySet, candidate: (i: number) => string, from: number): string { + for (let i = from; ; i++) { + const name = candidate(i); + if (!taken.has(name)) return name; + } +} + +/** "Sprite", "Sprite 2", "Sprite 3" … – the first name not yet used by an asset. */ +export function nextAssetName(project: Project, base: string): string { + const taken = new Set(project.assets.map((a) => a.name)); + return taken.has(base) ? firstFree(taken, (i) => `${base} ${i}`, 2) : base; +} + +/** "var1", "var2", … – the first variable name not yet used by the behavior. */ +export function nextVariableName(asset: BehaviorAsset, base = 'var'): string { + return firstFree(new Set(asset.variables.map((v) => v.key)), (i) => `${base}${i}`, 1); +} diff --git a/src/core/commands/projectCommands.ts b/src/core/commands/projectCommands.ts index e1d042da..0aa30b47 100644 --- a/src/core/commands/projectCommands.ts +++ b/src/core/commands/projectCommands.ts @@ -16,13 +16,3 @@ export class RenameProjectCommand implements Command { project.name = this.previous; } } - -/** "Sprite", "Sprite 2", "Sprite 3" … – the first name not yet used by an asset. */ -export function nextAssetName(project: Project, base: string): string { - const taken = new Set(project.assets.map((a) => a.name)); - if (!taken.has(base)) return base; - for (let i = 2; ; i++) { - const candidate = `${base} ${i}`; - if (!taken.has(candidate)) return candidate; - } -} diff --git a/src/core/commands/require.ts b/src/core/commands/require.ts new file mode 100644 index 00000000..46b8303f --- /dev/null +++ b/src/core/commands/require.ts @@ -0,0 +1,49 @@ +/** + * Lookups that throw a `CommandError` when the target does not exist. Commands validate + * their inputs with these before mutating anything, so an invalid edit is rejected with a + * message the editor can show. + */ +import { findNode, type GraphNode } from '../graph/Graph'; +import type { Asset, BehaviorAsset, SceneAsset } from '../model/Asset'; +import { findObject } from '../model/hierarchy'; +import { findAsset, findScene, type Project } from '../model/Project'; +import type { SceneObject } from '../model/SceneObject'; +import { propDef, type PropertyDef } from '../properties/PropertySchema'; +import { CommandError } from './Command'; + +export function requireAsset(project: Project, assetId: string): Asset { + const asset = findAsset(project, assetId); + if (!asset) throw new CommandError(`Asset ${assetId} not found`); + return asset; +} + +export function requireScene(project: Project, sceneId: string): SceneAsset { + const scene = findScene(project, sceneId); + if (!scene) throw new CommandError(`Scene ${sceneId} not found`); + return scene; +} + +export function requireObject(project: Project, sceneId: string, objectId: string): SceneObject { + const object = findObject(requireScene(project, sceneId), objectId); + if (!object) throw new CommandError(`Scene object ${objectId} not found`); + return object; +} + +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`); + return asset; +} + +export function requireNode(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; +} + +/** The schema entry for `key` on the asset – a property the asset's type (or the behavior) declares. */ +export function requirePropDef(asset: Asset, key: string): PropertyDef { + const def = propDef(asset, key); + if (!def) throw new CommandError(`Property "${key}" is not defined for ${asset.type}`); + return def; +} diff --git a/src/core/commands/sceneObjectCommands.ts b/src/core/commands/sceneObjectCommands.ts index be7f859a..9dc77103 100644 --- a/src/core/commands/sceneObjectCommands.ts +++ b/src/core/commands/sceneObjectCommands.ts @@ -1,55 +1,14 @@ -import { isPlaceableType } from '../model/AssetTypes'; -import { descendantsOf, isSelfOrAncestor, localPositionFor, type ObjectTree, worldPosition } from '../model/hierarchy'; -import type { Position, PropertyValue, Vec2 } from '../model/Properties'; -import { findScene, findSceneObject, type Project } from '../model/Project'; -import { createSceneObject, type SceneObject } from '../model/SceneObject'; -import { coerceValue, schemaForAsset } from '../properties/PropertySchema'; import { newId } from '../ids'; -import { type Command, CommandError } from './Command'; -import { requireAsset } from './assetCommands'; +import { isPlaceableType } from '../model/AssetTypes'; import { instantiateContainer } from '../model/container'; - -function requireScene(project: Project, sceneId: string) { - const scene = findScene(project, sceneId); - if (!scene) throw new CommandError(`Scene ${sceneId} not found`); - return scene; -} - -export function requireObject(project: Project, sceneId: string, objectId: string): SceneObject { - const object = findSceneObject(requireScene(project, sceneId), objectId); - if (!object) throw new CommandError(`Scene object ${objectId} not found`); - return object; -} - -/** An object taken out of a scene together with the index it had, so undo can put it back. */ -export interface RemovedObject { - object: SceneObject; - index: number; -} - -/** - * Removes the given objects and all their descendants from the object list (a subtree goes - * as a whole; ids that are descendants of other ids are simply covered). Returns what was - * removed in ascending index order. - */ -export function removeSubtrees(tree: ObjectTree, rootIds: readonly string[]): RemovedObject[] { - const ids = new Set(); - for (const rootId of rootIds) { - ids.add(rootId); - for (const child of descendantsOf(tree, rootId)) ids.add(child.id); - } - const removed: RemovedObject[] = []; - tree.objects.forEach((object, index) => { - if (ids.has(object.id)) removed.push({ object, index }); - }); - tree.objects = tree.objects.filter((o) => !ids.has(o.id)); - return removed; -} - -/** Puts removed objects back at their former indices (ascending order restores them exactly). */ -export function restoreObjects(tree: ObjectTree, removed: RemovedObject[]): void { - for (const entry of [...removed].sort((a, b) => a.index - b.index)) tree.objects.splice(entry.index, 0, entry.object); -} +import { isSelfOrAncestor, localPositionFor, removeSubtrees, worldPosition } from '../model/hierarchy'; +import { type Position, type PropertyValue, setBagValue, toPosition, type Vec2 } from '../model/Properties'; +import type { Project } from '../model/Project'; +import { createSceneObject, type SceneObject } from '../model/SceneObject'; +import { coerceValue } from '../properties/PropertySchema'; +import { type Removed, restoreAt } from '../util/list'; +import { type Command, CommandError } from './Command'; +import { requireAsset, requireObject, requirePropDef, requireScene } from './require'; /** * Places an asset into a scene, creating a scene object. Scenes cannot be placed. The @@ -74,8 +33,8 @@ export class PlaceAssetCommand implements Command { const asset = requireAsset(project, this.assetId); if (!isPlaceableType(asset.type)) throw new CommandError('A scene cannot be placed into a scene'); if (this.parentId) requireObject(project, this.sceneId, this.parentId); - const world = { x: this.position.x, y: this.position.y, z: 'z' in this.position ? this.position.z : 0 }; - const created = [createSceneObject(this.objectId, this.assetId, localPositionFor(scene, this.parentId, world), this.parentId)]; + const local = localPositionFor(scene, this.parentId, toPosition(this.position)); + const created = [createSceneObject(this.objectId, this.assetId, local, this.parentId)]; if (asset.type === 'container') created.push(...instantiateContainer(project, asset, this.objectId)); this.createdIds = created.map((o) => o.id); scene.objects.push(...created); @@ -88,29 +47,14 @@ export class PlaceAssetCommand implements Command { } } -/** Removes an object and everything parented to it. */ -export class RemoveSceneObjectCommand implements Command { - readonly label = 'Remove object'; - private removed: RemovedObject[] = []; - - constructor(private readonly sceneId: string, private readonly objectId: string) {} - - execute(project: Project): void { - requireObject(project, this.sceneId, this.objectId); - this.removed = removeSubtrees(requireScene(project, this.sceneId), [this.objectId]); - } - - undo(project: Project): void { - restoreObjects(requireScene(project, this.sceneId), this.removed); - } -} - -/** Removes several objects (a multi-selection) with their subtrees as one undo step. */ +/** Removes objects (a multi-selection) together with everything parented below them, as one undo step. */ export class RemoveSceneObjectsCommand implements Command { - readonly label = 'Remove objects'; - private removed: RemovedObject[] = []; + readonly label: string; + private removed: Removed[] = []; - constructor(private readonly sceneId: string, private readonly objectIds: readonly string[]) {} + constructor(private readonly sceneId: string, private readonly objectIds: readonly string[]) { + this.label = objectIds.length === 1 ? 'Remove object' : 'Remove objects'; + } execute(project: Project): void { for (const id of this.objectIds) requireObject(project, this.sceneId, id); @@ -118,28 +62,32 @@ export class RemoveSceneObjectsCommand implements Command { } undo(project: Project): void { - restoreObjects(requireScene(project, this.sceneId), this.removed); + restoreAt(requireScene(project, this.sceneId).objects, this.removed); } } -/** Sets the full local position (x, y and draw order z) of a scene object. */ +/** Removes one object and everything parented to it. */ +export class RemoveSceneObjectCommand extends RemoveSceneObjectsCommand { + constructor(sceneId: string, objectId: string) { + super(sceneId, [objectId]); + } +} + +/** + * Sets the full local position (x, y and draw order z) of a scene object. A caller that + * previewed the move must restore the model before executing: the previous value is read + * from the object at execution time. + */ export class MoveSceneObjectCommand implements Command { readonly label = 'Move object'; private previous: Position = { x: 0, y: 0, z: 0 }; - constructor( - private readonly sceneId: string, - private readonly objectId: string, - private readonly position: Position, - previous?: Position, - ) { - if (previous) this.previous = { ...previous }; - } + constructor(private readonly sceneId: string, private readonly objectId: string, private readonly position: Position) {} execute(project: Project): void { const object = requireObject(project, this.sceneId, this.objectId); this.previous = { ...object.position }; - object.position = { x: this.position.x, y: this.position.y, z: this.position.z }; + object.position = { ...this.position }; } undo(project: Project): void { @@ -156,14 +104,7 @@ export class SetEditorOffsetCommand implements Command { readonly label = 'Editor offset'; private previous: Vec2 = { x: 0, y: 0 }; - constructor( - private readonly sceneId: string, - private readonly objectId: string, - private readonly offset: Vec2, - previous?: Vec2, - ) { - if (previous) this.previous = { ...previous }; - } + constructor(private readonly sceneId: string, private readonly objectId: string, private readonly offset: Vec2) {} execute(project: Project): void { const object = requireObject(project, this.sceneId, this.objectId); @@ -209,17 +150,13 @@ export class SetOverrideCommand implements Command { execute(project: Project): void { const object = requireObject(project, this.sceneId, this.objectId); - const asset = requireAsset(project, object.assetId); - const def = schemaForAsset(asset).find((d) => d.key === this.key); - if (!def) throw new CommandError(`Property "${this.key}" is not defined for ${asset.type}`); + const def = requirePropDef(requireAsset(project, object.assetId), this.key); this.previous = object.overrides[this.key]; object.overrides[this.key] = coerceValue(def, this.value); } undo(project: Project): void { - const object = requireObject(project, this.sceneId, this.objectId); - if (this.previous === undefined) delete object.overrides[this.key]; - else object.overrides[this.key] = this.previous; + setBagValue(requireObject(project, this.sceneId, this.objectId).overrides, this.key, this.previous); } } @@ -239,8 +176,7 @@ export class ClearOverrideCommand implements Command { } undo(project: Project): void { - if (this.previous === undefined) return; - requireObject(project, this.sceneId, this.objectId).overrides[this.key] = this.previous; + setBagValue(requireObject(project, this.sceneId, this.objectId).overrides, this.key, this.previous); } } diff --git a/src/core/commands/variableCommands.ts b/src/core/commands/variableCommands.ts index 947fd60b..a27d0bb4 100644 --- a/src/core/commands/variableCommands.ts +++ b/src/core/commands/variableCommands.ts @@ -1,24 +1,8 @@ -import { nodeDef } from '../graph/NodeRegistry'; -import type { BehaviorAsset } from '../model/Asset'; import type { PropertyValue } from '../model/Properties'; -import { findAsset, findUsages, type Project } from '../model/Project'; +import { findUsages, type Project } from '../model/Project'; import { isCommonProp, type PropertyDef, type VariableKind, variableDef } from '../properties/PropertySchema'; import { type Command, CommandError } from './Command'; - -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`); - return asset; -} - -/** "var1", "var2", … – the first variable name not yet used by the behavior. */ -export function nextVariableName(asset: BehaviorAsset, base = 'var'): string { - const taken = new Set(asset.variables.map((v) => v.key)); - for (let i = 1; ; i++) { - const candidate = `${base}${i}`; - if (!taken.has(candidate)) return candidate; - } -} +import { requireBehavior } from './require'; /** Declares a new variable on a behavior; its default value is written to `props`. */ export class AddVariableCommand implements Command { @@ -32,7 +16,7 @@ export class AddVariableCommand implements Command { execute(project: Project): void { const asset = requireBehavior(project, this.assetId); - if (!isValidVariableName(this.def.key)) throw new CommandError('Variable names must start with a letter and contain only letters, digits or _'); + if (!isValidVariableName(this.def.key)) throw new CommandError(INVALID_NAME); if (asset.variables.some((v) => v.key === this.def.key)) throw new CommandError(`Variable "${this.def.key}" already exists`); asset.variables.push(this.def); asset.props[this.def.key] = this.def.default; @@ -77,8 +61,9 @@ export class RemoveVariableCommand implements Command { const asset = requireBehavior(project, this.assetId); asset.variables.splice(this.removed.index, 0, this.removed.def); if (this.removed.value !== undefined) asset.props[this.key] = this.removed.value; + const usages = findUsages(project, this.assetId); for (const o of this.overrides) { - const object = findUsages(project, this.assetId).find((u) => u.scene.id === o.sceneId && u.object.id === o.objectId)?.object; + const object = usages.find((u) => u.scene.id === o.sceneId && u.object.id === o.objectId)?.object; if (object) object.overrides[this.key] = o.value; } } @@ -103,7 +88,7 @@ export class RenameVariableCommand implements Command { private rename(project: Project, from: string, to: string): void { const asset = requireBehavior(project, this.assetId); if (from === to) return; - if (!isValidVariableName(to)) throw new CommandError('Variable names must start with a letter and contain only letters, digits or _'); + if (!isValidVariableName(to)) throw new CommandError(INVALID_NAME); const def = asset.variables.find((v) => v.key === from); if (!def) throw new CommandError(`Variable "${from}" not found`); if (asset.variables.some((v) => v.key === to)) throw new CommandError(`Variable "${to}" already exists`); @@ -118,16 +103,14 @@ export class RenameVariableCommand implements Command { object.overrides[to] = object.overrides[from]!; delete object.overrides[from]; } - for (const node of asset.graph.nodes) { - const def = nodeDef(node.type); - for (const param of def?.params ?? []) { - if (param.key === 'variable' && node.params.variable === from) node.params.variable = to; - } - } + // Every node that picks a variable stores it under the param key `variable`. + for (const node of asset.graph.nodes) if (node.params.variable === from) node.params.variable = to; } } +const INVALID_NAME = 'Variable names must start with a letter and contain only letters, digits or _'; + +/** Identifier-like, and not one of the common property keys (a variable "enabled" would shadow the switch). */ export function isValidVariableName(name: string): boolean { - // Common property keys (enabled) are reserved: a variable of that name would shadow the switch. return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && !isCommonProp(name); } diff --git a/src/core/graph/GraphInterpreter.ts b/src/core/graph/GraphInterpreter.ts index a3a9c8f0..8dee24e9 100644 --- a/src/core/graph/GraphInterpreter.ts +++ b/src/core/graph/GraphInterpreter.ts @@ -1,5 +1,7 @@ import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph'; -import { hasExecInput, inlineValue, isEventNode, type NodeContext, requireNodeDef, type Value } from './NodeRegistry'; +import type { NodeContext } from './NodeContext'; +import { hasExecInput, inlineValue, isEventNode, requireNodeDef } from './NodeRegistry'; +import type { Value } from './values'; /** * Guards against runaway flows (a Sequence looping back into itself, …). The interpreter diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts new file mode 100644 index 00000000..fffad756 --- /dev/null +++ b/src/core/graph/NodeContext.ts @@ -0,0 +1,98 @@ +import type { PropertyBag } from '../model/Properties'; +import { enabledAlongChain } from '../properties/enabled'; + +/** + * What a graph sees of a running entity. The runtime's Entity satisfies this shape; unit + * tests use plain objects. Mutating `x`/`y`/`props` is how actions take effect. + */ +export interface EntityHandle { + id: string; + /** Asset this entity was instantiated from (what `Is asset` compares against). */ + assetId: string; + name: string; + /** Position local to `parent` (world position for top-level entities). */ + x: number; + y: number; + z: number; + props: PropertyBag; + /** The entity this one is parented to; children move with their parent. */ + parent: EntityHandle | null; +} + +/** World position of a handle: its local position plus those of all ancestors. */ +export function worldPositionOf(handle: EntityHandle): { x: number; y: number; z: number } { + const result = { x: handle.x, y: handle.y, z: handle.z }; + const seen = new Set([handle]); + for (let p = handle.parent; p && !seen.has(p); p = p.parent) { + result.x += p.x; + result.y += p.y; + result.z += p.z; + seen.add(p); + } + return result; +} + +/** Whether a handle and every ancestor of it are switched on (see `properties/enabled.ts`). */ +export function isHandleEnabled(handle: EntityHandle): boolean { + return enabledAlongChain(handle, (h) => h.props, (h) => h.parent); +} + +/** Where the mouse is (world units) and what it did this frame. */ +export interface PointerState { + x: number; + y: number; + /** Whether the pointer is over the game view at all. */ + inside: boolean; + down: boolean; + /** Went down / up during this frame. */ + pressed: boolean; + released: boolean; +} + +/** + * Services a graph may use while running. Implemented by the runtime (BehaviorSystem) and + * by test doubles. Everything the graph does to the world goes through here, so the core + * stays free of runtime imports. + */ +export interface NodeContext { + /** The behavior entity itself. */ + self: EntityHandle; + /** The behavior's parent object (the one the graph acts on), if any. */ + owner: EntityHandle | null; + /** Mutable variable state of this behavior instance. */ + variables: PropertyBag; + /** Seconds since the world started and since the last tick. */ + time: number; + dt: number; + findByName(name: string): EntityHandle | null; + isKeyDown(key: string): boolean; + /** Mouse state of this frame, in world units. */ + pointer: PointerState; + /** + * Whether a world point lies on `target` – on its colliders when it has any, otherwise on + * its own footprint or that of anything parented below it (so a button made of a container + * with a sprite and a label is hit as a whole). + */ + hitTest(target: EntityHandle, x: number, y: number): boolean; + /** + * Whether any collider of `a` overlaps any collider of `b` right now. A collider entity + * stands for itself; any other entity stands for its direct collider children. + */ + isTouching(a: EntityHandle, b: EntityHandle): boolean; + /** + * Moves `target` by (dx, dy) in local units, stopping its solid colliders at other solid + * colliders axis by axis (x first, then y) so it slides along walls. Reports which axes hit. + */ + moveAndSlide(target: EntityHandle, dx: number, dy: number): { blockedX: boolean; blockedY: boolean }; + log(message: string): void; + /** + * Places a new instance of an asset into the running scene and returns it. The position is + * local to `parent` (world when none). Returns null when the asset does not exist or cannot + * be placed (a scene). Spawned behaviors run their `On start` on the next tick. + */ + spawn(assetId: string, position: { x: number; y: number; z: number }, parent: EntityHandle | null): EntityHandle | null; + destroy(target: EntityHandle): void; + stop(): void; + /** Ends the run and starts it again from the saved scene (everything spawned is lost). */ + restart(): void; +} diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index b816d825..2d584a0d 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -1,106 +1,8 @@ import type { PropertyBag, PropertyValue } from '../model/Properties'; -import { enabledAlongChain } from '../properties/enabled'; import type { PropertyDef } from '../properties/PropertySchema'; import type { PortType } from './Graph'; - -/** A value travelling along a data edge. Objects are live entity handles (play mode only). */ -export type Value = PropertyValue | EntityHandle | null; - -/** - * What the graph sees of a running entity. The runtime's Entity satisfies this shape; unit - * tests use plain objects. Mutating `x`/`y`/`props` is how actions take effect. - */ -export interface EntityHandle { - id: string; - /** Asset this entity was instantiated from (what `Is asset` compares against). */ - assetId: string; - name: string; - /** Position local to `parent` (world position for top-level entities). */ - x: number; - y: number; - z: number; - props: PropertyBag; - /** The entity this one is parented to; children move with their parent. */ - parent: EntityHandle | null; -} - -/** World position of a handle: its local position plus those of all ancestors. */ -export function worldPositionOf(handle: EntityHandle): { x: number; y: number; z: number } { - const result = { x: handle.x, y: handle.y, z: handle.z }; - const seen = new Set([handle]); - for (let p = handle.parent; p && !seen.has(p); p = p.parent) { - result.x += p.x; - result.y += p.y; - result.z += p.z; - seen.add(p); - } - return result; -} - -/** Whether a handle and every ancestor of it are switched on (see `properties/enabled.ts`). */ -export function isHandleEnabled(handle: EntityHandle): boolean { - return enabledAlongChain(handle, (h) => h.props, (h) => h.parent); -} - -/** Where the mouse is (world units) and what it did this frame. */ -export interface PointerState { - x: number; - y: number; - /** Whether the pointer is over the game view at all. */ - inside: boolean; - down: boolean; - /** Went down / up during this frame. */ - pressed: boolean; - released: boolean; -} - -/** - * Services a graph may use while running. Implemented by the runtime (BehaviorSystem) and - * by test doubles. Everything the graph does to the world goes through here, so the core - * stays free of runtime imports. - */ -export interface NodeContext { - /** The behavior entity itself. */ - self: EntityHandle; - /** The behavior's parent object (the one the graph acts on), if any. */ - owner: EntityHandle | null; - /** Mutable variable state of this behavior instance. */ - variables: PropertyBag; - /** Seconds since the world started and since the last tick. */ - time: number; - dt: number; - findByName(name: string): EntityHandle | null; - isKeyDown(key: string): boolean; - /** Mouse state of this frame, in world units (see `PointerState`). */ - pointer: PointerState; - /** - * Whether a world point lies on `target` – on its colliders when it has any, otherwise on - * its own footprint or that of anything parented below it (so a button made of a container - * with a sprite and a label is hit as a whole). - */ - hitTest(target: EntityHandle, x: number, y: number): boolean; - /** - * Whether any collider of `a` overlaps any collider of `b` right now. A collider entity - * stands for itself; any other entity stands for its direct collider children. - */ - isTouching(a: EntityHandle, b: EntityHandle): boolean; - /** - * Moves `target` by (dx, dy) in local units, stopping its solid colliders at other solid - * colliders axis by axis (x first, then y) so it slides along walls. Reports which axes hit. - */ - moveAndSlide(target: EntityHandle, dx: number, dy: number): { blockedX: boolean; blockedY: boolean }; - log(message: string): void; - /** - * Places a new instance of an asset into the running scene and returns it. The position is - * local to `parent` (world when none). Returns null when the asset does not exist or cannot - * be placed (a scene). Spawned behaviors run their `On start` on the next tick. - */ - spawn(assetId: string, position: { x: number; y: number; z: number }, parent: EntityHandle | null): EntityHandle | null; - destroy(target: EntityHandle): void; - stop(): void; - /** Ends the run and starts it again from the saved scene (everything spawned is lost). */ - restart(): void; -} +import type { NodeContext } from './NodeContext'; +import { asBoolean, asNumber, asString, type Value } from './values'; export interface PortDef { key: string; @@ -128,7 +30,7 @@ export interface NodeDef { type: string; label: string; category: NodeCategory; - /** Shown in the palette. */ + /** Shown in the palette and the inspector. */ description: string; inputs: PortDef[]; outputs: PortDef[]; @@ -177,7 +79,7 @@ export function inlineInputs(def: NodeDef): PortDef[] { return def.inputs.filter((p) => p.type !== 'exec' && p.type !== 'object'); } -/** A PropertyDef for editing an inline input value of the given port. */ +/** A PropertyDef for editing the inline value of a port. */ export function inlineInputDef(port: PortDef): PropertyDef { switch (port.type) { case 'boolean': @@ -194,30 +96,3 @@ export function inlineInputDef(port: PortDef): PropertyDef { export function inlineValue(node: { params: PropertyBag }, port: PortDef): Value { return node.params[port.key] ?? port.default ?? null; } - -// --- value helpers for node implementations --------------------------------------------- - -export function asNumber(value: Value | undefined): number { - if (typeof value === 'number') return value; - if (typeof value === 'boolean') return value ? 1 : 0; - if (typeof value === 'string') { - const n = Number(value); - return Number.isFinite(n) ? n : 0; - } - return 0; -} - -export function asBoolean(value: Value | undefined): boolean { - if (typeof value === 'string') return value !== '' && value !== 'false' && value !== '0'; - return Boolean(value); -} - -export function asString(value: Value | undefined): string { - if (value === null || value === undefined) return ''; - if (typeof value === 'object') return value.name; - return String(value); -} - -export function asEntity(value: Value | undefined): EntityHandle | null { - return value && typeof value === 'object' ? value : null; -} diff --git a/src/core/graph/index.ts b/src/core/graph/index.ts index 26ef34bd..64fbedd5 100644 --- a/src/core/graph/index.ts +++ b/src/core/graph/index.ts @@ -2,5 +2,7 @@ import './nodes'; export * from './Graph'; +export * from './NodeContext'; export * from './NodeRegistry'; +export * from './values'; export { GraphInterpreter, type TraceListener } from './GraphInterpreter'; diff --git a/src/core/graph/nodes.ts b/src/core/graph/nodes.ts deleted file mode 100644 index 5ca75bd7..00000000 --- a/src/core/graph/nodes.ts +++ /dev/null @@ -1,572 +0,0 @@ -import type { PropertyDef } from '../properties/PropertySchema'; -import { asBoolean, asEntity, asNumber, asString, isHandleEnabled, type PortDef, registerNode, worldPositionOf } from './NodeRegistry'; - -/** - * The built-in node set. Each entry is data + a `run` function; everything else (palette, - * ports, inspector fields, validation) is derived. Importing this module registers them. - */ - -const exec = (key = 'exec', label = ''): PortDef => ({ key, label, type: 'exec' }); -const num = (key: string, label = key): PortDef => ({ key, label, type: 'number' }); -const bool = (key: string, label = key): PortDef => ({ key, label, type: 'boolean' }); -const obj = (key: string, label = key): PortDef => ({ key, label, type: 'object' }); -const any = (key: string, label = key): PortDef => ({ key, label, type: 'any' }); - -const keyParam: PropertyDef = { key: 'key', label: 'Key', kind: 'key', default: 'ArrowRight' }; -/** Placeholder: the graph inspector replaces the options with the asset's variables. */ -const variableParam: PropertyDef = { key: 'variable', label: 'Variable', kind: 'enum', default: '', options: [] }; - -// --- events ------------------------------------------------------------------------------- - -registerNode({ type: 'OnStart', label: 'On start', category: 'event', description: 'Runs once when the game starts.', inputs: [], outputs: [exec()], params: [] }); -registerNode({ type: 'OnUpdate', label: 'On update', category: 'event', description: 'Runs every frame.', inputs: [], outputs: [exec(), num('dt', 'dt')], params: [] }); -registerNode({ type: 'OnKeyDown', label: 'On key down', category: 'event', description: 'Runs once when the key is pressed.', inputs: [], outputs: [exec()], params: [keyParam] }); -registerNode({ type: 'OnKeyUp', label: 'On key up', category: 'event', description: 'Runs once when the key is released.', inputs: [], outputs: [exec()], params: [keyParam] }); -registerNode({ type: 'OnKeyHeld', label: 'On key held', category: 'event', description: 'Runs every frame while the key is held.', inputs: [], outputs: [exec(), num('dt', 'dt')], params: [keyParam] }); -registerNode({ - type: 'OnTimer', - label: 'On timer', - category: 'event', - description: 'Runs every N seconds.', - inputs: [], - outputs: [exec()], - params: [{ key: 'interval', label: 'Interval (s)', kind: 'number', default: 1, min: 0.01, step: 0.1 }], -}); -// Collision events reach the behavior whose owner is the collider's body (the collider's -// parent, or the collider itself when it is top-level). `other` is the other body. -const collisionOutputs = (): PortDef[] => [exec(), obj('other'), obj('otherCollider', 'other collider'), obj('collider')]; -registerNode({ type: 'OnCollisionEnter', label: 'On collision enter', category: 'event', description: 'Runs once when a collider of the owner starts overlapping another collider.', inputs: [], outputs: collisionOutputs(), params: [] }); -registerNode({ type: 'OnCollisionStay', label: 'On collision stay', category: 'event', description: 'Runs every frame while a collider of the owner overlaps another collider.', inputs: [], outputs: collisionOutputs(), params: [] }); -registerNode({ type: 'OnCollisionExit', label: 'On collision exit', category: 'event', description: 'Runs once when a collider of the owner stops overlapping another collider.', inputs: [], outputs: collisionOutputs(), params: [] }); -registerNode({ - type: 'OnClick', - label: 'On click', - category: 'event', - description: 'Runs when the mouse is pressed on the owner (or on anything parented below it) in the Game tab. Without an owner it runs on a click anywhere in the game view.', - inputs: [], - outputs: [exec(), num('x', 'world x'), num('y', 'world y')], - params: [], -}); - -// --- flow --------------------------------------------------------------------------------- - -registerNode({ - type: 'Branch', - label: 'Branch', - category: 'flow', - description: 'Continues along "then" or "else" depending on the condition.', - inputs: [exec(), bool('condition')], - outputs: [exec('then', 'then'), exec('else', 'else')], - params: [], - run: (_ctx, inputs) => ({ next: asBoolean(inputs.condition) ? 'then' : 'else' }), -}); -registerNode({ - type: 'Sequence', - label: 'Sequence', - category: 'flow', - description: 'Runs its outputs one after another.', - inputs: [exec()], - outputs: [exec('a', '1'), exec('b', '2'), exec('c', '3')], - params: [], - run: () => ({ next: ['a', 'b', 'c'] }), -}); - -// --- data: references -------------------------------------------------------------------- - -registerNode({ type: 'Self', label: 'Self', category: 'data', description: 'This behavior object.', inputs: [], outputs: [obj('object')], params: [], run: (ctx) => ({ outputs: { object: ctx.self } }) }); -registerNode({ type: 'Owner', label: 'Owner', category: 'data', description: 'The object this behavior is parented to.', inputs: [], outputs: [obj('object')], params: [], run: (ctx) => ({ outputs: { object: ctx.owner } }) }); -registerNode({ - type: 'Parent', - label: 'Parent', - category: 'data', - description: 'The parent of an object (none for top-level objects).', - inputs: [obj('object')], - outputs: [obj('parent')], - params: [], - run: (_ctx, inputs) => ({ outputs: { parent: asEntity(inputs.object)?.parent ?? null } }), -}); -registerNode({ - type: 'NameOf', - label: 'Name of', - category: 'data', - description: 'The name of an object (empty when there is none).', - inputs: [obj('object')], - outputs: [{ key: 'name', label: 'name', type: 'string' }], - params: [], - run: (_ctx, inputs) => ({ outputs: { name: asEntity(inputs.object)?.name ?? '' } }), -}); -registerNode({ - type: 'IsAsset', - label: 'Is asset', - category: 'data', - description: 'Whether an object is an instance of the chosen asset – the way to tell what you collided with.', - inputs: [obj('object')], - outputs: [bool('result')], - // Placeholder options: the graph inspector lists the project's placeable assets. - params: [{ key: 'asset', label: 'Asset', kind: 'enum', default: '', options: [] }], - run: (_ctx, inputs, params) => { - const assetId = String(params.asset ?? ''); - return { outputs: { result: Boolean(assetId) && asEntity(inputs.object)?.assetId === assetId } }; - }, -}); -registerNode({ - type: 'ObjectByName', - label: 'Object by name', - category: 'data', - description: 'Finds a scene object by its name.', - inputs: [], - outputs: [obj('object')], - params: [{ key: 'name', label: 'Name', kind: 'text', default: '' }], - run: (ctx, _inputs, params) => ({ outputs: { object: ctx.findByName(String(params.name ?? '')) } }), -}); - -// --- variables ---------------------------------------------------------------------------- - -registerNode({ - type: 'GetVariable', - label: 'Get variable', - category: 'variable', - description: 'Reads one of this behavior\'s variables.', - inputs: [], - outputs: [any('value')], - params: [variableParam], - run: (ctx, _inputs, params) => ({ outputs: { value: ctx.variables[String(params.variable ?? '')] ?? null } }), -}); -registerNode({ - type: 'SetVariable', - label: 'Set variable', - category: 'variable', - description: 'Writes one of this behavior\'s variables.', - inputs: [exec(), any('value')], - outputs: [exec()], - params: [variableParam], - run: (ctx, inputs, params) => { - const key = String(params.variable ?? ''); - const current = ctx.variables[key]; - const value = inputs.value; - if (key) { - if (typeof current === 'number') ctx.variables[key] = asNumber(value); - else if (typeof current === 'boolean') ctx.variables[key] = asBoolean(value); - else ctx.variables[key] = asString(value); - } - return { next: 'exec' }; - }, -}); - -// --- data: properties and positions -------------------------------------------------------- - -registerNode({ - type: 'GetProperty', - label: 'Get property', - category: 'data', - description: 'Reads a property (width, color, …) of an object.', - inputs: [obj('object')], - outputs: [any('value')], - params: [{ key: 'property', label: 'Property', kind: 'text', default: 'width' }], - run: (_ctx, inputs, params) => ({ outputs: { value: asEntity(inputs.object)?.props[String(params.property ?? '')] ?? null } }), -}); -registerNode({ - type: 'SetProperty', - label: 'Set property', - category: 'action', - description: 'Writes a property (width, color, …) of an object.', - inputs: [exec(), obj('object'), any('value')], - outputs: [exec()], - params: [{ key: 'property', label: 'Property', kind: 'text', default: 'width' }], - run: (_ctx, inputs, params) => { - const target = asEntity(inputs.object); - const key = String(params.property ?? ''); - if (target && key) { - const current = target.props[key]; - const value = inputs.value; - if (typeof current === 'number') target.props[key] = asNumber(value); - else if (typeof current === 'boolean') target.props[key] = asBoolean(value); - else target.props[key] = asString(value); - } - return { next: 'exec' }; - }, -}); -registerNode({ - type: 'GetPosition', - label: 'Get position', - category: 'data', - description: 'The position of an object: local to its parent (x/y) and in world units (world x/y).', - inputs: [obj('object')], - outputs: [num('x'), num('y'), num('worldX', 'world x'), num('worldY', 'world y')], - params: [], - run: (_ctx, inputs) => { - const target = asEntity(inputs.object); - const world = target ? worldPositionOf(target) : { x: 0, y: 0 }; - return { outputs: { x: target?.x ?? 0, y: target?.y ?? 0, worldX: world.x, worldY: world.y } }; - }, -}); - -// --- data: literals and math --------------------------------------------------------------- - -registerNode({ type: 'Number', label: 'Number', category: 'data', description: 'A constant number.', inputs: [], outputs: [num('value')], params: [{ key: 'value', label: 'Value', kind: 'number', default: 0, step: 1 }], run: (_c, _i, p) => ({ outputs: { value: asNumber(p.value) } }) }); -registerNode({ type: 'Boolean', label: 'Boolean', category: 'data', description: 'A constant true/false.', inputs: [], outputs: [bool('value')], params: [{ key: 'value', label: 'Value', kind: 'boolean', default: false }], run: (_c, _i, p) => ({ outputs: { value: asBoolean(p.value) } }) }); -registerNode({ type: 'String', label: 'String', category: 'data', description: 'A constant text.', inputs: [], outputs: [{ key: 'value', label: 'value', type: 'string' }], params: [{ key: 'value', label: 'Value', kind: 'text', default: '' }], run: (_c, _i, p) => ({ outputs: { value: asString(p.value) } }) }); - -const MATH: [string, string, string, (a: number, b: number) => number][] = [ - ['Add', 'Add', 'a + b', (a, b) => a + b], - ['Subtract', 'Subtract', 'a − b', (a, b) => a - b], - ['Multiply', 'Multiply', 'a × b', (a, b) => a * b], - ['Divide', 'Divide', 'a ÷ b (0 when b is 0)', (a, b) => (b === 0 ? 0 : a / b)], -]; -for (const [type, label, description, fn] of MATH) { - registerNode({ type, label, category: 'data', description, inputs: [num('a'), num('b')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: fn(asNumber(i.a), asNumber(i.b)) } }) }); -} -registerNode({ type: 'Min', label: 'Min', category: 'data', description: 'The smaller of a and b.', inputs: [num('a'), num('b')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: Math.min(asNumber(i.a), asNumber(i.b)) } }) }); -registerNode({ type: 'Max', label: 'Max', category: 'data', description: 'The larger of a and b.', inputs: [num('a'), num('b')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: Math.max(asNumber(i.a), asNumber(i.b)) } }) }); -registerNode({ type: 'Abs', label: 'Abs', category: 'data', description: 'Absolute value (drops the sign).', inputs: [num('value')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: Math.abs(asNumber(i.value)) } }) }); -registerNode({ type: 'Sqrt', label: 'Sqrt', category: 'data', description: 'Square root (0 for negative input).', inputs: [num('value')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: Math.sqrt(Math.max(0, asNumber(i.value))) } }) }); -registerNode({ - type: 'Clamp', - label: 'Clamp', - category: 'data', - description: 'Limits a value to the range min…max.', - inputs: [num('value'), { ...num('min'), default: 0 }, { ...num('max'), default: 1 }], - outputs: [num('result')], - params: [], - run: (_c, i) => ({ outputs: { result: Math.min(Math.max(asNumber(i.value), asNumber(i.min)), asNumber(i.max)) } }), -}); - -registerNode({ type: 'Floor', label: 'Floor', category: 'data', description: 'Rounds down to a whole number.', inputs: [num('value')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: Math.floor(asNumber(i.value)) } }) }); -registerNode({ type: 'Ceil', label: 'Ceil', category: 'data', description: 'Rounds up to a whole number.', inputs: [num('value')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: Math.ceil(asNumber(i.value)) } }) }); -registerNode({ type: 'Round', label: 'Round', category: 'data', description: 'Rounds to the nearest whole number.', inputs: [num('value')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: Math.round(asNumber(i.value)) } }) }); -registerNode({ - type: 'Modulo', - label: 'Modulo', - category: 'data', - description: 'Remainder of a ÷ b (0 when b is 0) – seconds within a minute, for instance.', - inputs: [num('a'), { ...num('b'), default: 1 }], - outputs: [num('result')], - params: [], - run: (_c, i) => { - const b = asNumber(i.b); - return { outputs: { result: b === 0 ? 0 : asNumber(i.a) % b } }; - }, -}); -registerNode({ - type: 'Random', - label: 'Random', - category: 'data', - description: 'A random number between min and max (min included, max not).', - inputs: [{ ...num('min'), default: 0 }, { ...num('max'), default: 1 }], - outputs: [num('result')], - params: [], - run: (_c, i) => { - const min = asNumber(i.min); - return { outputs: { result: min + Math.random() * (asNumber(i.max) - min) } }; - }, -}); -registerNode({ - type: 'Time', - label: 'Time', - category: 'data', - description: 'Seconds since the game started, and the length of the current frame.', - inputs: [], - outputs: [num('time'), num('dt')], - params: [], - run: (ctx) => ({ outputs: { time: ctx.time, dt: ctx.dt } }), -}); - -// --- data: text ------------------------------------------------------------------------------ - -registerNode({ - type: 'ToText', - label: 'To text', - category: 'data', - description: 'Turns a number into text with a fixed number of decimals.', - inputs: [any('value'), num('decimals')], - outputs: [{ key: 'text', label: 'text', type: 'string' }], - params: [], - run: (_c, i) => { - const value = i.value; - const decimals = Math.max(0, Math.min(10, Math.floor(asNumber(i.decimals)))); - return { outputs: { text: typeof value === 'string' ? value : asNumber(value).toFixed(decimals) } }; - }, -}); -registerNode({ - type: 'Join', - label: 'Join text', - category: 'data', - description: 'Sticks two values together as text ("Coins: " + 3).', - inputs: [any('a'), any('b')], - outputs: [{ key: 'text', label: 'text', type: 'string' }], - params: [], - run: (_c, i) => ({ outputs: { text: asString(i.a) + asString(i.b) } }), -}); -registerNode({ - type: 'Equals', - label: 'Equals', - category: 'data', - description: 'Whether two values are the same. Objects compare by identity, everything else as text.', - inputs: [any('a'), any('b')], - outputs: [bool('result')], - params: [], - run: (_c, i) => { - const a = asEntity(i.a); - const b = asEntity(i.b); - if (a || b) return { outputs: { result: a === b } }; - return { outputs: { result: asString(i.a) === asString(i.b) } }; - }, -}); - -// --- data: mouse ------------------------------------------------------------------------------ - -registerNode({ - type: 'MousePosition', - label: 'Mouse position', - category: 'data', - description: 'Where the mouse is in the Game tab, in world units.', - inputs: [], - outputs: [num('x'), num('y'), bool('inside', 'in view')], - params: [], - run: (ctx) => ({ outputs: { x: ctx.pointer.x, y: ctx.pointer.y, inside: ctx.pointer.inside } }), -}); -registerNode({ - type: 'MouseButton', - label: 'Mouse button', - category: 'data', - description: 'State of the mouse button: held, and whether it went down or up this frame.', - inputs: [], - outputs: [bool('down'), bool('pressed'), bool('released')], - params: [], - run: (ctx) => ({ outputs: { down: ctx.pointer.down, pressed: ctx.pointer.pressed, released: ctx.pointer.released } }), -}); -registerNode({ - type: 'IsHovered', - label: 'Is hovered', - category: 'data', - description: 'Whether the mouse is over an object (its colliders, else its own or its children\'s footprint).', - inputs: [obj('object')], - outputs: [bool('result')], - params: [], - run: (ctx, i) => { - const target = asEntity(i.object); - return { outputs: { result: Boolean(target && ctx.pointer.inside && ctx.hitTest(target, ctx.pointer.x, ctx.pointer.y)) } }; - }, -}); - -// --- data: vectors ------------------------------------------------------------------------- - -registerNode({ type: 'Length', label: 'Length', category: 'data', description: 'Length of the vector (x, y).', inputs: [num('x'), num('y')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: Math.hypot(asNumber(i.x), asNumber(i.y)) } }) }); -registerNode({ - type: 'Distance', - label: 'Distance', - category: 'data', - description: 'Distance between two objects.', - inputs: [obj('a'), obj('b')], - outputs: [num('result')], - params: [], - run: (_c, i) => { - const a = asEntity(i.a); - const b = asEntity(i.b); - return { outputs: { result: a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0 } }; - }, -}); -registerNode({ - type: 'Normalize', - label: 'Normalize', - category: 'data', - description: 'Scales the vector (x, y) to length 1; (0, 0) stays (0, 0).', - inputs: [num('x'), num('y')], - outputs: [num('x'), num('y'), num('length')], - params: [], - run: (_c, i) => ({ outputs: normalize(asNumber(i.x), asNumber(i.y)) }), -}); -registerNode({ - type: 'AxisInput', - label: 'Axis input', - category: 'data', - description: 'Direction from four keys as a normalized vector (diagonals are not faster).', - inputs: [], - outputs: [num('x'), num('y'), bool('active')], - params: [ - { key: 'left', label: 'Left', kind: 'key', default: 'ArrowLeft' }, - { key: 'right', label: 'Right', kind: 'key', default: 'ArrowRight' }, - { key: 'up', label: 'Up', kind: 'key', default: 'ArrowUp' }, - { key: 'down', label: 'Down', kind: 'key', default: 'ArrowDown' }, - ], - run: (ctx, _i, p) => { - const held = (key: string, fallback: string) => (ctx.isKeyDown(String(p[key] ?? fallback)) ? 1 : 0); - const { x, y, length } = normalize(held('right', 'ArrowRight') - held('left', 'ArrowLeft'), held('down', 'ArrowDown') - held('up', 'ArrowUp')); - return { outputs: { x, y, active: length > 0 } }; - }, -}); - -function normalize(x: number, y: number): { x: number; y: number; length: number } { - const length = Math.hypot(x, y); - return length === 0 ? { x: 0, y: 0, length: 0 } : { x: x / length, y: y / length, length }; -} - -const COMPARE: Record boolean> = { - '==': (a, b) => a === b, - '!=': (a, b) => a !== b, - '<': (a, b) => a < b, - '<=': (a, b) => a <= b, - '>': (a, b) => a > b, - '>=': (a, b) => a >= b, -}; -registerNode({ - type: 'Compare', - label: 'Compare', - category: 'data', - description: 'Compares two numbers.', - inputs: [num('a'), num('b')], - outputs: [bool('result')], - params: [{ key: 'op', label: 'Operator', kind: 'enum', default: '<', options: Object.keys(COMPARE).map((op) => ({ value: op, label: op })) }], - run: (_c, i, p) => ({ outputs: { result: (COMPARE[String(p.op)] ?? COMPARE['=='])!(asNumber(i.a), asNumber(i.b)) } }), -}); -registerNode({ type: 'And', label: 'And', category: 'data', description: 'Both true.', inputs: [bool('a'), bool('b')], outputs: [bool('result')], params: [], run: (_c, i) => ({ outputs: { result: asBoolean(i.a) && asBoolean(i.b) } }) }); -registerNode({ type: 'Or', label: 'Or', category: 'data', description: 'Either true.', inputs: [bool('a'), bool('b')], outputs: [bool('result')], params: [], run: (_c, i) => ({ outputs: { result: asBoolean(i.a) || asBoolean(i.b) } }) }); -registerNode({ type: 'Not', label: 'Not', category: 'data', description: 'Inverts a boolean.', inputs: [bool('value')], outputs: [bool('result')], params: [], run: (_c, i) => ({ outputs: { result: !asBoolean(i.value) } }) }); -registerNode({ type: 'KeyIsDown', label: 'Key is down', category: 'data', description: 'Whether a key is currently held.', inputs: [], outputs: [bool('down')], params: [keyParam], run: (ctx, _i, p) => ({ outputs: { down: ctx.isKeyDown(String(p.key ?? '')) } }) }); -registerNode({ - type: 'IsTouching', - label: 'Is touching', - category: 'data', - description: 'Whether the colliders of two objects overlap right now (an object stands for its collider children; a collider for itself).', - inputs: [obj('a'), obj('b')], - outputs: [bool('result')], - params: [], - run: (ctx, i) => { - const a = asEntity(i.a); - const b = asEntity(i.b); - return { outputs: { result: Boolean(a && b && ctx.isTouching(a, b)) } }; - }, -}); - -// --- actions -------------------------------------------------------------------------------- - -registerNode({ - type: 'Move', - label: 'Move', - category: 'action', - description: 'Moves an object by (dx, dy) × scale × dt. Connect dt to an event\'s dt for per-second movement; leave it at 1 for per-frame. With "Collide with solids" on, the object\'s solid colliders stop at other solid colliders and slide along them; blocked x/y tell which axes hit.', - inputs: [exec(), obj('object'), num('dx'), num('dy'), { ...num('scale'), default: 1 }, { ...num('dt'), default: 1 }], - outputs: [exec(), bool('blockedX', 'blocked x'), bool('blockedY', 'blocked y')], - params: [{ key: 'collide', label: 'Collide with solids', kind: 'boolean', default: false }], - run: (ctx, i, p) => { - const target = asEntity(i.object); - let blockedX = false; - let blockedY = false; - if (target) { - const factor = asNumber(i.scale) * asNumber(i.dt); - const dx = asNumber(i.dx) * factor; - const dy = asNumber(i.dy) * factor; - if (asBoolean(p.collide)) { - ({ blockedX, blockedY } = ctx.moveAndSlide(target, dx, dy)); - } else { - target.x += dx; - target.y += dy; - } - } - return { next: 'exec', outputs: { blockedX, blockedY } }; - }, -}); -registerNode({ - type: 'SetPosition', - label: 'Set position', - category: 'action', - description: 'Places an object at x/y.', - inputs: [exec(), obj('object'), num('x'), num('y')], - outputs: [exec()], - params: [], - run: (_c, i) => { - const target = asEntity(i.object); - if (target) { - target.x = asNumber(i.x); - target.y = asNumber(i.y); - } - return { next: 'exec' }; - }, -}); -registerNode({ - type: 'SetEnabled', - label: 'Set enabled', - category: 'action', - description: 'Switches an object on or off. A disabled object is not drawn, does not collide and its behavior stops – and so does everything parented below it. This is how screens (title, win, lose) are shown and hidden.', - inputs: [exec(), obj('object'), { ...bool('enabled'), default: true }], - outputs: [exec()], - params: [], - run: (_ctx, i) => { - const target = asEntity(i.object); - if (target) target.props.enabled = asBoolean(i.enabled); - return { next: 'exec' }; - }, -}); -registerNode({ - type: 'IsEnabled', - label: 'Is enabled', - category: 'data', - description: 'Whether an object is switched on – "own" is its own switch, "result" also requires every parent to be on.', - inputs: [obj('object')], - outputs: [bool('result'), bool('own', 'own switch')], - params: [], - run: (_ctx, i) => { - const target = asEntity(i.object); - if (!target) return { outputs: { result: false, own: false } }; - return { outputs: { result: isHandleEnabled(target), own: target.props.enabled !== false } }; - }, -}); -registerNode({ - type: 'SetText', - label: 'Set text', - category: 'action', - description: 'Writes the content of a text object (same as Set property "text").', - inputs: [exec(), obj('object'), any('text')], - outputs: [exec()], - params: [], - run: (_ctx, i) => { - const target = asEntity(i.object); - if (target) target.props.text = asString(i.text); - return { next: 'exec' }; - }, -}); -registerNode({ - type: 'Destroy', - label: 'Destroy', - category: 'action', - description: 'Removes an object from the running game.', - inputs: [exec(), obj('object')], - outputs: [exec()], - params: [], - run: (ctx, i) => { - const target = asEntity(i.object); - if (target) ctx.destroy(target); - return { next: 'exec' }; - }, -}); -registerNode({ - type: 'Spawn', - label: 'Spawn', - category: 'action', - description: 'Creates a new instance of an asset in the running scene at (x, y, z) – local to the parent when one is connected, world coordinates otherwise. Outputs the new object.', - inputs: [exec(), num('x'), num('y'), num('z'), obj('parent')], - outputs: [exec(), obj('object')], - // Placeholder options: the graph inspector lists the project's placeable assets. - params: [{ key: 'asset', label: 'Asset', kind: 'enum', default: '', options: [] }], - run: (ctx, i, p) => { - const assetId = String(p.asset ?? ''); - const spawned = assetId ? ctx.spawn(assetId, { x: asNumber(i.x), y: asNumber(i.y), z: asNumber(i.z) }, asEntity(i.parent)) : null; - if (!spawned) ctx.log(assetId ? `Spawn: asset ${assetId} not found` : 'Spawn: no asset selected'); - return { outputs: { object: spawned }, next: 'exec' }; - }, -}); -registerNode({ - type: 'Log', - label: 'Log', - category: 'action', - description: 'Prints a message to the console.', - inputs: [exec(), any('message')], - outputs: [exec()], - params: [], - run: (ctx, i) => { - ctx.log(asString(i.message)); - return { next: 'exec' }; - }, -}); -registerNode({ type: 'StopGame', label: 'Stop game', category: 'action', description: 'Ends play mode.', inputs: [exec()], outputs: [], params: [], run: (ctx) => { ctx.stop(); return {}; } }); -registerNode({ type: 'RestartGame', label: 'Restart game', category: 'action', description: 'Starts the scene over from its saved state – everything spawned, moved or destroyed is reset.', inputs: [exec()], outputs: [], params: [], run: (ctx) => { ctx.restart(); return {}; } }); diff --git a/src/core/graph/nodes/action/Destroy.ts b/src/core/graph/nodes/action/Destroy.ts new file mode 100644 index 00000000..45d35abb --- /dev/null +++ b/src/core/graph/nodes/action/Destroy.ts @@ -0,0 +1,18 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asEntity } from '../../values'; +import { exec, obj } from '../shared'; + +export const Destroy: NodeDef = { + type: 'Destroy', + label: 'Destroy', + category: 'action', + description: 'Removes an object from the running game.', + inputs: [exec(), obj('object')], + outputs: [exec()], + params: [], + run: (ctx, inputs) => { + const target = asEntity(inputs.object); + if (target) ctx.destroy(target); + return { next: 'exec' }; + }, +}; diff --git a/src/core/graph/nodes/action/Log.ts b/src/core/graph/nodes/action/Log.ts new file mode 100644 index 00000000..751379de --- /dev/null +++ b/src/core/graph/nodes/action/Log.ts @@ -0,0 +1,17 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asString } from '../../values'; +import { any, exec } from '../shared'; + +export const Log: NodeDef = { + type: 'Log', + label: 'Log', + category: 'action', + description: 'Prints a message to the console.', + inputs: [exec(), any('message')], + outputs: [exec()], + params: [], + run: (ctx, inputs) => { + ctx.log(asString(inputs.message)); + return { next: 'exec' }; + }, +}; diff --git a/src/core/graph/nodes/action/Move.ts b/src/core/graph/nodes/action/Move.ts new file mode 100644 index 00000000..55ecbbce --- /dev/null +++ b/src/core/graph/nodes/action/Move.ts @@ -0,0 +1,27 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asBoolean, asEntity, asNumber } from '../../values'; +import { bool, exec, num, obj, withDefault } from '../shared'; + +export const Move: NodeDef = { + type: 'Move', + label: 'Move', + category: 'action', + description: + "Moves an object by (dx, dy) × scale × dt. Connect dt to an event's dt for per-second movement; " + + 'leave it at 1 for per-frame. With "Collide with solids" on, the object\'s solid colliders stop at ' + + 'other solid colliders and slide along them; blocked x/y tell which axes hit.', + inputs: [exec(), obj('object'), num('dx'), num('dy'), withDefault(num('scale'), 1), withDefault(num('dt'), 1)], + outputs: [exec(), bool('blockedX', 'blocked x'), bool('blockedY', 'blocked y')], + params: [{ key: 'collide', label: 'Collide with solids', kind: 'boolean', default: false }], + run: (ctx, inputs, params) => { + const target = asEntity(inputs.object); + if (!target) return { next: 'exec', outputs: { blockedX: false, blockedY: false } }; + const factor = asNumber(inputs.scale) * asNumber(inputs.dt); + const dx = asNumber(inputs.dx) * factor; + const dy = asNumber(inputs.dy) * factor; + if (asBoolean(params.collide)) return { next: 'exec', outputs: ctx.moveAndSlide(target, dx, dy) }; + target.x += dx; + target.y += dy; + return { next: 'exec', outputs: { blockedX: false, blockedY: false } }; + }, +}; diff --git a/src/core/graph/nodes/action/RestartGame.ts b/src/core/graph/nodes/action/RestartGame.ts new file mode 100644 index 00000000..2bc5bdd2 --- /dev/null +++ b/src/core/graph/nodes/action/RestartGame.ts @@ -0,0 +1,16 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { exec } from '../shared'; + +export const RestartGame: NodeDef = { + type: 'RestartGame', + label: 'Restart game', + category: 'action', + description: 'Starts the scene over from its saved state – everything spawned, moved or destroyed is reset.', + inputs: [exec()], + outputs: [], + params: [], + run: (ctx) => { + ctx.restart(); + return {}; + }, +}; diff --git a/src/core/graph/nodes/action/SetEnabled.ts b/src/core/graph/nodes/action/SetEnabled.ts new file mode 100644 index 00000000..24a19f8a --- /dev/null +++ b/src/core/graph/nodes/action/SetEnabled.ts @@ -0,0 +1,20 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asBoolean, asEntity } from '../../values'; +import { bool, exec, obj, withDefault } from '../shared'; + +export const SetEnabled: NodeDef = { + type: 'SetEnabled', + label: 'Set enabled', + category: 'action', + description: + 'Switches an object on or off. A disabled object is not drawn, does not collide and its behavior stops – ' + + 'and so does everything parented below it. This is how screens (title, win, lose) are shown and hidden.', + inputs: [exec(), obj('object'), withDefault(bool('enabled'), true)], + outputs: [exec()], + params: [], + run: (_ctx, inputs) => { + const target = asEntity(inputs.object); + if (target) target.props.enabled = asBoolean(inputs.enabled); + return { next: 'exec' }; + }, +}; diff --git a/src/core/graph/nodes/action/SetPosition.ts b/src/core/graph/nodes/action/SetPosition.ts new file mode 100644 index 00000000..98ebaf82 --- /dev/null +++ b/src/core/graph/nodes/action/SetPosition.ts @@ -0,0 +1,21 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asEntity, asNumber } from '../../values'; +import { exec, num, obj } from '../shared'; + +export const SetPosition: NodeDef = { + type: 'SetPosition', + label: 'Set position', + category: 'action', + description: 'Places an object at x/y.', + inputs: [exec(), obj('object'), num('x'), num('y')], + outputs: [exec()], + params: [], + run: (_ctx, inputs) => { + const target = asEntity(inputs.object); + if (target) { + target.x = asNumber(inputs.x); + target.y = asNumber(inputs.y); + } + return { next: 'exec' }; + }, +}; diff --git a/src/core/graph/nodes/action/SetProperty.ts b/src/core/graph/nodes/action/SetProperty.ts new file mode 100644 index 00000000..ce360dd3 --- /dev/null +++ b/src/core/graph/nodes/action/SetProperty.ts @@ -0,0 +1,19 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asEntity, writeTyped } from '../../values'; +import { any, exec, obj } from '../shared'; + +export const SetProperty: NodeDef = { + type: 'SetProperty', + label: 'Set property', + category: 'action', + description: 'Writes a property (width, color, …) of an object.', + inputs: [exec(), obj('object'), any('value')], + outputs: [exec()], + params: [{ key: 'property', label: 'Property', kind: 'text', default: 'width' }], + run: (_ctx, inputs, params) => { + const target = asEntity(inputs.object); + const key = String(params.property ?? ''); + if (target && key) writeTyped(target.props, key, inputs.value); + return { next: 'exec' }; + }, +}; diff --git a/src/core/graph/nodes/action/SetText.ts b/src/core/graph/nodes/action/SetText.ts new file mode 100644 index 00000000..d305d93b --- /dev/null +++ b/src/core/graph/nodes/action/SetText.ts @@ -0,0 +1,18 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asEntity, asString } from '../../values'; +import { any, exec, obj } from '../shared'; + +export const SetText: NodeDef = { + type: 'SetText', + label: 'Set text', + category: 'action', + description: 'Writes the content of a text object (same as Set property "text").', + inputs: [exec(), obj('object'), any('text')], + outputs: [exec()], + params: [], + run: (_ctx, inputs) => { + const target = asEntity(inputs.object); + if (target) target.props.text = asString(inputs.text); + return { next: 'exec' }; + }, +}; diff --git a/src/core/graph/nodes/action/Spawn.ts b/src/core/graph/nodes/action/Spawn.ts new file mode 100644 index 00000000..e95ce35a --- /dev/null +++ b/src/core/graph/nodes/action/Spawn.ts @@ -0,0 +1,22 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asEntity, asNumber } from '../../values'; +import { assetParam, exec, num, obj } from '../shared'; + +export const Spawn: NodeDef = { + type: 'Spawn', + label: 'Spawn', + category: 'action', + description: + 'Creates a new instance of an asset in the running scene at (x, y, z) – local to the parent when one is ' + + 'connected, world coordinates otherwise. Outputs the new object.', + inputs: [exec(), num('x'), num('y'), num('z'), obj('parent')], + outputs: [exec(), obj('object')], + params: [assetParam], + run: (ctx, inputs, params) => { + const assetId = String(params.asset ?? ''); + const position = { x: asNumber(inputs.x), y: asNumber(inputs.y), z: asNumber(inputs.z) }; + const spawned = assetId ? ctx.spawn(assetId, position, asEntity(inputs.parent)) : null; + if (!spawned) ctx.log(assetId ? `Spawn: asset ${assetId} not found` : 'Spawn: no asset selected'); + return { next: 'exec', outputs: { object: spawned } }; + }, +}; diff --git a/src/core/graph/nodes/action/StopGame.ts b/src/core/graph/nodes/action/StopGame.ts new file mode 100644 index 00000000..2da03ced --- /dev/null +++ b/src/core/graph/nodes/action/StopGame.ts @@ -0,0 +1,16 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { exec } from '../shared'; + +export const StopGame: NodeDef = { + type: 'StopGame', + label: 'Stop game', + category: 'action', + description: 'Ends play mode.', + inputs: [exec()], + outputs: [], + params: [], + run: (ctx) => { + ctx.stop(); + return {}; + }, +}; diff --git a/src/core/graph/nodes/data/Abs.ts b/src/core/graph/nodes/data/Abs.ts new file mode 100644 index 00000000..1560d586 --- /dev/null +++ b/src/core/graph/nodes/data/Abs.ts @@ -0,0 +1,3 @@ +import { unaryMath } from '../shared'; + +export const Abs = unaryMath('Abs', 'Absolute value (drops the sign).', Math.abs); diff --git a/src/core/graph/nodes/data/Add.ts b/src/core/graph/nodes/data/Add.ts new file mode 100644 index 00000000..349fa191 --- /dev/null +++ b/src/core/graph/nodes/data/Add.ts @@ -0,0 +1,3 @@ +import { binaryMath } from '../shared'; + +export const Add = binaryMath('Add', 'a + b', (a, b) => a + b); diff --git a/src/core/graph/nodes/data/And.ts b/src/core/graph/nodes/data/And.ts new file mode 100644 index 00000000..f9d99442 --- /dev/null +++ b/src/core/graph/nodes/data/And.ts @@ -0,0 +1,3 @@ +import { binaryLogic } from '../shared'; + +export const And = binaryLogic('And', 'Both true.', (a, b) => a && b); diff --git a/src/core/graph/nodes/data/AxisInput.ts b/src/core/graph/nodes/data/AxisInput.ts new file mode 100644 index 00000000..e67792a4 --- /dev/null +++ b/src/core/graph/nodes/data/AxisInput.ts @@ -0,0 +1,24 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { bool, normalize, num } from '../shared'; + +export const AxisInput: NodeDef = { + type: 'AxisInput', + label: 'Axis input', + category: 'data', + description: 'Direction from four keys as a normalized vector (diagonals are not faster).', + inputs: [], + outputs: [num('x'), num('y'), bool('active')], + params: [ + { key: 'left', label: 'Left', kind: 'key', default: 'ArrowLeft' }, + { key: 'right', label: 'Right', kind: 'key', default: 'ArrowRight' }, + { key: 'up', label: 'Up', kind: 'key', default: 'ArrowUp' }, + { key: 'down', label: 'Down', kind: 'key', default: 'ArrowDown' }, + ], + run: (ctx, _inputs, params) => { + const held = (key: string, fallback: string) => (ctx.isKeyDown(String(params[key] ?? fallback)) ? 1 : 0); + const dx = held('right', 'ArrowRight') - held('left', 'ArrowLeft'); + const dy = held('down', 'ArrowDown') - held('up', 'ArrowUp'); + const { x, y, length } = normalize(dx, dy); + return { outputs: { x, y, active: length > 0 } }; + }, +}; diff --git a/src/core/graph/nodes/data/Boolean.ts b/src/core/graph/nodes/data/Boolean.ts new file mode 100644 index 00000000..ee8fcaed --- /dev/null +++ b/src/core/graph/nodes/data/Boolean.ts @@ -0,0 +1,14 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asBoolean } from '../../values'; +import { bool } from '../shared'; + +export const BooleanLiteral: NodeDef = { + type: 'Boolean', + label: 'Boolean', + category: 'data', + description: 'A constant true/false.', + inputs: [], + outputs: [bool('value')], + params: [{ key: 'value', label: 'Value', kind: 'boolean', default: false }], + run: (_ctx, _inputs, params) => ({ outputs: { value: asBoolean(params.value) } }), +}; diff --git a/src/core/graph/nodes/data/Ceil.ts b/src/core/graph/nodes/data/Ceil.ts new file mode 100644 index 00000000..3c89d48c --- /dev/null +++ b/src/core/graph/nodes/data/Ceil.ts @@ -0,0 +1,3 @@ +import { unaryMath } from '../shared'; + +export const Ceil = unaryMath('Ceil', 'Rounds up to a whole number.', Math.ceil); diff --git a/src/core/graph/nodes/data/Clamp.ts b/src/core/graph/nodes/data/Clamp.ts new file mode 100644 index 00000000..8f96c689 --- /dev/null +++ b/src/core/graph/nodes/data/Clamp.ts @@ -0,0 +1,16 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asNumber } from '../../values'; +import { num, withDefault } from '../shared'; + +export const Clamp: NodeDef = { + type: 'Clamp', + label: 'Clamp', + category: 'data', + description: 'Limits a value to the range min…max.', + inputs: [num('value'), withDefault(num('min'), 0), withDefault(num('max'), 1)], + outputs: [num('result')], + params: [], + run: (_ctx, inputs) => ({ + outputs: { result: Math.min(Math.max(asNumber(inputs.value), asNumber(inputs.min)), asNumber(inputs.max)) }, + }), +}; diff --git a/src/core/graph/nodes/data/Compare.ts b/src/core/graph/nodes/data/Compare.ts new file mode 100644 index 00000000..5a5c78c7 --- /dev/null +++ b/src/core/graph/nodes/data/Compare.ts @@ -0,0 +1,34 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asNumber } from '../../values'; +import { bool, num } from '../shared'; + +const OPERATORS: Record boolean> = { + '==': (a, b) => a === b, + '!=': (a, b) => a !== b, + '<': (a, b) => a < b, + '<=': (a, b) => a <= b, + '>': (a, b) => a > b, + '>=': (a, b) => a >= b, +}; + +export const Compare: NodeDef = { + type: 'Compare', + label: 'Compare', + category: 'data', + description: 'Compares two numbers.', + inputs: [num('a'), num('b')], + outputs: [bool('result')], + params: [ + { + key: 'op', + label: 'Operator', + kind: 'enum', + default: '<', + options: Object.keys(OPERATORS).map((op) => ({ value: op, label: op })), + }, + ], + run: (_ctx, inputs, params) => { + const compare = OPERATORS[String(params.op)] ?? OPERATORS['==']!; + return { outputs: { result: compare(asNumber(inputs.a), asNumber(inputs.b)) } }; + }, +}; diff --git a/src/core/graph/nodes/data/Distance.ts b/src/core/graph/nodes/data/Distance.ts new file mode 100644 index 00000000..03400e10 --- /dev/null +++ b/src/core/graph/nodes/data/Distance.ts @@ -0,0 +1,18 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asEntity } from '../../values'; +import { num, obj } from '../shared'; + +export const Distance: NodeDef = { + type: 'Distance', + label: 'Distance', + category: 'data', + description: 'Distance between two objects.', + inputs: [obj('a'), obj('b')], + outputs: [num('result')], + params: [], + run: (_ctx, inputs) => { + const a = asEntity(inputs.a); + const b = asEntity(inputs.b); + return { outputs: { result: a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0 } }; + }, +}; diff --git a/src/core/graph/nodes/data/Divide.ts b/src/core/graph/nodes/data/Divide.ts new file mode 100644 index 00000000..201be93f --- /dev/null +++ b/src/core/graph/nodes/data/Divide.ts @@ -0,0 +1,3 @@ +import { binaryMath } from '../shared'; + +export const Divide = binaryMath('Divide', 'a ÷ b (0 when b is 0)', (a, b) => (b === 0 ? 0 : a / b)); diff --git a/src/core/graph/nodes/data/Equals.ts b/src/core/graph/nodes/data/Equals.ts new file mode 100644 index 00000000..28c8bf2b --- /dev/null +++ b/src/core/graph/nodes/data/Equals.ts @@ -0,0 +1,19 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asEntity, asString } from '../../values'; +import { any, bool } from '../shared'; + +export const Equals: NodeDef = { + type: 'Equals', + label: 'Equals', + category: 'data', + description: 'Whether two values are the same. Objects compare by identity, everything else as text.', + inputs: [any('a'), any('b')], + outputs: [bool('result')], + params: [], + run: (_ctx, inputs) => { + const a = asEntity(inputs.a); + const b = asEntity(inputs.b); + if (a || b) return { outputs: { result: a === b } }; + return { outputs: { result: asString(inputs.a) === asString(inputs.b) } }; + }, +}; diff --git a/src/core/graph/nodes/data/Floor.ts b/src/core/graph/nodes/data/Floor.ts new file mode 100644 index 00000000..3fc67724 --- /dev/null +++ b/src/core/graph/nodes/data/Floor.ts @@ -0,0 +1,3 @@ +import { unaryMath } from '../shared'; + +export const Floor = unaryMath('Floor', 'Rounds down to a whole number.', Math.floor); diff --git a/src/core/graph/nodes/data/GetPosition.ts b/src/core/graph/nodes/data/GetPosition.ts new file mode 100644 index 00000000..e459a4fe --- /dev/null +++ b/src/core/graph/nodes/data/GetPosition.ts @@ -0,0 +1,19 @@ +import { worldPositionOf } from '../../NodeContext'; +import type { NodeDef } from '../../NodeRegistry'; +import { asEntity } from '../../values'; +import { num, obj } from '../shared'; + +export const GetPosition: NodeDef = { + type: 'GetPosition', + label: 'Get position', + category: 'data', + description: 'The position of an object: local to its parent (x/y) and in world units (world x/y).', + inputs: [obj('object')], + outputs: [num('x'), num('y'), num('worldX', 'world x'), num('worldY', 'world y')], + params: [], + run: (_ctx, inputs) => { + const target = asEntity(inputs.object); + const world = target ? worldPositionOf(target) : { x: 0, y: 0 }; + return { outputs: { x: target?.x ?? 0, y: target?.y ?? 0, worldX: world.x, worldY: world.y } }; + }, +}; diff --git a/src/core/graph/nodes/data/GetProperty.ts b/src/core/graph/nodes/data/GetProperty.ts new file mode 100644 index 00000000..1f6254be --- /dev/null +++ b/src/core/graph/nodes/data/GetProperty.ts @@ -0,0 +1,16 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asEntity } from '../../values'; +import { any, obj } from '../shared'; + +export const GetProperty: NodeDef = { + type: 'GetProperty', + label: 'Get property', + category: 'data', + description: 'Reads a property (width, color, …) of an object.', + inputs: [obj('object')], + outputs: [any('value')], + params: [{ key: 'property', label: 'Property', kind: 'text', default: 'width' }], + run: (_ctx, inputs, params) => ({ + outputs: { value: asEntity(inputs.object)?.props[String(params.property ?? '')] ?? null }, + }), +}; diff --git a/src/core/graph/nodes/data/IsAsset.ts b/src/core/graph/nodes/data/IsAsset.ts new file mode 100644 index 00000000..7ac18662 --- /dev/null +++ b/src/core/graph/nodes/data/IsAsset.ts @@ -0,0 +1,17 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asEntity } from '../../values'; +import { assetParam, bool, obj } from '../shared'; + +export const IsAsset: NodeDef = { + type: 'IsAsset', + label: 'Is asset', + category: 'data', + description: 'Whether an object is an instance of the chosen asset – the way to tell what you collided with.', + inputs: [obj('object')], + outputs: [bool('result')], + params: [assetParam], + run: (_ctx, inputs, params) => { + const assetId = String(params.asset ?? ''); + return { outputs: { result: Boolean(assetId) && asEntity(inputs.object)?.assetId === assetId } }; + }, +}; diff --git a/src/core/graph/nodes/data/IsEnabled.ts b/src/core/graph/nodes/data/IsEnabled.ts new file mode 100644 index 00000000..a167a0d5 --- /dev/null +++ b/src/core/graph/nodes/data/IsEnabled.ts @@ -0,0 +1,19 @@ +import { isHandleEnabled } from '../../NodeContext'; +import type { NodeDef } from '../../NodeRegistry'; +import { asEntity } from '../../values'; +import { bool, obj } from '../shared'; + +export const IsEnabled: NodeDef = { + type: 'IsEnabled', + label: 'Is enabled', + category: 'data', + description: 'Whether an object is switched on – "own" is its own switch, "result" also requires every parent to be on.', + inputs: [obj('object')], + outputs: [bool('result'), bool('own', 'own switch')], + params: [], + run: (_ctx, inputs) => { + const target = asEntity(inputs.object); + if (!target) return { outputs: { result: false, own: false } }; + return { outputs: { result: isHandleEnabled(target), own: target.props.enabled !== false } }; + }, +}; diff --git a/src/core/graph/nodes/data/IsHovered.ts b/src/core/graph/nodes/data/IsHovered.ts new file mode 100644 index 00000000..7642788b --- /dev/null +++ b/src/core/graph/nodes/data/IsHovered.ts @@ -0,0 +1,18 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asEntity } from '../../values'; +import { bool, obj } from '../shared'; + +export const IsHovered: NodeDef = { + type: 'IsHovered', + label: 'Is hovered', + category: 'data', + description: "Whether the mouse is over an object (its colliders, else its own or its children's footprint).", + inputs: [obj('object')], + outputs: [bool('result')], + params: [], + run: (ctx, inputs) => { + const target = asEntity(inputs.object); + const { x, y, inside } = ctx.pointer; + return { outputs: { result: Boolean(target && inside && ctx.hitTest(target, x, y)) } }; + }, +}; diff --git a/src/core/graph/nodes/data/IsTouching.ts b/src/core/graph/nodes/data/IsTouching.ts new file mode 100644 index 00000000..9442261a --- /dev/null +++ b/src/core/graph/nodes/data/IsTouching.ts @@ -0,0 +1,20 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asEntity } from '../../values'; +import { bool, obj } from '../shared'; + +export const IsTouching: NodeDef = { + type: 'IsTouching', + label: 'Is touching', + category: 'data', + description: + 'Whether the colliders of two objects overlap right now ' + + '(an object stands for its collider children; a collider for itself).', + inputs: [obj('a'), obj('b')], + outputs: [bool('result')], + params: [], + run: (ctx, inputs) => { + const a = asEntity(inputs.a); + const b = asEntity(inputs.b); + return { outputs: { result: Boolean(a && b && ctx.isTouching(a, b)) } }; + }, +}; diff --git a/src/core/graph/nodes/data/Join.ts b/src/core/graph/nodes/data/Join.ts new file mode 100644 index 00000000..61e404ba --- /dev/null +++ b/src/core/graph/nodes/data/Join.ts @@ -0,0 +1,14 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asString } from '../../values'; +import { any, str } from '../shared'; + +export const Join: NodeDef = { + type: 'Join', + label: 'Join text', + category: 'data', + description: 'Sticks two values together as text ("Coins: " + 3).', + inputs: [any('a'), any('b')], + outputs: [str('text')], + params: [], + run: (_ctx, inputs) => ({ outputs: { text: asString(inputs.a) + asString(inputs.b) } }), +}; diff --git a/src/core/graph/nodes/data/KeyIsDown.ts b/src/core/graph/nodes/data/KeyIsDown.ts new file mode 100644 index 00000000..471a113b --- /dev/null +++ b/src/core/graph/nodes/data/KeyIsDown.ts @@ -0,0 +1,13 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { bool, keyParam } from '../shared'; + +export const KeyIsDown: NodeDef = { + type: 'KeyIsDown', + label: 'Key is down', + category: 'data', + description: 'Whether a key is currently held.', + inputs: [], + outputs: [bool('down')], + params: [keyParam], + run: (ctx, _inputs, params) => ({ outputs: { down: ctx.isKeyDown(String(params.key ?? '')) } }), +}; diff --git a/src/core/graph/nodes/data/Length.ts b/src/core/graph/nodes/data/Length.ts new file mode 100644 index 00000000..071259c3 --- /dev/null +++ b/src/core/graph/nodes/data/Length.ts @@ -0,0 +1,14 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asNumber } from '../../values'; +import { num } from '../shared'; + +export const Length: NodeDef = { + type: 'Length', + label: 'Length', + category: 'data', + description: 'Length of the vector (x, y).', + inputs: [num('x'), num('y')], + outputs: [num('result')], + params: [], + run: (_ctx, inputs) => ({ outputs: { result: Math.hypot(asNumber(inputs.x), asNumber(inputs.y)) } }), +}; diff --git a/src/core/graph/nodes/data/Max.ts b/src/core/graph/nodes/data/Max.ts new file mode 100644 index 00000000..d724857b --- /dev/null +++ b/src/core/graph/nodes/data/Max.ts @@ -0,0 +1,3 @@ +import { binaryMath } from '../shared'; + +export const Max = binaryMath('Max', 'The larger of a and b.', Math.max); diff --git a/src/core/graph/nodes/data/Min.ts b/src/core/graph/nodes/data/Min.ts new file mode 100644 index 00000000..3c89a35e --- /dev/null +++ b/src/core/graph/nodes/data/Min.ts @@ -0,0 +1,3 @@ +import { binaryMath } from '../shared'; + +export const Min = binaryMath('Min', 'The smaller of a and b.', Math.min); diff --git a/src/core/graph/nodes/data/Modulo.ts b/src/core/graph/nodes/data/Modulo.ts new file mode 100644 index 00000000..b0261050 --- /dev/null +++ b/src/core/graph/nodes/data/Modulo.ts @@ -0,0 +1,17 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asNumber } from '../../values'; +import { num, withDefault } from '../shared'; + +export const Modulo: NodeDef = { + type: 'Modulo', + label: 'Modulo', + category: 'data', + description: 'Remainder of a ÷ b (0 when b is 0) – seconds within a minute, for instance.', + inputs: [num('a'), withDefault(num('b'), 1)], + outputs: [num('result')], + params: [], + run: (_ctx, inputs) => { + const b = asNumber(inputs.b); + return { outputs: { result: b === 0 ? 0 : asNumber(inputs.a) % b } }; + }, +}; diff --git a/src/core/graph/nodes/data/MouseButton.ts b/src/core/graph/nodes/data/MouseButton.ts new file mode 100644 index 00000000..e0161dfe --- /dev/null +++ b/src/core/graph/nodes/data/MouseButton.ts @@ -0,0 +1,13 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { bool } from '../shared'; + +export const MouseButton: NodeDef = { + type: 'MouseButton', + label: 'Mouse button', + category: 'data', + description: 'State of the mouse button: held, and whether it went down or up this frame.', + inputs: [], + outputs: [bool('down'), bool('pressed'), bool('released')], + params: [], + run: (ctx) => ({ outputs: { down: ctx.pointer.down, pressed: ctx.pointer.pressed, released: ctx.pointer.released } }), +}; diff --git a/src/core/graph/nodes/data/MousePosition.ts b/src/core/graph/nodes/data/MousePosition.ts new file mode 100644 index 00000000..fd4187f6 --- /dev/null +++ b/src/core/graph/nodes/data/MousePosition.ts @@ -0,0 +1,13 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { bool, num } from '../shared'; + +export const MousePosition: NodeDef = { + type: 'MousePosition', + label: 'Mouse position', + category: 'data', + description: 'Where the mouse is in the Game tab, in world units.', + inputs: [], + outputs: [num('x'), num('y'), bool('inside', 'in view')], + params: [], + run: (ctx) => ({ outputs: { x: ctx.pointer.x, y: ctx.pointer.y, inside: ctx.pointer.inside } }), +}; diff --git a/src/core/graph/nodes/data/Multiply.ts b/src/core/graph/nodes/data/Multiply.ts new file mode 100644 index 00000000..f4d0f50d --- /dev/null +++ b/src/core/graph/nodes/data/Multiply.ts @@ -0,0 +1,3 @@ +import { binaryMath } from '../shared'; + +export const Multiply = binaryMath('Multiply', 'a × b', (a, b) => a * b); diff --git a/src/core/graph/nodes/data/NameOf.ts b/src/core/graph/nodes/data/NameOf.ts new file mode 100644 index 00000000..ca999e44 --- /dev/null +++ b/src/core/graph/nodes/data/NameOf.ts @@ -0,0 +1,14 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asEntity } from '../../values'; +import { obj, str } from '../shared'; + +export const NameOf: NodeDef = { + type: 'NameOf', + label: 'Name of', + category: 'data', + description: 'The name of an object (empty when there is none).', + inputs: [obj('object')], + outputs: [str('name')], + params: [], + run: (_ctx, inputs) => ({ outputs: { name: asEntity(inputs.object)?.name ?? '' } }), +}; diff --git a/src/core/graph/nodes/data/Normalize.ts b/src/core/graph/nodes/data/Normalize.ts new file mode 100644 index 00000000..39e7a1ea --- /dev/null +++ b/src/core/graph/nodes/data/Normalize.ts @@ -0,0 +1,14 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asNumber } from '../../values'; +import { normalize, num } from '../shared'; + +export const Normalize: NodeDef = { + type: 'Normalize', + label: 'Normalize', + category: 'data', + description: 'Scales the vector (x, y) to length 1; (0, 0) stays (0, 0).', + inputs: [num('x'), num('y')], + outputs: [num('x'), num('y'), num('length')], + params: [], + run: (_ctx, inputs) => ({ outputs: normalize(asNumber(inputs.x), asNumber(inputs.y)) }), +}; diff --git a/src/core/graph/nodes/data/Not.ts b/src/core/graph/nodes/data/Not.ts new file mode 100644 index 00000000..333c3a33 --- /dev/null +++ b/src/core/graph/nodes/data/Not.ts @@ -0,0 +1,14 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asBoolean } from '../../values'; +import { bool } from '../shared'; + +export const Not: NodeDef = { + type: 'Not', + label: 'Not', + category: 'data', + description: 'Inverts a boolean.', + inputs: [bool('value')], + outputs: [bool('result')], + params: [], + run: (_ctx, inputs) => ({ outputs: { result: !asBoolean(inputs.value) } }), +}; diff --git a/src/core/graph/nodes/data/Number.ts b/src/core/graph/nodes/data/Number.ts new file mode 100644 index 00000000..5ba2b2a0 --- /dev/null +++ b/src/core/graph/nodes/data/Number.ts @@ -0,0 +1,14 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asNumber } from '../../values'; +import { num } from '../shared'; + +export const NumberLiteral: NodeDef = { + type: 'Number', + label: 'Number', + category: 'data', + description: 'A constant number.', + inputs: [], + outputs: [num('value')], + params: [{ key: 'value', label: 'Value', kind: 'number', default: 0, step: 1 }], + run: (_ctx, _inputs, params) => ({ outputs: { value: asNumber(params.value) } }), +}; diff --git a/src/core/graph/nodes/data/ObjectByName.ts b/src/core/graph/nodes/data/ObjectByName.ts new file mode 100644 index 00000000..b266df3d --- /dev/null +++ b/src/core/graph/nodes/data/ObjectByName.ts @@ -0,0 +1,13 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { obj } from '../shared'; + +export const ObjectByName: NodeDef = { + type: 'ObjectByName', + label: 'Object by name', + category: 'data', + description: 'Finds a scene object by its name.', + inputs: [], + outputs: [obj('object')], + params: [{ key: 'name', label: 'Name', kind: 'text', default: '' }], + run: (ctx, _inputs, params) => ({ outputs: { object: ctx.findByName(String(params.name ?? '')) } }), +}; diff --git a/src/core/graph/nodes/data/Or.ts b/src/core/graph/nodes/data/Or.ts new file mode 100644 index 00000000..75092938 --- /dev/null +++ b/src/core/graph/nodes/data/Or.ts @@ -0,0 +1,3 @@ +import { binaryLogic } from '../shared'; + +export const Or = binaryLogic('Or', 'Either true.', (a, b) => a || b); diff --git a/src/core/graph/nodes/data/Owner.ts b/src/core/graph/nodes/data/Owner.ts new file mode 100644 index 00000000..7b185f42 --- /dev/null +++ b/src/core/graph/nodes/data/Owner.ts @@ -0,0 +1,13 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { obj } from '../shared'; + +export const Owner: NodeDef = { + type: 'Owner', + label: 'Owner', + category: 'data', + description: 'The object this behavior is parented to.', + inputs: [], + outputs: [obj('object')], + params: [], + run: (ctx) => ({ outputs: { object: ctx.owner } }), +}; diff --git a/src/core/graph/nodes/data/Parent.ts b/src/core/graph/nodes/data/Parent.ts new file mode 100644 index 00000000..850b1df9 --- /dev/null +++ b/src/core/graph/nodes/data/Parent.ts @@ -0,0 +1,14 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asEntity } from '../../values'; +import { obj } from '../shared'; + +export const Parent: NodeDef = { + type: 'Parent', + label: 'Parent', + category: 'data', + description: 'The parent of an object (none for top-level objects).', + inputs: [obj('object')], + outputs: [obj('parent')], + params: [], + run: (_ctx, inputs) => ({ outputs: { parent: asEntity(inputs.object)?.parent ?? null } }), +}; diff --git a/src/core/graph/nodes/data/Random.ts b/src/core/graph/nodes/data/Random.ts new file mode 100644 index 00000000..54c7deff --- /dev/null +++ b/src/core/graph/nodes/data/Random.ts @@ -0,0 +1,17 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asNumber } from '../../values'; +import { num, withDefault } from '../shared'; + +export const Random: NodeDef = { + type: 'Random', + label: 'Random', + category: 'data', + description: 'A random number between min and max (min included, max not).', + inputs: [withDefault(num('min'), 0), withDefault(num('max'), 1)], + outputs: [num('result')], + params: [], + run: (_ctx, inputs) => { + const min = asNumber(inputs.min); + return { outputs: { result: min + Math.random() * (asNumber(inputs.max) - min) } }; + }, +}; diff --git a/src/core/graph/nodes/data/Round.ts b/src/core/graph/nodes/data/Round.ts new file mode 100644 index 00000000..de78cf68 --- /dev/null +++ b/src/core/graph/nodes/data/Round.ts @@ -0,0 +1,3 @@ +import { unaryMath } from '../shared'; + +export const Round = unaryMath('Round', 'Rounds to the nearest whole number.', Math.round); diff --git a/src/core/graph/nodes/data/Self.ts b/src/core/graph/nodes/data/Self.ts new file mode 100644 index 00000000..addaa299 --- /dev/null +++ b/src/core/graph/nodes/data/Self.ts @@ -0,0 +1,13 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { obj } from '../shared'; + +export const Self: NodeDef = { + type: 'Self', + label: 'Self', + category: 'data', + description: 'This behavior object.', + inputs: [], + outputs: [obj('object')], + params: [], + run: (ctx) => ({ outputs: { object: ctx.self } }), +}; diff --git a/src/core/graph/nodes/data/Sqrt.ts b/src/core/graph/nodes/data/Sqrt.ts new file mode 100644 index 00000000..e469b782 --- /dev/null +++ b/src/core/graph/nodes/data/Sqrt.ts @@ -0,0 +1,3 @@ +import { unaryMath } from '../shared'; + +export const Sqrt = unaryMath('Sqrt', 'Square root (0 for negative input).', (value) => Math.sqrt(Math.max(0, value))); diff --git a/src/core/graph/nodes/data/String.ts b/src/core/graph/nodes/data/String.ts new file mode 100644 index 00000000..ed365e51 --- /dev/null +++ b/src/core/graph/nodes/data/String.ts @@ -0,0 +1,14 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asString } from '../../values'; +import { str } from '../shared'; + +export const StringLiteral: NodeDef = { + type: 'String', + label: 'String', + category: 'data', + description: 'A constant text.', + inputs: [], + outputs: [str('value')], + params: [{ key: 'value', label: 'Value', kind: 'text', default: '' }], + run: (_ctx, _inputs, params) => ({ outputs: { value: asString(params.value) } }), +}; diff --git a/src/core/graph/nodes/data/Subtract.ts b/src/core/graph/nodes/data/Subtract.ts new file mode 100644 index 00000000..89679b6e --- /dev/null +++ b/src/core/graph/nodes/data/Subtract.ts @@ -0,0 +1,3 @@ +import { binaryMath } from '../shared'; + +export const Subtract = binaryMath('Subtract', 'a − b', (a, b) => a - b); diff --git a/src/core/graph/nodes/data/Time.ts b/src/core/graph/nodes/data/Time.ts new file mode 100644 index 00000000..eb39575c --- /dev/null +++ b/src/core/graph/nodes/data/Time.ts @@ -0,0 +1,13 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { num } from '../shared'; + +export const Time: NodeDef = { + type: 'Time', + label: 'Time', + category: 'data', + description: 'Seconds since the game started, and the length of the current frame.', + inputs: [], + outputs: [num('time'), num('dt')], + params: [], + run: (ctx) => ({ outputs: { time: ctx.time, dt: ctx.dt } }), +}; diff --git a/src/core/graph/nodes/data/ToText.ts b/src/core/graph/nodes/data/ToText.ts new file mode 100644 index 00000000..3157beec --- /dev/null +++ b/src/core/graph/nodes/data/ToText.ts @@ -0,0 +1,18 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asNumber } from '../../values'; +import { any, num, str } from '../shared'; + +export const ToText: NodeDef = { + type: 'ToText', + label: 'To text', + category: 'data', + description: 'Turns a number into text with a fixed number of decimals.', + inputs: [any('value'), num('decimals')], + outputs: [str('text')], + params: [], + run: (_ctx, inputs) => { + const value = inputs.value; + const decimals = Math.max(0, Math.min(10, Math.floor(asNumber(inputs.decimals)))); + return { outputs: { text: typeof value === 'string' ? value : asNumber(value).toFixed(decimals) } }; + }, +}; diff --git a/src/core/graph/nodes/events/OnClick.ts b/src/core/graph/nodes/events/OnClick.ts new file mode 100644 index 00000000..3cb92063 --- /dev/null +++ b/src/core/graph/nodes/events/OnClick.ts @@ -0,0 +1,14 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { exec, num } from '../shared'; + +export const OnClick: NodeDef = { + type: 'OnClick', + label: 'On click', + category: 'event', + description: + 'Runs when the mouse is pressed on the owner (or on anything parented below it) in the Game tab. ' + + 'Without an owner it runs on a click anywhere in the game view.', + inputs: [], + outputs: [exec(), num('x', 'world x'), num('y', 'world y')], + params: [], +}; diff --git a/src/core/graph/nodes/events/OnCollisionEnter.ts b/src/core/graph/nodes/events/OnCollisionEnter.ts new file mode 100644 index 00000000..36bf4d26 --- /dev/null +++ b/src/core/graph/nodes/events/OnCollisionEnter.ts @@ -0,0 +1,12 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { collisionOutputs } from '../shared'; + +export const OnCollisionEnter: NodeDef = { + type: 'OnCollisionEnter', + label: 'On collision enter', + category: 'event', + description: 'Runs once when a collider of the owner starts overlapping another collider.', + inputs: [], + outputs: collisionOutputs(), + params: [], +}; diff --git a/src/core/graph/nodes/events/OnCollisionExit.ts b/src/core/graph/nodes/events/OnCollisionExit.ts new file mode 100644 index 00000000..86bb9c83 --- /dev/null +++ b/src/core/graph/nodes/events/OnCollisionExit.ts @@ -0,0 +1,12 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { collisionOutputs } from '../shared'; + +export const OnCollisionExit: NodeDef = { + type: 'OnCollisionExit', + label: 'On collision exit', + category: 'event', + description: 'Runs once when a collider of the owner stops overlapping another collider.', + inputs: [], + outputs: collisionOutputs(), + params: [], +}; diff --git a/src/core/graph/nodes/events/OnCollisionStay.ts b/src/core/graph/nodes/events/OnCollisionStay.ts new file mode 100644 index 00000000..0a2eb3cf --- /dev/null +++ b/src/core/graph/nodes/events/OnCollisionStay.ts @@ -0,0 +1,12 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { collisionOutputs } from '../shared'; + +export const OnCollisionStay: NodeDef = { + type: 'OnCollisionStay', + label: 'On collision stay', + category: 'event', + description: 'Runs every frame while a collider of the owner overlaps another collider.', + inputs: [], + outputs: collisionOutputs(), + params: [], +}; diff --git a/src/core/graph/nodes/events/OnKeyDown.ts b/src/core/graph/nodes/events/OnKeyDown.ts new file mode 100644 index 00000000..5db40f32 --- /dev/null +++ b/src/core/graph/nodes/events/OnKeyDown.ts @@ -0,0 +1,12 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { exec, keyParam } from '../shared'; + +export const OnKeyDown: NodeDef = { + type: 'OnKeyDown', + label: 'On key down', + category: 'event', + description: 'Runs once when the key is pressed.', + inputs: [], + outputs: [exec()], + params: [keyParam], +}; diff --git a/src/core/graph/nodes/events/OnKeyHeld.ts b/src/core/graph/nodes/events/OnKeyHeld.ts new file mode 100644 index 00000000..2d95d6e0 --- /dev/null +++ b/src/core/graph/nodes/events/OnKeyHeld.ts @@ -0,0 +1,12 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { exec, keyParam, num } from '../shared'; + +export const OnKeyHeld: NodeDef = { + type: 'OnKeyHeld', + label: 'On key held', + category: 'event', + description: 'Runs every frame while the key is held.', + inputs: [], + outputs: [exec(), num('dt')], + params: [keyParam], +}; diff --git a/src/core/graph/nodes/events/OnKeyUp.ts b/src/core/graph/nodes/events/OnKeyUp.ts new file mode 100644 index 00000000..dd9eed49 --- /dev/null +++ b/src/core/graph/nodes/events/OnKeyUp.ts @@ -0,0 +1,12 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { exec, keyParam } from '../shared'; + +export const OnKeyUp: NodeDef = { + type: 'OnKeyUp', + label: 'On key up', + category: 'event', + description: 'Runs once when the key is released.', + inputs: [], + outputs: [exec()], + params: [keyParam], +}; diff --git a/src/core/graph/nodes/events/OnStart.ts b/src/core/graph/nodes/events/OnStart.ts new file mode 100644 index 00000000..85283dc1 --- /dev/null +++ b/src/core/graph/nodes/events/OnStart.ts @@ -0,0 +1,12 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { exec } from '../shared'; + +export const OnStart: NodeDef = { + type: 'OnStart', + label: 'On start', + category: 'event', + description: 'Runs once when the game starts.', + inputs: [], + outputs: [exec()], + params: [], +}; diff --git a/src/core/graph/nodes/events/OnTimer.ts b/src/core/graph/nodes/events/OnTimer.ts new file mode 100644 index 00000000..a400b59e --- /dev/null +++ b/src/core/graph/nodes/events/OnTimer.ts @@ -0,0 +1,12 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { exec } from '../shared'; + +export const OnTimer: NodeDef = { + type: 'OnTimer', + label: 'On timer', + category: 'event', + description: 'Runs every N seconds.', + inputs: [], + outputs: [exec()], + params: [{ key: 'interval', label: 'Interval (s)', kind: 'number', default: 1, min: 0.01, step: 0.1 }], +}; diff --git a/src/core/graph/nodes/events/OnUpdate.ts b/src/core/graph/nodes/events/OnUpdate.ts new file mode 100644 index 00000000..6a1ad5b0 --- /dev/null +++ b/src/core/graph/nodes/events/OnUpdate.ts @@ -0,0 +1,12 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { exec, num } from '../shared'; + +export const OnUpdate: NodeDef = { + type: 'OnUpdate', + label: 'On update', + category: 'event', + description: 'Runs every frame.', + inputs: [], + outputs: [exec(), num('dt')], + params: [], +}; diff --git a/src/core/graph/nodes/flow/Branch.ts b/src/core/graph/nodes/flow/Branch.ts new file mode 100644 index 00000000..eb233440 --- /dev/null +++ b/src/core/graph/nodes/flow/Branch.ts @@ -0,0 +1,14 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asBoolean } from '../../values'; +import { bool, exec } from '../shared'; + +export const Branch: NodeDef = { + type: 'Branch', + label: 'Branch', + category: 'flow', + description: 'Continues along "then" or "else" depending on the condition.', + inputs: [exec(), bool('condition')], + outputs: [exec('then', 'then'), exec('else', 'else')], + params: [], + run: (_ctx, inputs) => ({ next: asBoolean(inputs.condition) ? 'then' : 'else' }), +}; diff --git a/src/core/graph/nodes/flow/Sequence.ts b/src/core/graph/nodes/flow/Sequence.ts new file mode 100644 index 00000000..d7611a53 --- /dev/null +++ b/src/core/graph/nodes/flow/Sequence.ts @@ -0,0 +1,13 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { exec } from '../shared'; + +export const Sequence: NodeDef = { + type: 'Sequence', + label: 'Sequence', + category: 'flow', + description: 'Runs its outputs one after another.', + inputs: [exec()], + outputs: [exec('a', '1'), exec('b', '2'), exec('c', '3')], + params: [], + run: () => ({ next: ['a', 'b', 'c'] }), +}; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts new file mode 100644 index 00000000..b1b70d0b --- /dev/null +++ b/src/core/graph/nodes/index.ts @@ -0,0 +1,95 @@ +import { type NodeDef, registerNode } from '../NodeRegistry'; +import { Destroy } from './action/Destroy'; +import { Log } from './action/Log'; +import { Move } from './action/Move'; +import { RestartGame } from './action/RestartGame'; +import { SetEnabled } from './action/SetEnabled'; +import { SetPosition } from './action/SetPosition'; +import { SetProperty } from './action/SetProperty'; +import { SetText } from './action/SetText'; +import { Spawn } from './action/Spawn'; +import { StopGame } from './action/StopGame'; +import { Abs } from './data/Abs'; +import { Add } from './data/Add'; +import { And } from './data/And'; +import { AxisInput } from './data/AxisInput'; +import { BooleanLiteral } from './data/Boolean'; +import { Ceil } from './data/Ceil'; +import { Clamp } from './data/Clamp'; +import { Compare } from './data/Compare'; +import { Distance } from './data/Distance'; +import { Divide } from './data/Divide'; +import { Equals } from './data/Equals'; +import { Floor } from './data/Floor'; +import { GetPosition } from './data/GetPosition'; +import { GetProperty } from './data/GetProperty'; +import { IsAsset } from './data/IsAsset'; +import { IsEnabled } from './data/IsEnabled'; +import { IsHovered } from './data/IsHovered'; +import { IsTouching } from './data/IsTouching'; +import { Join } from './data/Join'; +import { KeyIsDown } from './data/KeyIsDown'; +import { Length } from './data/Length'; +import { Max } from './data/Max'; +import { Min } from './data/Min'; +import { Modulo } from './data/Modulo'; +import { MouseButton } from './data/MouseButton'; +import { MousePosition } from './data/MousePosition'; +import { Multiply } from './data/Multiply'; +import { NameOf } from './data/NameOf'; +import { Normalize } from './data/Normalize'; +import { Not } from './data/Not'; +import { NumberLiteral } from './data/Number'; +import { ObjectByName } from './data/ObjectByName'; +import { Or } from './data/Or'; +import { Owner } from './data/Owner'; +import { Parent } from './data/Parent'; +import { Random } from './data/Random'; +import { Round } from './data/Round'; +import { Self } from './data/Self'; +import { Sqrt } from './data/Sqrt'; +import { StringLiteral } from './data/String'; +import { Subtract } from './data/Subtract'; +import { Time } from './data/Time'; +import { ToText } from './data/ToText'; +import { OnClick } from './events/OnClick'; +import { OnCollisionEnter } from './events/OnCollisionEnter'; +import { OnCollisionExit } from './events/OnCollisionExit'; +import { OnCollisionStay } from './events/OnCollisionStay'; +import { OnKeyDown } from './events/OnKeyDown'; +import { OnKeyHeld } from './events/OnKeyHeld'; +import { OnKeyUp } from './events/OnKeyUp'; +import { OnStart } from './events/OnStart'; +import { OnTimer } from './events/OnTimer'; +import { OnUpdate } from './events/OnUpdate'; +import { Branch } from './flow/Branch'; +import { Sequence } from './flow/Sequence'; +import { GetVariable } from './variable/GetVariable'; +import { SetVariable } from './variable/SetVariable'; + +/** + * The built-in node set, one definition per file. Importing this module registers them; + * the palette sorts by category and label, so the order here is documentation only. + */ +export const BUILT_IN_NODES: readonly NodeDef[] = [ + // events + OnStart, OnUpdate, OnKeyDown, OnKeyUp, OnKeyHeld, OnTimer, + OnCollisionEnter, OnCollisionStay, OnCollisionExit, OnClick, + // flow + Branch, Sequence, + // variables + GetVariable, SetVariable, + // data: references and properties + Self, Owner, Parent, ObjectByName, NameOf, IsAsset, IsEnabled, GetProperty, GetPosition, + // data: literals and math + NumberLiteral, BooleanLiteral, StringLiteral, + Add, Subtract, Multiply, Divide, Min, Max, Abs, Sqrt, Clamp, Floor, Ceil, Round, Modulo, Random, Time, + // data: text and comparison + ToText, Join, Equals, Compare, And, Or, Not, + // data: vectors and input + Length, Distance, Normalize, AxisInput, KeyIsDown, MousePosition, MouseButton, IsHovered, IsTouching, + // actions + Move, SetPosition, SetProperty, SetEnabled, SetText, Spawn, Destroy, Log, StopGame, RestartGame, +]; + +for (const def of BUILT_IN_NODES) registerNode(def); diff --git a/src/core/graph/nodes/shared.ts b/src/core/graph/nodes/shared.ts new file mode 100644 index 00000000..1130c223 --- /dev/null +++ b/src/core/graph/nodes/shared.ts @@ -0,0 +1,79 @@ +import type { PropertyValue } from '../../model/Properties'; +import type { PropertyDef } from '../../properties/PropertySchema'; +import type { NodeDef, PortDef } from '../NodeRegistry'; +import { asBoolean, asNumber } from '../values'; + +// --- ports ----------------------------------------------------------------------------------- + +export const exec = (key = 'exec', label = ''): PortDef => ({ key, label, type: 'exec' }); +export const num = (key: string, label = key): PortDef => ({ key, label, type: 'number' }); +export const bool = (key: string, label = key): PortDef => ({ key, label, type: 'boolean' }); +export const str = (key: string, label = key): PortDef => ({ key, label, type: 'string' }); +export const obj = (key: string, label = key): PortDef => ({ key, label, type: 'object' }); +export const any = (key: string, label = key): PortDef => ({ key, label, type: 'any' }); + +/** The same port with an inline value it carries while unconnected. */ +export const withDefault = (port: PortDef, value: PropertyValue): PortDef => ({ ...port, default: value }); + +/** Outputs of the three collision events: the other body, its collider and the own collider. */ +export const collisionOutputs = (): PortDef[] => [exec(), obj('other'), obj('otherCollider', 'other collider'), obj('collider')]; + +// --- params ---------------------------------------------------------------------------------- + +export const keyParam: PropertyDef = { key: 'key', label: 'Key', kind: 'key', default: 'ArrowRight' }; + +/** Options are empty on purpose: the graph inspector fills in the behavior's variables. */ +export const variableParam: PropertyDef = { key: 'variable', label: 'Variable', kind: 'enum', default: '', options: [] }; + +/** Options are empty on purpose: the graph inspector fills in the project's placeable assets. */ +export const assetParam: PropertyDef = { key: 'asset', label: 'Asset', kind: 'enum', default: '', options: [] }; + +// --- node factories -------------------------------------------------------------------------- + +/** A data node computing one number from `a` and `b`. */ +export function binaryMath(type: string, description: string, fn: (a: number, b: number) => number): NodeDef { + return { + type, + label: type, + category: 'data', + description, + inputs: [num('a'), num('b')], + outputs: [num('result')], + params: [], + run: (_ctx, inputs) => ({ outputs: { result: fn(asNumber(inputs.a), asNumber(inputs.b)) } }), + }; +} + +/** A data node computing one number from `value`. */ +export function unaryMath(type: string, description: string, fn: (value: number) => number): NodeDef { + return { + type, + label: type, + category: 'data', + description, + inputs: [num('value')], + outputs: [num('result')], + params: [], + run: (_ctx, inputs) => ({ outputs: { result: fn(asNumber(inputs.value)) } }), + }; +} + +/** A data node combining the booleans `a` and `b`. */ +export function binaryLogic(type: string, description: string, fn: (a: boolean, b: boolean) => boolean): NodeDef { + return { + type, + label: type, + category: 'data', + description, + inputs: [bool('a'), bool('b')], + outputs: [bool('result')], + params: [], + run: (_ctx, inputs) => ({ outputs: { result: fn(asBoolean(inputs.a), asBoolean(inputs.b)) } }), + }; +} + +/** Scales (x, y) to length 1; the zero vector stays zero. */ +export function normalize(x: number, y: number): { x: number; y: number; length: number } { + const length = Math.hypot(x, y); + return length === 0 ? { x: 0, y: 0, length: 0 } : { x: x / length, y: y / length, length }; +} diff --git a/src/core/graph/nodes/variable/GetVariable.ts b/src/core/graph/nodes/variable/GetVariable.ts new file mode 100644 index 00000000..a98cf3f2 --- /dev/null +++ b/src/core/graph/nodes/variable/GetVariable.ts @@ -0,0 +1,13 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { any, variableParam } from '../shared'; + +export const GetVariable: NodeDef = { + type: 'GetVariable', + label: 'Get variable', + category: 'variable', + description: "Reads one of this behavior's variables.", + inputs: [], + outputs: [any('value')], + params: [variableParam], + run: (ctx, _inputs, params) => ({ outputs: { value: ctx.variables[String(params.variable ?? '')] ?? null } }), +}; diff --git a/src/core/graph/nodes/variable/SetVariable.ts b/src/core/graph/nodes/variable/SetVariable.ts new file mode 100644 index 00000000..1cc9f060 --- /dev/null +++ b/src/core/graph/nodes/variable/SetVariable.ts @@ -0,0 +1,18 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { writeTyped } from '../../values'; +import { any, exec, variableParam } from '../shared'; + +export const SetVariable: NodeDef = { + type: 'SetVariable', + label: 'Set variable', + category: 'variable', + description: "Writes one of this behavior's variables.", + inputs: [exec(), any('value')], + outputs: [exec()], + params: [variableParam], + run: (ctx, inputs, params) => { + const key = String(params.variable ?? ''); + if (key) writeTyped(ctx.variables, key, inputs.value); + return { next: 'exec' }; + }, +}; diff --git a/src/core/graph/values.ts b/src/core/graph/values.ts new file mode 100644 index 00000000..5a3cde36 --- /dev/null +++ b/src/core/graph/values.ts @@ -0,0 +1,42 @@ +import type { PropertyBag, PropertyValue } from '../model/Properties'; +import type { EntityHandle } from './NodeContext'; + +/** A value travelling along a data edge. Objects are live entity handles (play mode only). */ +export type Value = PropertyValue | EntityHandle | null; + +export function asNumber(value: Value | undefined): number { + if (typeof value === 'number') return value; + if (typeof value === 'boolean') return value ? 1 : 0; + if (typeof value === 'string') { + const n = Number(value); + return Number.isFinite(n) ? n : 0; + } + return 0; +} + +export function asBoolean(value: Value | undefined): boolean { + if (typeof value === 'string') return value !== '' && value !== 'false' && value !== '0'; + return Boolean(value); +} + +/** Entities read as their name, so a handle can be logged or joined into text directly. */ +export function asString(value: Value | undefined): string { + if (value === null || value === undefined) return ''; + if (typeof value === 'object') return value.name; + return String(value); +} + +export function asEntity(value: Value | undefined): EntityHandle | null { + return value && typeof value === 'object' ? value : null; +} + +/** + * Writes `value` into `bag[key]` converted to the type the slot currently holds, so a number + * variable stays a number no matter what arrives on an `any` input. + */ +export function writeTyped(bag: PropertyBag, key: string, value: Value | undefined): void { + const current = bag[key]; + if (typeof current === 'number') bag[key] = asNumber(value); + else if (typeof current === 'boolean') bag[key] = asBoolean(value); + else bag[key] = asString(value); +} diff --git a/src/core/ids.ts b/src/core/ids.ts index 1b0d951f..c5a4a6ec 100644 --- a/src/core/ids.ts +++ b/src/core/ids.ts @@ -1,8 +1,10 @@ -/** Short, URL-safe random ids (10 chars, ~60 bits). Works in browsers and Node. */ +const ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'; +const LENGTH = 10; + +/** Short, URL-safe random ids: 10 base-36 characters (~51 bits). Works in browsers and Node. */ export function newId(): string { - const bytes = new Uint8Array(8); - crypto.getRandomValues(bytes); + const bytes = crypto.getRandomValues(new Uint8Array(LENGTH)); let id = ''; - for (const b of bytes) id += b.toString(36).padStart(2, '0'); - return id.slice(0, 10); + for (const byte of bytes) id += ALPHABET[byte % ALPHABET.length]; + return id; } diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index bf9fc63d..63714d29 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -70,7 +70,3 @@ export type Asset = SpriteAsset | CameraAsset | TextAsset | SceneAsset | Behavio export function isSceneAsset(asset: Asset): asset is SceneAsset { return asset.type === 'scene'; } - -export function isBehaviorAsset(asset: Asset): asset is BehaviorAsset { - return asset.type === 'behavior'; -} diff --git a/src/core/model/AssetTypes.ts b/src/core/model/AssetTypes.ts index dc6e7c82..c1fac966 100644 --- a/src/core/model/AssetTypes.ts +++ b/src/core/model/AssetTypes.ts @@ -3,6 +3,17 @@ export const ASSET_TYPES = ['scene', 'camera', 'sprite', 'text', 'behavior', 'co export type AssetType = (typeof ASSET_TYPES)[number]; +/** Display names of the asset types (menus, list hints). */ +export const ASSET_TYPE_LABELS: Record = { + scene: 'Scene', + camera: 'Camera', + sprite: 'Sprite', + text: 'Text', + behavior: 'Behavior', + collider: 'Collider', + container: 'Container', +}; + /** Types that can be placed into a scene (everything except scenes themselves). */ export type PlaceableAssetType = Exclude; @@ -21,3 +32,8 @@ export function isAssetType(value: unknown): value is AssetType { export function isSymbolType(type: AssetType): boolean { return type === 'camera' || type === 'behavior' || type === 'collider' || type === 'container'; } + +/** Types whose colour is worth a swatch in the lists. */ +export function hasColorSwatch(type: AssetType): boolean { + return type === 'sprite' || type === 'text'; +} diff --git a/src/core/model/Project.ts b/src/core/model/Project.ts index c66c2df8..e4d1fbbb 100644 --- a/src/core/model/Project.ts +++ b/src/core/model/Project.ts @@ -2,7 +2,7 @@ import type { Asset, SceneAsset } from './Asset'; import { isSceneAsset } from './Asset'; import type { SceneObject } from './SceneObject'; -/** Bumped whenever the persisted shape changes; the loader migrates older documents. */ +/** Written into every document. Reserved for format migrations – the loader does not branch on it yet. */ export const PROJECT_FORMAT_VERSION = 1; export interface Project { @@ -22,6 +22,7 @@ export function findAsset(project: Project, assetId: string): Asset | undefined return project.assets.find((a) => a.id === assetId); } +/** The scene asset with that id – undefined for other asset types as well. */ export function findScene(project: Project, sceneId: string): SceneAsset | undefined { const asset = findAsset(project, sceneId); return asset && isSceneAsset(asset) ? asset : undefined; @@ -31,17 +32,21 @@ export function listScenes(project: Project): SceneAsset[] { return project.assets.filter(isSceneAsset); } -export function findSceneObject(scene: SceneAsset, objectId: string): SceneObject | undefined { - return scene.objects.find((o) => o.id === objectId); +export interface SceneObjectRef { + scene: SceneAsset; + object: SceneObject; } -/** Every scene object (across all scenes) that references the given asset. */ -export function findUsages(project: Project, assetId: string): { scene: SceneAsset; object: SceneObject }[] { - const usages: { scene: SceneAsset; object: SceneObject }[] = []; +/** Objects of all scenes that satisfy `predicate`, each with the scene it belongs to. */ +export function sceneObjectsWhere(project: Project, predicate: (object: SceneObject) => boolean): SceneObjectRef[] { + const result: SceneObjectRef[] = []; for (const scene of listScenes(project)) { - for (const object of scene.objects) { - if (object.assetId === assetId) usages.push({ scene, object }); - } + for (const object of scene.objects) if (predicate(object)) result.push({ scene, object }); } - return usages; + return result; +} + +/** Every placed instance of an asset, across all scenes. */ +export function findUsages(project: Project, assetId: string): SceneObjectRef[] { + return sceneObjectsWhere(project, (o) => o.assetId === assetId); } diff --git a/src/core/model/Properties.ts b/src/core/model/Properties.ts index 84633cca..ebd9f3e3 100644 --- a/src/core/model/Properties.ts +++ b/src/core/model/Properties.ts @@ -1,9 +1,15 @@ -/** A single property value. Colors are stored as 0xRRGGBB numbers. */ +/** Colors are stored as 0xRRGGBB numbers. */ export type PropertyValue = number | string | boolean; /** Sparse map of property key → value (asset properties or scene-object overrides). */ export type PropertyBag = Record; +/** Writes `value` into the bag, or removes the key when the value is undefined (an undo of "no value"). */ +export function setBagValue(bag: PropertyBag, key: string, value: PropertyValue | undefined): void { + if (value === undefined) delete bag[key]; + else bag[key] = value; +} + /** A point in world or screen space. */ export interface Vec2 { x: number; @@ -18,6 +24,13 @@ export interface Position extends Vec2 { z: number; } +export type Axis = keyof Position; + +/** A full position from a 2D point (`z` = 0) or a position (copied). */ +export function toPosition(point: Vec2 | Position): Position { + return { x: point.x, y: point.y, z: 'z' in point ? point.z : 0 }; +} + /** Stable draw order: ascending `z`, ties keep the input (hierarchy) order. */ export function sortByZ(items: readonly T[]): T[] { return [...items].sort((a, b) => a.z - b.z); diff --git a/src/core/model/SceneObject.ts b/src/core/model/SceneObject.ts index c6007233..b5524ee6 100644 --- a/src/core/model/SceneObject.ts +++ b/src/core/model/SceneObject.ts @@ -1,4 +1,4 @@ -import type { Position, PropertyBag, Vec2 } from './Properties'; +import { type Position, type PropertyBag, toPosition, type Vec2 } from './Properties'; /** * An asset placed inside a scene. It links to the asset by id, owns a position @@ -42,7 +42,7 @@ export function createSceneObject(id: string, assetId: string, position: Vec2 | id, assetId, name: '', - position: { x: position.x, y: position.y, z: 'z' in position ? position.z : 0 }, + position: toPosition(position), editorOffset: { x: 0, y: 0 }, overrides: {}, parentId, diff --git a/src/core/model/container.ts b/src/core/model/container.ts index 5b6faa45..72240421 100644 --- a/src/core/model/container.ts +++ b/src/core/model/container.ts @@ -1,7 +1,7 @@ import { newId } from '../ids'; -import type { ContainerAsset, SceneAsset } from './Asset'; -import { findObject, type ObjectTree } from './hierarchy'; -import { findAsset, type Project } from './Project'; +import type { ContainerAsset } from './Asset'; +import { chainOf, type ObjectTree } from './hierarchy'; +import { findAsset, type Project, sceneObjectsWhere, type SceneObjectRef } from './Project'; import type { SceneObject } from './SceneObject'; /** What a placed container's content object is linked to: the container and its template object. */ @@ -18,28 +18,18 @@ export interface TemplateLink { */ export function templateLinkOf(project: Project, tree: ObjectTree, object: SceneObject): TemplateLink | undefined { if (!object.templateId) return undefined; - const seen = new Set([object.id]); - let current = object.parentId ? findObject(tree, object.parentId) : undefined; - while (current && !seen.has(current.id)) { - const asset = findAsset(project, current.assetId); - if (asset?.type === 'container') { - const template = asset.objects.find((t) => t.id === object.templateId); - if (template) return { containerObject: current, asset, template }; - } - seen.add(current.id); - current = current.parentId ? findObject(tree, current.parentId) : undefined; + for (const containerObject of chainOf(tree, object).slice(1)) { + const asset = findAsset(project, containerObject.assetId); + if (asset?.type !== 'container') continue; + const template = asset.objects.find((t) => t.id === object.templateId); + if (template) return { containerObject, asset, template }; } return undefined; } /** Every placed copy of a template object, across all scenes (template ids are unique per project). */ -export function instancesOf(project: Project, templateId: string): { scene: SceneAsset; object: SceneObject }[] { - const result: { scene: SceneAsset; object: SceneObject }[] = []; - for (const asset of project.assets) { - if (asset.type !== 'scene') continue; - for (const object of asset.objects) if (object.templateId === templateId) result.push({ scene: asset, object }); - } - return result; +export function instancesOf(project: Project, templateId: string): SceneObjectRef[] { + return sceneObjectsWhere(project, (o) => o.templateId === templateId); } /** Nesting depth at which a container inside a container inside … stops being expanded. */ @@ -58,12 +48,23 @@ export function instantiateContainer(project: Project, asset: ContainerAsset, co for (const template of asset.objects) { const id = ids.get(template.id)!; const parentId = template.parentId ? ids.get(template.parentId) ?? containerObjectId : containerObjectId; - // The editor offset stays with the instance, never with the template: it is a view tweak - // on one placed object, not something every copy of the container should inherit. - result.push({ id, assetId: template.assetId, name: template.name, position: { ...template.position }, editorOffset: { x: 0, y: 0 }, overrides: { ...template.overrides }, parentId, templateId: template.id }); + result.push({ + id, + assetId: template.assetId, + name: template.name, + position: { ...template.position }, + // The editor offset stays with the instance, never with the template: it is a view tweak + // on one placed object, not something every copy of the container should inherit. + editorOffset: { x: 0, y: 0 }, + overrides: { ...template.overrides }, + parentId, + templateId: template.id, + }); const nested = findAsset(project, template.assetId); const hasExplicitContent = asset.objects.some((o) => o.parentId === template.id); - if (nested?.type === 'container' && !hasExplicitContent && depth < MAX_NESTING) result.push(...instantiateContainer(project, nested, id, depth + 1)); + if (nested?.type === 'container' && !hasExplicitContent && depth < MAX_NESTING) { + result.push(...instantiateContainer(project, nested, id, depth + 1)); + } } return result; } diff --git a/src/core/model/hierarchy.ts b/src/core/model/hierarchy.ts index 37d9eef0..db55c320 100644 --- a/src/core/model/hierarchy.ts +++ b/src/core/model/hierarchy.ts @@ -1,3 +1,4 @@ +import { type Removed, removeWhere } from '../util/list'; import type { Position } from './Properties'; import type { SceneObject } from './SceneObject'; @@ -35,53 +36,24 @@ export function descendantsOf(tree: ObjectTree, id: string): SceneObject[] { return result; } -/** Ids of the ancestors of `id`, nearest parent first. Empty for a top-level object. */ -export function ancestorIdsOf(tree: ObjectTree, id: string): string[] { - const result: string[] = []; - let current = findObject(tree, id); - const seen = new Set(); - while (current?.parentId && !seen.has(current.id)) { - seen.add(current.id); - const parent = findObject(tree, current.parentId); - if (!parent || seen.has(parent.id)) break; - result.push(parent.id); - current = parent; +/** The ids of `rootIds` and of everything below them – what group operations act on. */ +export function subtreeIds(tree: ObjectTree, rootIds: Iterable): Set { + const ids = new Set(); + for (const rootId of rootIds) { + ids.add(rootId); + for (const child of descendantsOf(tree, rootId)) ids.add(child.id); } - return result; + return ids; } -/** Whether `ancestorId` is `objectId` itself or one of its ancestors (guards against cycles). */ -export function isSelfOrAncestor(tree: ObjectTree, ancestorId: string, objectId: string): boolean { - let current: SceneObject | undefined = findObject(tree, objectId); - const seen = new Set(); - while (current && !seen.has(current.id)) { - if (current.id === ancestorId) return true; - seen.add(current.id); - current = current.parentId ? findObject(tree, current.parentId) : undefined; - } - return false; -} - -/** - * Of the given ids, those that have no ancestor among the ids themselves – the tops of the - * selected subtrees. Whole subtrees are what group operations (move, remove, group) act on. - */ -export function subtreeRoots(tree: ObjectTree, ids: readonly string[]): string[] { - const set = new Set(ids); - return ids.filter((id) => { - let current = findObject(tree, id); - const seen = new Set(); - while (current?.parentId && !seen.has(current.id)) { - seen.add(current.id); - if (set.has(current.parentId)) return false; - current = findObject(tree, current.parentId); - } - return true; - }); +/** Takes the subtrees of `rootIds` out of the tree; returns them with their former indices (see `restoreAt`). */ +export function removeSubtrees(tree: ObjectTree, rootIds: readonly string[]): Removed[] { + const ids = subtreeIds(tree, rootIds); + return removeWhere(tree.objects, (o) => ids.has(o.id)); } /** An object and every parent above it, nearest first. Stops at a cycle (defensive). */ -function chainOf(tree: ObjectTree, object: SceneObject): SceneObject[] { +export function chainOf(tree: ObjectTree, object: SceneObject): SceneObject[] { const chain = [object]; const seen = new Set([object.id]); let parent = object.parentId ? findObject(tree, object.parentId) : undefined; @@ -93,30 +65,49 @@ function chainOf(tree: ObjectTree, object: SceneObject): SceneObject[] { return chain; } -/** Position of an object in world units (all local positions along the chain summed up). */ -export function worldPosition(tree: ObjectTree, object: SceneObject): Position { +/** Ids of the ancestors of `id`, nearest parent first. Empty for a top-level object. */ +export function ancestorIdsOf(tree: ObjectTree, id: string): string[] { + const object = findObject(tree, id); + return object ? chainOf(tree, object).slice(1).map((o) => o.id) : []; +} + +/** Whether `ancestorId` is `objectId` itself or one of its ancestors. */ +export function isSelfOrAncestor(tree: ObjectTree, ancestorId: string, objectId: string): boolean { + const object = findObject(tree, objectId); + return object !== undefined && chainOf(tree, object).some((o) => o.id === ancestorId); +} + +/** + * Of the given ids, those that have no ancestor among the ids themselves – the tops of the + * selected subtrees. Whole subtrees are what group operations (move, remove, group) act on. + */ +export function subtreeRoots(tree: ObjectTree, ids: readonly string[]): string[] { + const set = new Set(ids); + return ids.filter((id) => !ancestorIdsOf(tree, id).some((ancestorId) => set.has(ancestorId))); +} + +function sumAlongChain(tree: ObjectTree, object: SceneObject, withEditorOffset: boolean): Position { const result: Position = { x: 0, y: 0, z: 0 }; for (const o of chainOf(tree, object)) { - result.x += o.position.x; - result.y += o.position.y; + result.x += o.position.x + (withEditorOffset ? o.editorOffset.x : 0); + result.y += o.position.y + (withEditorOffset ? o.editorOffset.y : 0); result.z += o.position.z; } return result; } +/** Position of an object in world units (all local positions along the chain summed up). */ +export function worldPosition(tree: ObjectTree, object: SceneObject): Position { + return sumAlongChain(tree, object, false); +} + /** * Where the scene view draws an object: its world position plus the editor offsets along the * chain, so shifting a parent shifts everything below it. This is a view aid only – nothing * in the game, in collisions or in the graph ever sees it (they all use `worldPosition`). */ export function editorPosition(tree: ObjectTree, object: SceneObject): Position { - const result: Position = { x: 0, y: 0, z: 0 }; - for (const o of chainOf(tree, object)) { - result.x += o.position.x + (o.editorOffset?.x ?? 0); - result.y += o.position.y + (o.editorOffset?.y ?? 0); - result.z += o.position.z; - } - return result; + return sumAlongChain(tree, object, true); } /** The local position that puts an object at `world` under the given parent ('' = top level). */ @@ -155,7 +146,7 @@ export function treeOrder(tree: ObjectTree): TreeEntry[] { } export interface VisibleEntry extends TreeEntry { - /** Whether the object has children at all – only those rows offer a expand/collapse chevron. */ + /** Whether the object has children at all – only those rows offer an expand/collapse chevron. */ hasChildren: boolean; } @@ -168,7 +159,7 @@ export interface VisibleEntry extends TreeEntry { export function visibleTreeOrder(tree: ObjectTree, expanded: ReadonlySet): VisibleEntry[] { const parents = new Set(tree.objects.map((o) => o.parentId).filter(Boolean)); const result: VisibleEntry[] = []; - // Depth of the collapsed row we are currently skipping the subtree of (-1 = showing rows). + // Depth of the collapsed row whose subtree is being skipped (-1 = showing rows). let collapsedAt = -1; for (const entry of treeOrder(tree)) { if (collapsedAt >= 0) { diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 394369fe..a4d98e67 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -55,11 +55,20 @@ export const ASSET_SCHEMAS: Record = { // Behaviors declare their own properties (variables) per asset – see `schemaForAsset`. behavior: [], collider: [ - { key: 'shape', label: 'Shape', kind: 'enum', default: 'box', options: [{ value: 'box', label: 'Box' }, { value: 'circle', label: 'Circle' }] }, + { + key: 'shape', + label: 'Shape', + kind: 'enum', + default: 'box', + options: [ + { value: 'box', label: 'Box' }, + { value: 'circle', label: 'Circle' }, + ], + }, { key: 'width', label: 'Width', kind: 'number', default: 100, min: 1, step: 1 }, { key: 'height', label: 'Height', kind: 'number', default: 100, min: 1, step: 1 }, { key: 'radius', label: 'Radius', kind: 'number', default: 50, min: 1, step: 1 }, - // Triggers report collisions but never block "Move and slide"; solid colliders do both. + // Triggers report collisions but never block a colliding `Move`; solid colliders do both. { key: 'isTrigger', label: 'Trigger (no blocking)', kind: 'boolean', default: false }, ], // A container is pure structure: its content is the template, not a property. @@ -68,7 +77,17 @@ export const ASSET_SCHEMAS: Record = { { key: 'text', label: 'Text', kind: 'text', default: 'Text' }, { key: 'fontSize', label: 'Font size', kind: 'number', default: 32, min: 1, step: 1 }, { key: 'color', label: 'Color', kind: 'color', default: 0xffffff }, - { key: 'align', label: 'Align', kind: 'enum', default: 'center', options: [{ value: 'left', label: 'Left' }, { value: 'center', label: 'Center' }, { value: 'right', label: 'Right' }] }, + { + key: 'align', + label: 'Align', + kind: 'enum', + default: 'center', + options: [ + { value: 'left', label: 'Left' }, + { value: 'center', label: 'Center' }, + { value: 'right', label: 'Right' }, + ], + }, ], }; @@ -77,12 +96,6 @@ const FULL_SCHEMAS: Record = Object.fromEntries( (Object.keys(ASSET_SCHEMAS) as AssetType[]).map((type) => [type, type === 'scene' ? ASSET_SCHEMAS[type] : [...COMMON_PROPS, ...ASSET_SCHEMAS[type]]]), ) as Record; -/** Properties every scene object has regardless of its asset type. */ -export const SCENE_OBJECT_SCHEMA: PropertySchema = [ - { key: 'x', label: 'X', kind: 'number', default: 0, step: 1 }, - { key: 'y', label: 'Y', kind: 'number', default: 0, step: 1 }, -]; - /** Schema shared by every asset of a type (common properties first). */ export function schemaFor(type: AssetType): PropertySchema { return FULL_SCHEMAS[type]; @@ -93,6 +106,12 @@ export function schemaForAsset(asset: Asset): PropertySchema { return asset.type === 'behavior' ? [...COMMON_PROPS, ...asset.variables] : FULL_SCHEMAS[asset.type]; } +/** The definition of `key` in the effective schema of `asset`, if it has one. */ +export function propDef(asset: Asset, key: string): PropertyDef | undefined { + return schemaForAsset(asset).find((d) => d.key === key); +} + +/** A fresh bag of the type's default values (callers may mutate it). */ export function defaultsFor(type: AssetType): PropertyBag { return defaultsOf(FULL_SCHEMAS[type]); } @@ -115,7 +134,7 @@ export function variableDef(key: string, kind: VariableKind, defaultValue?: Prop } } -/** Clamp / coerce a raw value so it satisfies the definition. Unknown kinds are returned as-is. */ +/** Clamp / coerce a raw value so it satisfies the definition. */ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyValue { switch (def.kind) { case 'number': { diff --git a/src/core/properties/bounds.ts b/src/core/properties/bounds.ts index 90e3397a..f8269761 100644 --- a/src/core/properties/bounds.ts +++ b/src/core/properties/bounds.ts @@ -1,14 +1,15 @@ +/** + * Footprints in world units – how much room an object takes up. Shared by the viewport + * (culling, selection gizmos), by pointer hit tests in the running game and by "Add + * collider", so all three agree on how big a thing is. + */ import type { AssetType } from '../model/AssetTypes'; import type { PropertyBag, Vec2 } from '../model/Properties'; import { cameraFrame } from './camera'; import { colliderSize } from './collider'; import { defaultsFor } from './PropertySchema'; -/** - * Footprints in world units – how much room an object takes up. Shared by the viewport - * (culling, selection gizmos), by pointer hit tests in the running game and by "Add - * collider", so all three agree on how big a thing is. - */ +const TEXT_DEFAULTS = defaultsFor('text'); /** World-unit size of the behavior symbol (a diamond). */ export const BEHAVIOR_SYMBOL = 28; @@ -21,16 +22,15 @@ const LINE_HEIGHT = 1.25; /** Size a text object covers, estimated from its content and font size. */ export function textSize(props: PropertyBag): { w: number; h: number } { - const d = defaultsFor('text'); - const size = Math.max(1, Number(props.fontSize ?? d.fontSize)); - const lines = String(props.text ?? d.text).split('\n'); + const size = Math.max(1, Number(props.fontSize ?? TEXT_DEFAULTS.fontSize)); + const lines = String(props.text ?? TEXT_DEFAULTS.text).split('\n'); const longest = lines.reduce((max, line) => Math.max(max, line.length), 0); return { w: Math.max(size * CHAR_WIDTH, longest * size * CHAR_WIDTH), h: lines.length * size * LINE_HEIGHT }; } /** Horizontal anchor of a text object: 0 = left edge on the position, 0.5 = centred, 1 = right. */ export function textAnchor(props: PropertyBag): number { - const align = String(props.align ?? defaultsFor('text').align); + const align = String(props.align ?? TEXT_DEFAULTS.align); return align === 'left' ? 0 : align === 'right' ? 1 : 0.5; } diff --git a/src/core/properties/camera.ts b/src/core/properties/camera.ts index 46e78df7..592831aa 100644 --- a/src/core/properties/camera.ts +++ b/src/core/properties/camera.ts @@ -1,6 +1,8 @@ import type { PropertyBag } from '../model/Properties'; import { defaultsFor } from './PropertySchema'; +const DEFAULTS = defaultsFor('camera'); + /** Axis-aligned rectangle in world units. */ export interface WorldRect { left: number; @@ -14,9 +16,8 @@ export interface WorldRect { * zoom. This is the frame drawn in edit mode and the only region rendered in play mode. */ export function cameraFrame(props: PropertyBag): { w: number; h: number } { - const d = defaultsFor('camera'); - const zoom = Math.max(0.01, Number(props.zoom ?? d.zoom)); - return { w: Number(props.width ?? d.width) / zoom, h: Number(props.height ?? d.height) / zoom }; + const zoom = Math.max(0.01, Number(props.zoom ?? DEFAULTS.zoom)); + return { w: Number(props.width ?? DEFAULTS.width) / zoom, h: Number(props.height ?? DEFAULTS.height) / zoom }; } export function rectAround(cx: number, cy: number, w: number, h: number): WorldRect { diff --git a/src/core/properties/collider.ts b/src/core/properties/collider.ts index 0104ffd9..f60a7edf 100644 --- a/src/core/properties/collider.ts +++ b/src/core/properties/collider.ts @@ -1,24 +1,31 @@ -import type { PropertyBag, Vec2 } from '../model/Properties'; -import { defaultsFor } from './PropertySchema'; - /** * Collider geometry in world units. Pure functions shared by the editor (drawing, gizmos) * and the runtime (CollisionSystem), so both agree on what "overlaps" means. */ +import type { PropertyBag, Vec2 } from '../model/Properties'; +import { defaultsFor } from './PropertySchema'; + +const DEFAULTS = defaultsFor('collider'); + export type ColliderShape = | { kind: 'box'; x: number; y: number; w: number; h: number } | { kind: 'circle'; x: number; y: number; r: number }; +type Box = Extract; +type Circle = Extract; + +/** A movement axis in the plane (`z` is draw order, not geometry). */ +export type PlaneAxis = 'x' | 'y'; + /** The shape described by collider properties, centred on `centre` (a world position). */ export function colliderShape(props: PropertyBag, centre: Vec2): ColliderShape { - const d = defaultsFor('collider'); - if (props.shape === 'circle') return { kind: 'circle', x: centre.x, y: centre.y, r: Math.max(0, Number(props.radius ?? d.radius)) }; - return { kind: 'box', x: centre.x, y: centre.y, w: Math.max(0, Number(props.width ?? d.width)), h: Math.max(0, Number(props.height ?? d.height)) }; + if (props.shape === 'circle') return { kind: 'circle', x: centre.x, y: centre.y, r: Math.max(0, Number(props.radius ?? DEFAULTS.radius)) }; + return { kind: 'box', x: centre.x, y: centre.y, w: Math.max(0, Number(props.width ?? DEFAULTS.width)), h: Math.max(0, Number(props.height ?? DEFAULTS.height)) }; } /** Whether the collider only reports overlaps (true) or also blocks movement (false). */ export function isTrigger(props: PropertyBag): boolean { - return Boolean(props.isTrigger ?? defaultsFor('collider').isTrigger); + return Boolean(props.isTrigger ?? DEFAULTS.isTrigger); } /** Footprint of a collider shape (used for culling and the selection gizmo). */ @@ -42,23 +49,20 @@ export function shapesOverlap(a: ColliderShape, b: ColliderShape): boolean { const r = a.r + b.r; return (a.x - b.x) ** 2 + (a.y - b.y) ** 2 < r * r; } - const box = a.kind === 'box' ? a : (b as Extract); - const circle = a.kind === 'circle' ? a : (b as Extract); + const { box, circle } = boxAndCircle(a, b); // Closest point of the box to the circle centre, then a plain distance check. const cx = Math.max(box.x - box.w / 2, Math.min(circle.x, box.x + box.w / 2)); const cy = Math.max(box.y - box.h / 2, Math.min(circle.y, box.y + box.h / 2)); return (circle.x - cx) ** 2 + (circle.y - cy) ** 2 < circle.r * circle.r; } -export type Axis = 'x' | 'y'; - /** * The centre distance along `axis` at which the two shapes exactly touch, given their * current offset on the other axis. Only meaningful when the shapes overlap on that other * axis (otherwise they cannot collide by moving along `axis`); returns 0 when they do not. */ -export function axisSeparation(a: ColliderShape, b: ColliderShape, axis: Axis): number { - const other: Axis = axis === 'x' ? 'y' : 'x'; +export function axisSeparation(a: ColliderShape, b: ColliderShape, axis: PlaneAxis): number { + const other: PlaneAxis = axis === 'x' ? 'y' : 'x'; const offset = Math.abs(a[other] - b[other]); if (a.kind === 'box' && b.kind === 'box') { return offset * 2 < extent(a, other) + extent(b, other) ? (extent(a, axis) + extent(b, axis)) / 2 : 0; @@ -67,8 +71,7 @@ export function axisSeparation(a: ColliderShape, b: ColliderShape, axis: Axis): const r = a.r + b.r; return offset < r ? Math.sqrt(r * r - offset * offset) : 0; } - const box = a.kind === 'box' ? a : (b as Extract); - const circle = a.kind === 'circle' ? a : (b as Extract); + const { box, circle } = boxAndCircle(a, b); const halfOther = extent(box, other) / 2; const halfAxis = extent(box, axis) / 2; // Circle centre within the box's span on the other axis: the flat side is hit. @@ -83,11 +86,16 @@ export function axisSeparation(a: ColliderShape, b: ColliderShape, axis: Axis): * `axis` so it just touches `obstacle` (0 when they do not overlap). Only valid when * `mover` approached from the far side, i.e. it did not overlap before its move. */ -export function axisCorrection(mover: ColliderShape, obstacle: ColliderShape, axis: Axis, dir: 1 | -1): number { +export function axisCorrection(mover: ColliderShape, obstacle: ColliderShape, axis: PlaneAxis, dir: 1 | -1): number { if (!shapesOverlap(mover, obstacle)) return 0; return Math.max(0, dir * (mover[axis] - obstacle[axis]) + axisSeparation(mover, obstacle, axis)); } -function extent(box: Extract, axis: Axis): number { +/** The mixed pair (one box, one circle) sorted by kind. */ +function boxAndCircle(a: ColliderShape, b: ColliderShape): { box: Box; circle: Circle } { + return a.kind === 'box' ? { box: a, circle: b as Circle } : { box: b as Box, circle: a }; +} + +function extent(box: Box, axis: PlaneAxis): number { return axis === 'x' ? box.w : box.h; } diff --git a/src/core/properties/enabled.ts b/src/core/properties/enabled.ts index 6c1a2ad1..a91e637f 100644 --- a/src/core/properties/enabled.ts +++ b/src/core/properties/enabled.ts @@ -1,14 +1,13 @@ -import type { PropertyBag } from '../model/Properties'; - /** * The `enabled` switch (see `COMMON_PROPS`). Disabling an object takes it out of the running * game completely – not drawn, no collisions, no behavior ticks – and the same holds for its * children, because "enabled" is only true when the whole chain up to the root is on. That is * what makes a container usable as a screen: switch it on to show it, off to hide it. */ +import type { PropertyBag } from '../model/Properties'; -/** Whether a resolved property bag says "on". A missing value counts as on. */ -export function propsEnabled(props: PropertyBag): boolean { +/** A missing value counts as on. */ +function propsEnabled(props: PropertyBag): boolean { return props.enabled !== false; } diff --git a/src/core/properties/resolve.ts b/src/core/properties/resolve.ts index 9eba2894..fa4951d4 100644 --- a/src/core/properties/resolve.ts +++ b/src/core/properties/resolve.ts @@ -1,7 +1,7 @@ import type { Asset } from '../model/Asset'; import type { PropertyBag, PropertyValue } from '../model/Properties'; import type { SceneObject } from '../model/SceneObject'; -import { schemaForAsset } from './PropertySchema'; +import { propDef, schemaForAsset } from './PropertySchema'; /** * Effective property values of an asset: schema defaults filled with the asset's own values. @@ -38,7 +38,7 @@ export function isOverridden(object: SceneObject, key: string): boolean { * counterpart of an override. The inspector highlights such values and offers a reset. */ export function isModified(asset: Asset, key: string): boolean { - const def = schemaForAsset(asset).find((d) => d.key === key); + const def = propDef(asset, key); if (!def) return false; const own = asset.props[key]; return own !== undefined && own !== def.default; diff --git a/src/core/store/HttpProjectStore.ts b/src/core/store/HttpProjectStore.ts index 23b7dee3..bd60ac73 100644 --- a/src/core/store/HttpProjectStore.ts +++ b/src/core/store/HttpProjectStore.ts @@ -36,6 +36,7 @@ export class HttpProjectStore implements ProjectStore { await this.request('DELETE', `projects/${encodeURIComponent(id)}`); } + /** One JSON round trip; 409 becomes a `StaleRevisionError`, every other failure an `Error` with the server's message. */ private async request(method: string, route: string, payload?: unknown): Promise { const url = `${this.endpoint}?r=${encodeURIComponent(route)}`; const response = await fetch(url, { diff --git a/src/core/util/list.ts b/src/core/util/list.ts new file mode 100644 index 00000000..0a10f254 --- /dev/null +++ b/src/core/util/list.ts @@ -0,0 +1,20 @@ +/** An item taken out of a list together with the index it had, so it can be put back exactly. */ +export interface Removed { + item: T; + index: number; +} + +/** Removes every item matching `predicate` in place; returns them with their former indices, ascending. */ +export function removeWhere(list: T[], predicate: (item: T) => boolean): Removed[] { + const removed: Removed[] = []; + list.forEach((item, index) => { + if (predicate(item)) removed.push({ item, index }); + }); + for (let i = removed.length - 1; i >= 0; i--) list.splice(removed[i]!.index, 1); + return removed; +} + +/** Puts removed items back at their former indices (ascending order restores the list exactly). */ +export function restoreAt(list: T[], removed: readonly Removed[]): void { + for (const entry of [...removed].sort((a, b) => a.index - b.index)) list.splice(entry.index, 0, entry.item); +} diff --git a/src/editor/App.ts b/src/editor/App.ts index 4bbc55cd..d03ead3e 100644 --- a/src/editor/App.ts +++ b/src/editor/App.ts @@ -1,409 +1,16 @@ -import { type Application, Container, Text } from 'pixi.js'; -import { DeleteAssetCommand, SetActiveSceneCommand } from '../core/commands/assetCommands'; -import { RemoveNodeCommand } from '../core/commands/graphCommands'; -import { listScenes } from '../core/model/Project'; +import type { Application } from 'pixi.js'; import { HttpProjectStore } from '../core/store/HttpProjectStore'; -import type { ProjectStore } from '../core/store/ProjectStore'; -import { BehaviorSystem } from '../runtime/systems/BehaviorSystem'; -import { CollisionSystem } from '../runtime/systems/CollisionSystem'; -import { Input } from '../runtime/systems/Input'; -import { World } from '../runtime/World'; -import { Button, Dialog, HStack, Menu, type MenuItem, Splitter, Theme, ui, VStack, type Widget } from '../ui'; -import { Viewport } from '../viewport/Viewport'; -import { createContainer, removeObjects } from './actions'; -import { attachAutosave } from './autosave'; -import { GraphEditor } from './graph/GraphEditor'; -import { AssetPanel } from './panels/AssetPanel'; -import { HierarchyPanel } from './panels/HierarchyPanel'; -import { Inspector } from './panels/Inspector'; -import { Toolbar } from './panels/Toolbar'; +import type { VStack } from '../ui'; +import { installDebugHook } from './debugHook'; +import { Editor } from './Editor'; import { EditorState } from './state'; const API_ENDPOINT = '/game-maker/api/index.php'; -type CentreTab = 'scene' | 'graph' | 'game'; - -/** Composes panels, viewport, graph editor and runtime into the one application that edits and plays. */ +/** Wires state, store and the editor composition together and opens the first project. */ export async function startEditor(app: Application, root: VStack): Promise { const state = new EditorState(); - const store: ProjectStore = new HttpProjectStore(API_ENDPOINT); - const editor = new Editor(app, root, state, store); + const editor = new Editor(app, root, state, new HttpProjectStore(API_ENDPOINT)); installDebugHook(app, state, editor); await editor.boot(); } - -/** - * `window.__gm` – used by tools/e2e.mjs to find widgets by their visible text and to - * inspect state. Harmless in production (read-only access). - */ -function installDebugHook(app: Application, state: EditorState, editor: Editor): void { - const { viewport, gameView, graphEditor } = editor; - const viewportCenter = () => { - const p = viewport.getGlobalPosition(); - return { x: p.x + viewport.w / 2, y: p.y + viewport.h / 2 }; - }; - /** Centre of the first (or, with `last`, the topmost/last-drawn) visible text equal to `text`. */ - const locate = (text: string, last = false): { x: number; y: number } | null => { - let found: Text | null = null; - const walk = (node: Container): void => { - if ((found && !last) || !node.visible) return; - if (node instanceof Text && node.text === text) { - found = node; - return; - } - for (const child of node.children) walk(child); - }; - walk(app.stage); - if (!found) return null; - const node = found as Text; - const p = node.getGlobalPosition(); - return { x: p.x + node.width / 2, y: p.y + node.height / 2 }; - }; - (window as unknown as { __gm: unknown }).__gm = { - state, - locate, - viewportCenter, - viewportInfo: () => viewport.debugInfo(), - gameInfo: () => gameView.debugInfo(), - layerOf: (id: string) => viewport.layerOf(id), - drawOrder: () => viewport.drawOrder(), - /** Screen position of an object in the Scene tab (its world position through the view). */ - objectScreen: (id: string) => viewport.objectScreen(id), - /** The same for the Game tab (through the camera), so tests can click what the player sees. */ - gameScreen: (id: string) => gameView.objectScreen(id), - graph: { - current: () => graphEditor.currentAssetId, - portAt: (nodeId: string, port: string, isOutput: boolean) => graphEditor.portGlobal(nodeId, port, isOutput), - nodeAt: (nodeId: string) => graphEditor.nodeGlobal(nodeId), - center: () => { - const p = graphEditor.getGlobalPosition(); - return { x: p.x + graphEditor.w / 2, y: p.y + graphEditor.h / 2 }; - }, - }, - world: () => editor.worldSnapshot(), - /** Rows the hierarchy shows – collapsed subtrees are not in here. */ - hierarchyOrder: () => editor.hierarchy.rowOrder(), - hierarchyChevron: (id: string) => editor.hierarchy.chevronPosition(id), - hierarchyExpandAll: () => editor.hierarchy.expandAll(), - hierarchyCollapseAll: () => editor.hierarchy.collapseAll(), - fps: () => editor.fps, - tab: () => editor.tab, - showTab: (tab: CentreTab) => editor.showTab(tab), - }; -} - -class Editor { - /** Scene tab: the editable model view (live positions while playing). */ - readonly viewport: Viewport; - /** Game tab: the running world through its camera. */ - readonly gameView: Viewport; - readonly graphEditor: GraphEditor; - readonly hierarchy: HierarchyPanel; - tab: CentreTab = 'scene'; - private readonly leftColumn: VStack; - private readonly inspector: Inspector; - private readonly leftSplitter = new Splitter('x'); - private readonly rightSplitter = new Splitter('x'); - private readonly workspace: HStack; - private readonly centre: VStack; - private readonly tabButtons: Record; - private world: World | null = null; - private behaviors: BehaviorSystem | null = null; - private readonly input = new Input(); - private readonly toolbar: Toolbar; - /** Frame-rate meter: frames counted over a sliding ~0.5 s window, shown in the toolbar. */ - private fpsFrames = 0; - private fpsElapsed = 0; - fps: number | null = null; - private leftWidth = 240; - private rightWidth = 300; - - constructor(app: Application, root: VStack, private readonly state: EditorState, private readonly store: ProjectStore) { - this.toolbar = new Toolbar(state, { - openProjectMenu: (anchor) => this.openProjectMenu(anchor), - openSceneMenu: (anchor) => this.openSceneMenu(anchor), - togglePlay: () => (state.mode === 'edit' ? this.play() : this.stop()), - }); - root.add(this.toolbar); - - this.workspace = new HStack(); - this.viewport = new Viewport(state); - this.gameView = new Viewport(state, { game: true }); - this.graphEditor = new GraphEditor(state); - this.graphEditor.traceProvider = (assetId) => this.behaviors?.traceFor(assetId) ?? null; - this.graphEditor.timeProvider = () => this.world?.time ?? 0; - this.leftColumn = new VStack({ gap: 1 }); - this.leftColumn.add(new AssetPanel(state, () => this.viewport.worldCenter()), 1); - this.hierarchy = new HierarchyPanel(state); - this.leftColumn.add(this.hierarchy, 1); - this.inspector = new Inspector(state); - - // Centre: a tab strip (Scene | Game | Graph) above the scene view, the graph editor or the game view. - this.centre = new VStack(); - const tabs = new HStack({ gap: 2, padding: 4, align: 'center' }); - tabs.minHeight = Theme.controlHeight + 8; - this.tabButtons = { - scene: new Button('Scene', { variant: 'ghost', width: 90, onClick: () => this.showTab('scene') }), - graph: new Button('Graph', { variant: 'ghost', width: 90, onClick: () => this.showTab('graph') }), - game: new Button('Game', { variant: 'ghost', width: 90, onClick: () => this.showTab('game') }), - }; - tabs.add(this.tabButtons.scene); - tabs.add(this.tabButtons.game); - tabs.add(this.tabButtons.graph); - this.centre.add(tabs); - this.centre.add(this.viewport, 1); - this.centre.add(this.graphEditor, 1); - this.centre.add(this.gameView, 1); - this.graphEditor.visible = false; - this.gameView.visible = false; - this.tabButtons.scene.setToggled(true); - - this.leftColumn.minWidth = this.leftWidth; - this.inspector.minWidth = this.rightWidth; - this.leftSplitter.onDrag = (delta) => { - this.leftWidth = Math.max(160, Math.min(500, this.leftWidth + delta)); - this.leftColumn.minWidth = this.leftWidth; - this.workspace.relayout(); - }; - this.rightSplitter.onDrag = (delta) => { - this.rightWidth = Math.max(200, Math.min(600, this.rightWidth - delta)); - this.inspector.minWidth = this.rightWidth; - this.workspace.relayout(); - }; - this.workspace.add(this.leftColumn); - this.workspace.add(this.leftSplitter); - this.workspace.add(this.centre, 1); - this.workspace.add(this.rightSplitter); - this.workspace.add(this.inspector); - root.add(this.workspace, 1); - root.resize(app.screen.width, app.screen.height); - - attachAutosave(state, store); - this.installShortcuts(); - app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000)); - // Selecting a graph node switches to the graph; selecting a behavior does not force it. - state.on('selection', () => { - if (state.selection?.kind === 'node' && this.tab !== 'graph') this.showTab('graph'); - }); - // While playing, every edit (command, undo, scrub preview) is mirrored into the world at once. - state.on('project', () => this.world?.syncFromModel()); - } - - showTab(tab: CentreTab): void { - this.tab = tab; - this.viewport.visible = tab === 'scene'; - this.graphEditor.visible = tab === 'graph'; - this.gameView.visible = tab === 'game'; - for (const [key, button] of Object.entries(this.tabButtons)) button.setToggled(key === tab); - this.centre.relayout(); - ui().focus.validate(); - } - - // --- project lifecycle --------------------------------------------------------------- - - async boot(): Promise { - const wanted = new URLSearchParams(location.search).get('project'); - try { - if (wanted) { - await this.open(wanted); - return; - } - const list = await this.store.list(); - if (list[0]) await this.open(list[0].id); - else await this.createProject('My first project'); - } catch (error) { - this.state.setNotice(`Could not load project: ${error instanceof Error ? error.message : String(error)}`); - } - } - - private async open(id: string): Promise { - if (this.world) this.stop(); - const loaded = await this.store.load(id); - this.state.loadProject(loaded.project, loaded.revision); - const url = new URL(location.href); - url.searchParams.set('project', id); - history.replaceState(null, '', url); - } - - private async createProject(name: string): Promise { - const created = await this.store.create(name); - await this.open(created.project.id); - } - - private openProjectMenu(anchor: Widget): void { - const g = anchor.getGlobalPosition(); - const open = async () => { - const list = await this.store.list(); - const items: MenuItem[] = [ - { label: 'New project', onSelect: () => void this.createProject(`Project ${list.length + 1}`) }, - ...list.map((p) => ({ - label: p.id === this.state.project.id ? `● ${p.name}` : p.name, - onSelect: () => void this.open(p.id), - disabled: p.id === this.state.project.id, - })), - { - label: 'Delete this project…', - danger: true, - onSelect: () => - new Dialog(`Delete project "${this.state.project.name}"?`, 'All assets and scenes are removed permanently.', [ - { label: 'Cancel' }, - { label: 'Delete', danger: true, onClick: () => void this.deleteCurrentProject() }, - ]).openCentered(), - }, - ]; - new Menu(items).open(g.x, g.y + anchor.h + 2); - }; - void open().catch((error) => this.state.setNotice(String(error))); - } - - private async deleteCurrentProject(): Promise { - if (this.world) this.stop(); - await this.store.delete(this.state.project.id); - const list = await this.store.list(); - if (list[0]) await this.open(list[0].id); - else await this.createProject('My first project'); - } - - private openSceneMenu(anchor: Widget): void { - const scenes = listScenes(this.state.project); - const g = anchor.getGlobalPosition(); - const items: MenuItem[] = scenes.map((scene) => ({ - label: scene.id === this.state.project.activeSceneId ? `● ${scene.name}` : scene.name, - onSelect: () => this.state.execute(new SetActiveSceneCommand(scene.id)), - })); - if (items.length === 0) items.push({ label: 'No scenes – create one in Assets', onSelect: () => {}, disabled: true }); - new Menu(items).open(g.x, g.y + anchor.h + 2); - } - - // --- play mode ------------------------------------------------------------------------- - - /** - * Play: the state switches to a runtime copy of the project, the world is built from that - * copy and shown in the Game tab. All panels stay open and edit the copy – the world picks - * those edits up immediately, and nothing reaches the saved model. - */ - private play(): void { - const scene = this.state.activeScene; - if (!scene) return; - ui().focus.blur(); - const runtime = this.state.enterPlay(); - 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.gameView.setWorld(world); - this.showTab('game'); - this.state.setNotice(world.firstOfType('camera') ? 'Playing · edits are not saved · Esc stops' : 'This scene has no camera – nothing is rendered · Esc stops'); - if (world.stopRequested) this.stop(); - } - - /** Stop: the world and the runtime copy are discarded; the saved model returns untouched. */ - private stop(): void { - const world = this.world; - this.world = null; - this.behaviors = null; - this.input.detach(); - ui().focus.blur(); - world?.destroy(); - this.gameView.setWorld(null); - this.state.exitPlay(); - if (this.tab === 'game') this.showTab('scene'); - const lastError = world?.log.find((line) => line.startsWith('Error in behavior')); - this.state.setNotice(lastError ?? ''); - } - - private tick(dt: number): void { - this.measureFps(dt); - const world = this.world; - if (!world) return; - world.tick(Math.min(dt, 0.1)); - this.input.endFrame(); - if (world.stopRequested) { - this.stop(); - return; - } - // Restart game: throw the run away and start the scene again from the saved model. - if (world.restartRequested) { - this.stop(); - this.play(); - return; - } - // Mirror what the systems did into the runtime copy so the panels show live values. - // A destroyed entity removes its object – that is a structural change the lists must see. - if (world.syncToModel()) this.state.preview(); - this.state.runtimeTick(); - if (this.graphEditor.visible) this.graphEditor.redraw(); - } - - /** Counts rendered frames (edit and play alike) and refreshes the toolbar readout twice a second. */ - private measureFps(dt: number): void { - this.fpsFrames += 1; - this.fpsElapsed += dt; - if (this.fpsElapsed < 0.5) return; - this.fps = Math.round(this.fpsFrames / this.fpsElapsed); - this.fpsFrames = 0; - this.fpsElapsed = 0; - this.toolbar.setFps(this.fps); - } - - /** Read-only view of the running world for tests. */ - worldSnapshot(): { entities: { objectId: string; assetId: string; name: string; x: number; y: number; worldX: number; worldY: number; parent: string; props: Record }[]; log: string[] } | null { - const world = this.world; - if (!world) return null; - return { - entities: world.entities.map((e) => { - const w = world.worldPosition(e); - return { objectId: e.objectId, assetId: e.assetId, name: e.name, x: e.x, y: e.y, worldX: w.x, worldY: w.y, parent: e.parent?.objectId ?? '', props: { ...e.props } }; - }), - log: [...world.log], - }; - } - - // --- keyboard -------------------------------------------------------------------------- - - private installShortcuts(): void { - ui().focus.globalHandlers.push((event) => { - const mod = event.ctrlKey || event.metaKey; - if (event.key === 'Escape' && this.state.mode === 'play') { - this.stop(); - return true; - } - if (mod && event.key.toLowerCase() === 'z') { - if (event.shiftKey) this.state.redo(); - else this.state.undo(); - return true; - } - if (mod && event.key.toLowerCase() === 'y') { - this.state.redo(); - return true; - } - if (mod && event.key.toLowerCase() === 'g') { - const s = this.state.selection; - if (s?.kind === 'object') createContainer(this.state, s.sceneId, s.objectIds); - return true; - } - if (event.key === 'Delete' || event.key === 'Backspace') { - const s = this.state.selection; - if (s?.kind === 'object') removeObjects(this.state, s.sceneId, s.objectIds); - else if (s?.kind === 'node') this.state.execute(new RemoveNodeCommand(s.assetId, s.nodeId)); - else if (s?.kind === 'asset') { - const asset = this.state.project.assets.find((a) => a.id === s.assetId); - if (asset) new Dialog(`Delete "${asset.name}"?`, 'Placed instances are removed as well.', [ - { label: 'Cancel' }, - { label: 'Delete', danger: true, onClick: () => this.state.execute(new DeleteAssetCommand(s.assetId)) }, - ]).openCentered(); - } - return s !== null; - } - if (mod && event.key === 'Enter' && this.state.mode === 'edit') { - this.play(); - return true; - } - return false; - }); - } -} diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts new file mode 100644 index 00000000..7f5d1211 --- /dev/null +++ b/src/editor/Editor.ts @@ -0,0 +1,301 @@ +import type { Application } from 'pixi.js'; +import { SetActiveSceneCommand } from '../core/commands/assetCommands'; +import { worldPositionOf } from '../core/graph'; +import { listScenes } from '../core/model/Project'; +import type { ProjectStore } from '../core/store/ProjectStore'; +import { BehaviorSystem } from '../runtime/systems/BehaviorSystem'; +import { CollisionSystem } from '../runtime/systems/CollisionSystem'; +import { Input } from '../runtime/systems/Input'; +import { World } from '../runtime/World'; +import { Button, Dialog, HStack, Menu, type MenuItem, Splitter, Theme, ui, VStack, type Widget } from '../ui'; +import { Viewport } from '../viewport/Viewport'; +import { attachAutosave } from './autosave'; +import { GraphEditor } from './graph/GraphEditor'; +import { Inspector } from './inspector/Inspector'; +import { AssetPanel } from './panels/AssetPanel'; +import { HierarchyPanel } from './panels/HierarchyPanel'; +import { Toolbar } from './panels/Toolbar'; +import { installShortcuts } from './shortcuts'; +import type { EditorState } from './state'; + +export type CentreTab = 'scene' | 'graph' | 'game'; + +/** Facts about the running world for the check scripts. */ +export interface WorldSnapshot { + entities: { objectId: string; assetId: string; name: string; x: number; y: number; worldX: number; worldY: number; parent: string; props: Record }[]; + log: string[]; +} + +/** + * Composes toolbar, panels, viewports, graph editor and runtime into the one application that + * edits and plays. Owns the play session: the world, its systems and the tick. + */ +export class Editor { + /** Scene tab: the editable model view (live positions while playing). */ + readonly viewport: Viewport; + /** Game tab: the running world through its camera. */ + readonly gameView: Viewport; + readonly graphEditor: GraphEditor; + readonly hierarchy: HierarchyPanel; + tab: CentreTab = 'scene'; + /** Frame rate, refreshed twice a second; null until the first measurement. */ + fps: number | null = null; + private readonly leftColumn: VStack; + private readonly inspector: Inspector; + private readonly leftSplitter = new Splitter('x'); + private readonly rightSplitter = new Splitter('x'); + private readonly workspace: HStack; + private readonly centre: VStack; + private readonly tabButtons: Record; + private readonly toolbar: Toolbar; + private world: World | null = null; + private behaviors: BehaviorSystem | null = null; + private readonly input = new Input(); + private fpsFrames = 0; + private fpsElapsed = 0; + private leftWidth = 240; + private rightWidth = 300; + + constructor(app: Application, root: VStack, private readonly state: EditorState, private readonly store: ProjectStore) { + this.toolbar = new Toolbar(state, { + openProjectMenu: (anchor) => this.openProjectMenu(anchor), + openSceneMenu: (anchor) => this.openSceneMenu(anchor), + togglePlay: () => (state.mode === 'edit' ? this.play() : this.stop()), + }); + root.add(this.toolbar); + + this.workspace = new HStack(); + this.viewport = new Viewport(state); + this.gameView = new Viewport(state, { game: true }); + this.graphEditor = new GraphEditor(state); + this.graphEditor.traceProvider = (assetId) => this.behaviors?.traceFor(assetId) ?? null; + this.graphEditor.timeProvider = () => this.world?.time ?? 0; + this.leftColumn = new VStack({ gap: 1 }); + this.leftColumn.add(new AssetPanel(state, () => this.viewport.worldCenter()), 1); + this.hierarchy = new HierarchyPanel(state); + this.leftColumn.add(this.hierarchy, 1); + this.inspector = new Inspector(state); + + // Centre: a tab strip (Scene | Game | Graph) above the scene view, the game view or the graph editor. + this.centre = new VStack(); + const tabs = new HStack({ gap: 2, padding: 4, align: 'center' }); + tabs.minHeight = Theme.controlHeight + 8; + this.tabButtons = { + scene: new Button('Scene', { variant: 'ghost', width: 90, onClick: () => this.showTab('scene') }), + game: new Button('Game', { variant: 'ghost', width: 90, onClick: () => this.showTab('game') }), + graph: new Button('Graph', { variant: 'ghost', width: 90, onClick: () => this.showTab('graph') }), + }; + for (const tab of ['scene', 'game', 'graph'] as const) tabs.add(this.tabButtons[tab]); + this.centre.add(tabs); + this.centre.add(this.viewport, 1); + this.centre.add(this.graphEditor, 1); + this.centre.add(this.gameView, 1); + this.graphEditor.visible = false; + this.gameView.visible = false; + this.tabButtons.scene.setToggled(true); + + this.leftColumn.minWidth = this.leftWidth; + this.inspector.minWidth = this.rightWidth; + this.leftSplitter.onDrag = (delta) => { + this.leftWidth = Math.max(160, Math.min(500, this.leftWidth + delta)); + this.leftColumn.minWidth = this.leftWidth; + this.workspace.relayout(); + }; + this.rightSplitter.onDrag = (delta) => { + this.rightWidth = Math.max(200, Math.min(600, this.rightWidth - delta)); + this.inspector.minWidth = this.rightWidth; + this.workspace.relayout(); + }; + this.workspace.add(this.leftColumn); + this.workspace.add(this.leftSplitter); + this.workspace.add(this.centre, 1); + this.workspace.add(this.rightSplitter); + this.workspace.add(this.inspector); + root.add(this.workspace, 1); + root.resize(app.screen.width, app.screen.height); + + attachAutosave(state, store); + installShortcuts(state, { play: () => this.play(), stop: () => this.stop() }); + app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000)); + // Selecting a graph node switches to the graph; selecting a behavior does not force it. + state.on('selection', () => { + if (state.selection?.kind === 'node' && this.tab !== 'graph') this.showTab('graph'); + }); + // While playing, every edit (command, undo, scrub preview) is mirrored into the world at once. + state.on('project', () => this.world?.syncFromModel()); + } + + showTab(tab: CentreTab): void { + this.tab = tab; + this.viewport.visible = tab === 'scene'; + this.graphEditor.visible = tab === 'graph'; + this.gameView.visible = tab === 'game'; + for (const [key, button] of Object.entries(this.tabButtons)) button.setToggled(key === tab); + this.centre.relayout(); + ui().focus.validate(); + } + + // --- project lifecycle ----------------------------------------------------------------------- + + /** Opens the project named in the URL, else the first one on the server, else a new one. */ + async boot(): Promise { + const wanted = new URLSearchParams(location.search).get('project'); + try { + if (wanted) { + await this.open(wanted); + return; + } + await this.openFirstOrCreate(); + } catch (error) { + this.state.setNotice(`Could not load project: ${error instanceof Error ? error.message : String(error)}`); + } + } + + private async open(id: string): Promise { + if (this.world) this.stop(); + const loaded = await this.store.load(id); + this.state.loadProject(loaded.project, loaded.revision); + const url = new URL(location.href); + url.searchParams.set('project', id); + history.replaceState(null, '', url); + } + + private async openFirstOrCreate(): Promise { + const list = await this.store.list(); + if (list[0]) await this.open(list[0].id); + else await this.createProject('My first project'); + } + + private async createProject(name: string): Promise { + const created = await this.store.create(name); + await this.open(created.project.id); + } + + private async deleteCurrentProject(): Promise { + if (this.world) this.stop(); + await this.store.delete(this.state.project.id); + await this.openFirstOrCreate(); + } + + private openProjectMenu(anchor: Widget): void { + const open = async () => { + const list = await this.store.list(); + const items: MenuItem[] = [ + { label: 'New project', onSelect: () => void this.createProject(`Project ${list.length + 1}`) }, + ...list.map((p) => ({ + label: p.id === this.state.project.id ? `● ${p.name}` : p.name, + onSelect: () => void this.open(p.id), + disabled: p.id === this.state.project.id, + })), + { + label: 'Delete this project…', + danger: true, + onSelect: () => + new Dialog(`Delete project "${this.state.project.name}"?`, 'All assets and scenes are removed permanently.', [ + { label: 'Cancel' }, + { label: 'Delete', danger: true, onClick: () => void this.deleteCurrentProject() }, + ]).openCentered(), + }, + ]; + new Menu(items).openBelow(anchor); + }; + void open().catch((error) => this.state.setNotice(String(error))); + } + + private openSceneMenu(anchor: Widget): void { + const items: MenuItem[] = listScenes(this.state.project).map((scene) => ({ + label: scene.id === this.state.project.activeSceneId ? `● ${scene.name}` : scene.name, + onSelect: () => this.state.execute(new SetActiveSceneCommand(scene.id)), + })); + if (items.length === 0) items.push({ label: 'No scenes – create one in Assets', onSelect: () => {}, disabled: true }); + new Menu(items).openBelow(anchor); + } + + // --- play mode ------------------------------------------------------------------------------- + + /** + * Play: the state switches to a runtime copy of the project, the world is built from that + * copy and shown in the Game tab. All panels stay open and edit the copy – the world picks + * those edits up immediately, and nothing reaches the saved model. + */ + private play(): void { + const scene = this.state.activeScene; + if (!scene) return; + ui().focus.blur(); + const runtime = this.state.enterPlay(); + 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.gameView.setWorld(world); + this.showTab('game'); + this.state.setNotice(world.firstOfType('camera') ? 'Playing · edits are not saved · Esc stops' : 'This scene has no camera – nothing is rendered · Esc stops'); + if (world.stopRequested) this.stop(); + } + + /** Stop: the world and the runtime copy are discarded; the saved model returns untouched. */ + private stop(): void { + const world = this.world; + this.world = null; + this.behaviors = null; + this.input.detach(); + ui().focus.blur(); + world?.destroy(); + this.gameView.setWorld(null); + this.state.exitPlay(); + if (this.tab === 'game') this.showTab('scene'); + const lastError = world?.log.find((line) => line.startsWith('Error in behavior')); + this.state.setNotice(lastError ?? ''); + } + + private tick(dt: number): void { + this.measureFps(dt); + const world = this.world; + if (!world) return; + world.tick(Math.min(dt, 0.1)); + this.input.endFrame(); + if (world.stopRequested) { + this.stop(); + return; + } + // Restart game: throw the run away and start the scene again from the saved model. + if (world.restartRequested) { + this.stop(); + this.play(); + return; + } + // Mirror what the systems did into the runtime copy so the panels show live values. + // A destroyed entity removes its object – that is a structural change the lists must see. + if (world.syncToModel()) this.state.preview(); + this.state.runtimeTick(); + if (this.graphEditor.visible) this.graphEditor.redraw(); + } + + /** Counts rendered frames (edit and play alike) and refreshes the toolbar readout twice a second. */ + private measureFps(dt: number): void { + this.fpsFrames += 1; + this.fpsElapsed += dt; + if (this.fpsElapsed < 0.5) return; + this.fps = Math.round(this.fpsFrames / this.fpsElapsed); + this.fpsFrames = 0; + this.fpsElapsed = 0; + this.toolbar.setFps(this.fps); + } + + /** Read-only view of the running world for the check scripts. */ + worldSnapshot(): WorldSnapshot | null { + const world = this.world; + if (!world) return null; + return { + entities: world.entities.map((e) => { + const w = worldPositionOf(e); + return { objectId: e.objectId, assetId: e.assetId, name: e.name, x: e.x, y: e.y, worldX: w.x, worldY: w.y, parent: e.parent?.objectId ?? '', props: { ...e.props } }; + }), + log: [...world.log], + }; + } +} diff --git a/src/editor/actions.ts b/src/editor/actions.ts index b4a7a00f..8096afe4 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -1,11 +1,11 @@ -import { CreateContainerCommand } from '../core/commands/containerCommands'; -import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands'; -import { type EditorState, objectSelection } from './state'; - /** * Editor actions that several places trigger the same way (inspector, hierarchy context * menu, keyboard): they run the command and put the selection where the user expects it. */ +import { AddColliderCommand } from '../core/commands/colliderCommands'; +import { CreateContainerCommand } from '../core/commands/containerCommands'; +import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands'; +import { type EditorState, objectSelection } from './state'; /** Groups the objects into a new container asset placed in their stead; selects the container. */ export function createContainer(state: EditorState, sceneId: string, objectIds: readonly string[]): boolean { @@ -15,6 +15,14 @@ export function createContainer(state: EditorState, sceneId: string, objectIds: return true; } +/** Gives the object a collider child sized like it; selects the collider. */ +export function addCollider(state: EditorState, sceneId: string, objectId: string): boolean { + const command = new AddColliderCommand(sceneId, objectId); + if (!state.execute(command)) return false; + state.select(objectSelection(sceneId, [command.objectId])); + return true; +} + /** Removes the objects (with their subtrees) as one undo step. */ export function removeObjects(state: EditorState, sceneId: string, objectIds: readonly string[]): boolean { return state.execute(new RemoveSceneObjectsCommand(sceneId, objectIds)); diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts new file mode 100644 index 00000000..dc3edf0a --- /dev/null +++ b/src/editor/debugHook.ts @@ -0,0 +1,61 @@ +import type { Application, Container } from 'pixi.js'; +import { Text } from 'pixi.js'; +import type { Editor } from './Editor'; +import type { EditorState } from './state'; + +/** + * Installs `window.__gm`, which the check scripts in tools/ use to find widgets by their + * visible text and to read editor state. Read-only access; harmless in production. + */ +export function installDebugHook(app: Application, state: EditorState, editor: Editor): void { + const { viewport, gameView, graphEditor, hierarchy } = editor; + const centreOf = (widget: { getGlobalPosition(): { x: number; y: number }; w: number; h: number }) => { + const p = widget.getGlobalPosition(); + return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 }; + }; + /** Centre of the first (or, with `last`, the topmost/last-drawn) visible text equal to `text`. */ + const locate = (text: string, last = false): { x: number; y: number } | null => { + let found: Text | null = null; + const walk = (node: Container): void => { + if ((found && !last) || !node.visible) return; + if (node instanceof Text && node.text === text) { + found = node; + return; + } + for (const child of node.children) walk(child); + }; + walk(app.stage); + if (!found) return null; + const node = found as Text; + const p = node.getGlobalPosition(); + return { x: p.x + node.width / 2, y: p.y + node.height / 2 }; + }; + (window as unknown as { __gm: unknown }).__gm = { + state, + locate, + viewportCenter: () => centreOf(viewport), + viewportInfo: () => viewport.debugInfo(), + gameInfo: () => gameView.debugInfo(), + layerOf: (id: string) => viewport.layerOf(id), + drawOrder: () => viewport.drawOrder(), + /** Screen position of an object in the Scene tab (its drawn position through the view). */ + objectScreen: (id: string) => viewport.objectScreen(id), + /** The same for the Game tab (through the camera), so tests can click what the player sees. */ + gameScreen: (id: string) => gameView.objectScreen(id), + graph: { + current: () => graphEditor.currentAssetId, + portAt: (nodeId: string, port: string, isOutput: boolean) => graphEditor.portGlobal(nodeId, port, isOutput), + nodeAt: (nodeId: string) => graphEditor.nodeGlobal(nodeId), + center: () => centreOf(graphEditor), + }, + world: () => editor.worldSnapshot(), + /** Rows the hierarchy shows – collapsed subtrees are not in here. */ + hierarchyOrder: () => hierarchy.rowOrder(), + hierarchyChevron: (id: string) => hierarchy.chevronPosition(id), + hierarchyExpandAll: () => hierarchy.expandAll(), + hierarchyCollapseAll: () => hierarchy.collapseAll(), + fps: () => editor.fps, + tab: () => editor.tab, + showTab: (tab: 'scene' | 'graph' | 'game') => editor.showTab(tab), + }; +} diff --git a/src/editor/graph/GraphEditor.ts b/src/editor/graph/GraphEditor.ts index 0e23385f..24d1e0b2 100644 --- a/src/editor/graph/GraphEditor.ts +++ b/src/editor/graph/GraphEditor.ts @@ -241,10 +241,11 @@ export class GraphEditor extends Widget { if (drag && this.assetId) { const node = this.asset()?.graph.nodes.find((n) => n.id === drag.nodeId); if (node && drag.moved) { + // The preview moved the node already; put it back so the command records the start. const next = { x: node.x, y: node.y }; node.x = drag.start.x; node.y = drag.start.y; - this.state.execute(new MoveNodeCommand(this.assetId, drag.nodeId, next.x, next.y, drag.start)); + this.state.execute(new MoveNodeCommand(this.assetId, drag.nodeId, next.x, next.y)); } } const connecting = this.connecting; diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index dfeb0c36..f79d9b1f 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -3,9 +3,10 @@ import { edgesInto, type Graph, type GraphNode, inlineInputs, inlineValue, type import type { BehaviorAsset } from '../../core/model/Asset'; import { findAsset, type Project } from '../../core/model/Project'; import type { PropertyDef } from '../../core/properties/PropertySchema'; -import { Theme, textStyle, Widget } from '../../ui'; +import { Theme, textStyle, type TextStyleToken, Widget } from '../../ui'; import { keyLabel } from '../inspector/fieldFactory'; +/** Node geometry in graph units; the editor relies on it for edge anchors and hit tests. */ export const NODE_WIDTH = 176; export const TITLE_HEIGHT = 24; export const ROW_HEIGHT = 20; @@ -54,7 +55,7 @@ export class NodeView extends Widget { private paramLines: string[] = []; selected = false; hot = false; - /** Whether a data input currently has a connection (inline value hidden). */ + /** Data inputs that currently have a connection (their inline value is hidden). */ private connected = new Set(); constructor(readonly node: GraphNode, readonly def: NodeDef) { @@ -64,22 +65,17 @@ export class NodeView extends Widget { this.sync(node, { nodes: [node], edges: [] }, null, null); } - get titleText(): string { - return this.def.label; - } - /** Ports sit on the left/right edges; widen the hit area so they can be grabbed. */ override resize(w: number, h: number): void { super.resize(w, h); this.hitArea = new Rectangle(-PORT_HIT_RADIUS, 0, w + PORT_HIT_RADIUS * 2, h); } - /** Re-reads params and connections; rebuilds the text lines. `project` resolves asset references (Spawn). */ + /** Re-reads params and connections; rebuilds the text lines. `project` resolves asset references (Spawn, Is asset). */ sync(node: GraphNode, graph: Graph, asset: BehaviorAsset | null, project: Project | null): void { this.paramLines = this.def.params.map((p) => `${p.label}: ${formatParam(p, node.params[p.key] ?? p.default, asset, project)}`); this.connected = new Set(inlineInputs(this.def).filter((p) => edgesInto(graph, node.id, p.key).length > 0).map((p) => p.key)); - const rows = this.rowCount(); - const height = TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + rows * ROW_HEIGHT + PAD; + const height = TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + this.rowCount() * ROW_HEIGHT + PAD; this.rebuildTexts(); this.resize(NODE_WIDTH, height); this.position.set(node.x, node.y); @@ -101,13 +97,17 @@ export class NodeView extends Widget { return TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + index * ROW_HEIGHT + ROW_HEIGHT / 2; } + /** Every port with its local centre – inputs on the left edge, outputs on the right. */ + private *portCentres(): Iterable<{ port: PortDef; isOutput: boolean; x: number; y: number }> { + for (const [list, isOutput] of [[this.def.inputs, false], [this.def.outputs, true]] as const) { + for (let i = 0; i < list.length; i++) yield { port: list[i]!, isOutput, x: isOutput ? this.w : 0, y: this.rowY(i) }; + } + } + /** Which port (if any) sits under a local point. */ portAt(localX: number, localY: number): PortHit | null { - for (const [list, isOutput] of [[this.def.inputs, false], [this.def.outputs, true]] as const) { - for (let i = 0; i < list.length; i++) { - const px = isOutput ? this.w : 0; - if (Math.hypot(localX - px, localY - this.rowY(i)) <= PORT_HIT_RADIUS) return { port: list[i]!, isOutput }; - } + for (const { port, isOutput, x, y } of this.portCentres()) { + if (Math.hypot(localX - x, localY - y) <= PORT_HIT_RADIUS) return { port, isOutput }; } return null; } @@ -115,12 +115,11 @@ export class NodeView extends Widget { private rebuildTexts(): void { for (const t of this.texts) t.destroy(); this.texts.length = 0; - const add = (text: string, style: Parameters[0], x: number, y: number, align: 'left' | 'right' = 'left', maxWidth = NODE_WIDTH - PAD * 2) => { + const add = (text: string, style: TextStyleToken, x: number, y: number, align: 'left' | 'right' = 'left', maxWidth = NODE_WIDTH - PAD * 2) => { const node = new Text({ text: fit(text, style, maxWidth), style: textStyle(style) }); node.position.set(align === 'left' ? x : x - node.width, y); this.addChild(node); this.texts.push(node); - return node; }; add(this.def.label, 'accent', PAD + 2, (TITLE_HEIGHT - 16) / 2); let y = TITLE_HEIGHT + PAD; @@ -128,8 +127,7 @@ export class NodeView extends Widget { add(line, 'small', PAD + 2, y + 2); y += ROW_HEIGHT; } - const rows = this.rowCount(); - for (let i = 0; i < rows; i++) { + for (let i = 0; i < this.rowCount(); i++) { const input = this.def.inputs[i]; const output = this.def.outputs[i]; const rowTop = y + i * ROW_HEIGHT + 2; @@ -139,9 +137,9 @@ export class NodeView extends Widget { const value = inlineValue(this.node, input); if (value !== null && value !== '') label += ` ${formatValue(value)}`; } - add(label || '', input.type === 'exec' ? 'body' : 'muted', PAD + PORT_RADIUS + 4, rowTop, 'left', NODE_WIDTH / 2 + 20); + add(label, input.type === 'exec' ? 'body' : 'muted', PAD + PORT_RADIUS + 4, rowTop, 'left', NODE_WIDTH / 2 + 20); } - if (output) add(output.label || '', output.type === 'exec' ? 'body' : 'muted', this.w - PAD - PORT_RADIUS - 4, rowTop, 'right', NODE_WIDTH / 2 - 10); + if (output) add(output.label, output.type === 'exec' ? 'body' : 'muted', this.w - PAD - PORT_RADIUS - 4, rowTop, 'right', NODE_WIDTH / 2 - 10); } } @@ -156,21 +154,20 @@ export class NodeView extends Widget { this.box.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: this.selected || this.hot ? 2 : 1, color: border }); this.ports.clear(); - for (const [list, isOutput] of [[this.def.inputs, false], [this.def.outputs, true]] as const) { - list.forEach((port, i) => { - const x = isOutput ? this.w : 0; - const y = this.rowY(i); - if (port.type === 'exec') { - this.ports.moveTo(x - 5, y - 5).lineTo(x + 1, y - 5).lineTo(x + 6, y).lineTo(x + 1, y + 5).lineTo(x - 5, y + 5).closePath().fill(portColor('exec')); - } else { - this.ports.circle(x, y, PORT_RADIUS).fill(portColor(port.type)); - this.ports.circle(x, y, PORT_RADIUS).stroke({ width: 1, color: c.node }); - } - }); + const r = PORT_RADIUS; + for (const { port, x, y } of this.portCentres()) { + if (port.type === 'exec') { + // A pentagon pointing right: the Blueprint-style exec pin. + this.ports.moveTo(x - r, y - r).lineTo(x + 1, y - r).lineTo(x + r + 1, y).lineTo(x + 1, y + r).lineTo(x - r, y + r).closePath().fill(portColor('exec')); + } else { + this.ports.circle(x, y, r).fill(portColor(port.type)); + this.ports.circle(x, y, r).stroke({ width: 1, color: c.node }); + } } } } +/** The value of a setting as shown on the node; references that no longer resolve get a "?". */ function formatParam(param: PropertyDef, value: unknown, asset: BehaviorAsset | null, project: Project | null): string { if (value === undefined || value === '') return '—'; if (param.kind === 'key') return keyLabel(String(value)); @@ -178,7 +175,7 @@ function formatParam(param: PropertyDef, value: unknown, asset: BehaviorAsset | const target = project ? findAsset(project, String(value)) : undefined; return target ? target.name : `${value}?`; } - if (param.kind === 'enum' && param.key === 'variable' && asset && !asset.variables.some((v) => v.key === value) && asset.variables.length > 0 && String(value).length > 3) return `${value}?`; + if (param.kind === 'enum' && param.key === 'variable' && asset && !asset.variables.some((v) => v.key === value)) return `${value}?`; return formatValue(value); } @@ -188,7 +185,8 @@ function formatValue(value: unknown): string { return String(value); } -function fit(text: string, style: Parameters[0], maxWidth: number): string { +/** Truncates with an ellipsis so the text fits `maxWidth`. */ +function fit(text: string, style: TextStyleToken, maxWidth: number): string { const s = textStyle(style); if (CanvasTextMetrics.measureText(text, s).width <= maxWidth) return text; let cut = text; diff --git a/src/editor/inspector/Inspector.ts b/src/editor/inspector/Inspector.ts new file mode 100644 index 00000000..bc0a47ac --- /dev/null +++ b/src/editor/inspector/Inspector.ts @@ -0,0 +1,98 @@ +import type { Container } from 'pixi.js'; +import { Label, Panel, ScrollContainer, Theme, ui, VStack } from '../../ui'; +import type { EditorState, Selection } from '../state'; +import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection'; +import type { InspectorHost } from './host'; +import { buildNodeSection, nodeSignature, refreshNodeSection } from './nodeSection'; +import { buildObjectSection, buildObjectsSection, objectSignature, refreshObjectSection } from './objectSection'; + +/** + * Shows the properties of the current selection. The rows are generated from the + * PropertySchema by one section per selection kind (asset, scene object, graph node); the + * panel only decides when a section has to be rebuilt and when its values merely refresh. + */ +export class Inspector extends Panel { + private readonly scroll = new ScrollContainer(); + private host: InspectorHost; + private builtFor: Selection = null; + /** Describes the structure the rows were built for; a change means a rebuild. */ + private signature = ''; + /** Last live refresh (play mode), in ms – the world ticks far more often than the eye needs. */ + private lastLiveRefresh = 0; + + constructor(private readonly state: EditorState) { + super({ title: 'Inspector', padding: 0, gap: 0 }); + this.minWidth = 220; + this.host = this.emptyHost(); + this.scroll.setContent(this.host.body); + this.content.add(this.scroll, 1); + state.on('selection', () => this.rebuild()); + state.on('load', () => this.rebuild()); + state.on('project', () => this.refreshValues()); + state.on('runtime', () => this.refreshLive()); + this.rebuild(); + } + + private emptyHost(): InspectorHost { + return { state: this.state, body: new VStack({ padding: Theme.padding, gap: Theme.gap }), fields: new Map(), nameField: null }; + } + + /** + * Play mode: show what the behaviors are doing to the selected object, ~10× a second. + * Skipped while a field in here is being edited so the typed text is not overwritten. + */ + private refreshLive(): void { + const now = performance.now(); + if (now - this.lastLiveRefresh < 100) return; + for (let node: Container | null = ui().focus.focused; node; node = node.parent) if (node === this) return; + this.lastLiveRefresh = now; + this.refreshValues(); + } + + private rebuild(): void { + this.builtFor = this.state.selection; + this.host.body.destroy({ children: true }); + this.host = this.emptyHost(); + this.signature = this.currentSignature(); + + const s = this.state.selection; + if (!s) this.host.body.add(new Label('Select an asset, a scene object or a node.', { style: 'muted' })); + else if (s.kind === 'asset') buildAssetSection(this.host, s.assetId); + else if (s.kind === 'node') buildNodeSection(this.host, s.assetId, s.nodeId); + else if (s.objectIds.length > 1) buildObjectsSection(this.host, s.sceneId, s.objectIds); + else buildObjectSection(this.host, s.sceneId, s.objectId); + this.scroll.setContent(this.host.body); + } + + private currentSignature(): string { + const s = this.state.selection; + if (!s) return ''; + if (s.kind === 'asset') return assetSignature(this.state.project, s.assetId); + if (s.kind === 'node') return nodeSignature(this.state.project, s.assetId, s.nodeId); + return objectSignature(this.state.project, s.sceneId, s.objectIds, s.objectId); + } + + /** Pushes current model values into the widgets (after undo, drag in viewport, …). */ + private refreshValues(): void { + const s = this.state.selection; + if (!sameTarget(s, this.builtFor) || this.currentSignature() !== this.signature) { + this.rebuild(); + return; + } + if (!s) return; + const ok = + s.kind === 'asset' ? refreshAssetSection(this.host, s.assetId) + : s.kind === 'node' ? refreshNodeSection(this.host, s.assetId, s.nodeId) + : s.objectIds.length > 1 || refreshObjectSection(this.host, s.sceneId, s.objectId); + if (!ok) this.rebuild(); + } +} + +function sameTarget(a: Selection, b: Selection): boolean { + if (!a || !b) return a === b; + if (a.kind !== b.kind) return false; + if (a.kind === 'asset' && b.kind === 'asset') return a.assetId === b.assetId; + if (a.kind === 'object' && b.kind === 'object') return a.sceneId === b.sceneId && a.objectId === b.objectId && a.objectIds.join(',') === b.objectIds.join(','); + if (a.kind === 'node' && b.kind === 'node') return a.assetId === b.assetId && a.nodeId === b.nodeId; + return false; +} diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts new file mode 100644 index 00000000..dbee4747 --- /dev/null +++ b/src/editor/inspector/assetSection.ts @@ -0,0 +1,134 @@ +import { RenameAssetCommand, ResetAssetPropertyCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands'; +import { nextVariableName } from '../../core/commands/naming'; +import { AddVariableCommand, RemoveVariableCommand, RenameVariableCommand } from '../../core/commands/variableCommands'; +import type { Asset, BehaviorAsset } from '../../core/model/Asset'; +import type { PropertyValue } from '../../core/model/Properties'; +import { findAsset, type Project } from '../../core/model/Project'; +import { COMMON_PROPS, type PropertyDef, schemaForAsset, VARIABLE_KINDS, type VariableKind } from '../../core/properties/PropertySchema'; +import { isModified, resolveAssetProps } from '../../core/properties/resolve'; +import { Button, HStack, Label, Menu, TextField, Theme } from '../../ui'; +import { createField, createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory'; +import { addNote, addTitle, type InspectorHost } from './host'; + +const VARIABLE_KIND_LABELS: Record = { number: 'Number', boolean: 'Boolean', text: 'Text' }; +const VARIABLE_KIND_MARKS: Record = { number: '#', boolean: '✓', text: 'Aa' }; + +/** Structure-defining facts of an asset selection: the schema keys and kinds. */ +export function assetSignature(project: Project, assetId: string): string { + const asset = findAsset(project, assetId); + return asset ? schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',') : 'missing'; +} + +/** Name, properties (asset values marked when they differ from the default) and, for behaviors, the variables. */ +export function buildAssetSection(host: InspectorHost, assetId: string): void { + const asset = findAsset(host.state.project, assetId); + if (!asset) return; + addTitle(host, `${asset.type.toUpperCase()} ASSET`); + host.nameField = new TextField(asset.name, { + onChange: (name) => { + if (name.trim()) host.state.execute(new RenameAssetCommand(assetId, name.trim())); + else host.nameField?.setValue(asset.name); + }, + }); + const { row } = labelledRow('Name'); + row.add(host.nameField, 1); + host.body.add(row); + + if (asset.type === 'behavior') { + buildProps(host, asset, COMMON_PROPS); + buildVariables(host, asset); + } else { + buildProps(host, asset, schemaForAsset(asset)); + } +} + +/** Pushes the current asset values into the rows. False when the asset is gone (rebuild). */ +export function refreshAssetSection(host: InspectorHost, assetId: string): boolean { + const asset = findAsset(host.state.project, assetId); + if (!asset) return false; + host.nameField?.setValue(asset.name); + const values = resolveAssetProps(asset); + for (const [key, row] of host.fields) { + row.setValue(values[key] ?? 0); + row.setModified(isModified(asset, key)); + } + return true; +} + +function buildProps(host: InspectorHost, asset: Asset, schema: readonly PropertyDef[]): void { + const values = resolveAssetProps(asset); + for (const def of schema) { + const row = createFieldRow(def, values[def.key] ?? def.default, assetHandlers(host, asset.id, def), () => { + const target = findAsset(host.state.project, asset.id); + if (!target || !isModified(target, def.key)) return []; + return [{ label: 'Reset to default', onSelect: () => host.state.execute(new ResetAssetPropertyCommand(asset.id, def.key)) }]; + }); + row.setModified(isModified(asset, def.key)); + host.fields.set(def.key, row); + host.body.add(row.row); + } +} + +/** 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 { + let original: PropertyValue | undefined; + return { + onInput: (value) => { + const target = findAsset(host.state.project, assetId); + if (!target) return; + if (original === undefined) original = target.props[def.key]; + target.props[def.key] = value; + host.state.preview(); + }, + onChange: (value) => { + const target = findAsset(host.state.project, assetId); + if (!target) return; + if (original !== undefined) target.props[def.key] = original; + original = undefined; + host.state.execute(new SetAssetPropertyCommand(assetId, def.key, value)); + }, + }; +} + +/** Variables of a behavior: kind mark, name, default value, remove – plus an "add" menu. */ +function buildVariables(host: InspectorHost, asset: BehaviorAsset): void { + const header = new HStack({ gap: Theme.gap, align: 'center' }); + header.add(new Label('VARIABLES', { style: 'title' }), 1); + const add = new Button('+ Variable ▾', { variant: 'ghost' }); + add.onClick = () => { + const items = VARIABLE_KINDS.map((kind) => ({ + label: VARIABLE_KIND_LABELS[kind], + onSelect: () => { + const current = findAsset(host.state.project, asset.id); + if (current?.type === 'behavior') host.state.execute(new AddVariableCommand(asset.id, nextVariableName(current), kind)); + }, + })); + new Menu(items).openBelow(add); + }; + header.add(add); + host.body.add(header); + + if (asset.variables.length === 0) addNote(host, 'No variables yet.'); + const values = resolveAssetProps(asset); + for (const def of asset.variables) { + const row = new HStack({ gap: Theme.gap, align: 'center' }); + row.minHeight = Theme.rowHeight; + const mark = new Label(VARIABLE_KIND_MARKS[def.kind as VariableKind] ?? '?', { style: 'small', align: 'center' }); + mark.minWidth = 18; + row.add(mark); + row.add( + new TextField(def.key, { + width: 90, + onChange: (next) => { + host.state.execute(new RenameVariableCommand(asset.id, def.key, next.trim())); + }, + }), + ); + const field = createField(def, values[def.key] ?? def.default, assetHandlers(host, asset.id, def)); + row.add(field.widget, field.grow ? 1 : 0); + if (!field.grow) row.add(new Label(''), 1); + row.add(new Button('×', { variant: 'ghost', width: Theme.controlHeight, onClick: () => host.state.execute(new RemoveVariableCommand(asset.id, def.key)) })); + host.fields.set(def.key, { row, setValue: field.setValue, setModified: () => {} }); + host.body.add(row); + } +} diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index 7ba5b23a..15e5e6ee 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -20,6 +20,7 @@ export interface FieldRow { setModified(modified: boolean): void; } +/** Width of the label column every inspector row aligns to. */ export const FIELD_LABEL_WIDTH = 64; /** One way of resetting or applying a value ("Reset to asset", "Apply to container", …). */ @@ -34,6 +35,16 @@ export interface FieldAction { */ export type FieldActions = () => FieldAction[]; +/** A row starting with the fixed-width, muted label column. */ +export function labelledRow(text: string): { row: HStack; label: Label } { + const row = new HStack({ gap: Theme.gap, align: 'center' }); + row.minHeight = Theme.rowHeight; + const label = new Label(text, { style: 'muted' }); + label.minWidth = FIELD_LABEL_WIDTH; + row.add(label); + return { row, label }; +} + /** * Maps one PropertySchema entry to a labelled row containing the matching widget. * The inspector is entirely generated through this function: adding a new property @@ -44,12 +55,7 @@ export type FieldActions = () => FieldAction[]; * means no button, one entry means a direct button, several mean a menu (↺ ▾ / ↑ ▾). */ export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers: FieldHandlers, reset?: FieldActions, apply?: FieldActions): FieldRow { - const row = new HStack({ gap: Theme.gap, align: 'center' }); - row.minHeight = Theme.rowHeight; - const label = new Label(def.label, { style: 'muted' }); - label.minWidth = FIELD_LABEL_WIDTH; - row.add(label); - + const { row, label } = labelledRow(def.label); const field = createField(def, value, handlers); row.add(field.widget, field.grow ? 1 : 0); if (!field.grow) row.add(new Label(''), 1); @@ -63,8 +69,7 @@ export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers: const items = available(); if (items.length === 0) return; if (items.length === 1) return items[0]!.onSelect(); - const g = button.getGlobalPosition(); - new Menu(items.map((a) => ({ label: a.label, onSelect: a.onSelect }))).open(g.x, g.y + button.h + 2); + new Menu(items.map((a) => ({ label: a.label, onSelect: a.onSelect }))).openBelow(button); }; button.visible = false; actions.push({ button, glyph, available }); @@ -139,10 +144,7 @@ export function createField(def: PropertyDef, value: PropertyValue, handlers: Fi const labelOf = (v: PropertyValue) => def.options.find((o) => o.value === String(v))?.label ?? (String(v) || '—'); const button = new Button(`${labelOf(value)} ▾`); button.onClick = () => { - const g = button.getGlobalPosition(); - const items = def.options.map((o) => ({ label: o.label, onSelect: () => handlers.onChange(o.value) })); - if (items.length === 0) items.push({ label: 'No options', onSelect: () => {} }); - new Menu(items).open(g.x, g.y + button.h + 2); + new Menu(def.options.map((o) => ({ label: o.label, onSelect: () => handlers.onChange(o.value) }))).openBelow(button); }; return { widget: button, grow: true, setValue: (v) => button.setText(`${labelOf(v)} ▾`) }; } @@ -200,6 +202,7 @@ class KeyCaptureButton extends Button { } } +/** Display name of a `KeyboardEvent.key` value: "Space", "Left arrow", "—" for none. */ export function keyLabel(key: string): string { if (key === ' ') return 'Space'; if (key === '') return '—'; diff --git a/src/editor/inspector/host.ts b/src/editor/inspector/host.ts new file mode 100644 index 00000000..04772797 --- /dev/null +++ b/src/editor/inspector/host.ts @@ -0,0 +1,25 @@ +import { Label, type TextField, type VStack } from '../../ui'; +import type { EditorState } from '../state'; +import type { FieldRow } from './fieldFactory'; + +/** What the inspector sections build into: the panel body and the registry of rows. */ +export interface InspectorHost { + readonly state: EditorState; + readonly body: VStack; + /** Rows by key; live refreshes and highlight updates find them here. */ + readonly fields: Map; + /** The name field at the top, when the selection has a name. */ + nameField: TextField | null; +} + +export function addTitle(host: InspectorHost, text: string): void { + host.body.add(new Label(text, { style: 'title' })); +} + +export function addNote(host: InspectorHost, text: string, style: 'muted' | 'small' = 'muted'): void { + host.body.add(new Label(text, { style })); +} + +export function addGap(host: InspectorHost): void { + host.body.add(new Label('')); +} diff --git a/src/editor/inspector/nodeSection.ts b/src/editor/inspector/nodeSection.ts new file mode 100644 index 00000000..0c17052b --- /dev/null +++ b/src/editor/inspector/nodeSection.ts @@ -0,0 +1,115 @@ +import { RemoveNodeCommand, SetNodeParamCommand } from '../../core/commands/graphCommands'; +import { edgesInto, type GraphNode, inlineInputDef, inlineInputs, nodeDef } from '../../core/graph'; +import type { Asset } from '../../core/model/Asset'; +import { isPlaceableType } from '../../core/model/AssetTypes'; +import type { PropertyValue } from '../../core/model/Properties'; +import { findAsset, 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'; + +/** Structure-defining facts of a node selection: type, 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 node = asset.graph.nodes.find((n) => n.id === nodeId); + if (!node) return 'missing'; + const def = nodeDef(node.type); + const connected = def ? inlineInputs(def).filter((p) => edgesInto(asset.graph, node.id, p.key).length > 0).map((p) => p.key) : []; + return `${node.type}|${connected.join(',')}|${asset.variables.map((v) => v.key).join(',')}`; +} + +/** 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 node = asset.graph.nodes.find((n) => n.id === nodeId); + const def = node && nodeDef(node.type); + if (!node || !def) return; + + addTitle(host, `${def.category.toUpperCase()} NODE`); + host.body.add(new Label(def.label, { style: 'bodyBold' })); + addNote(host, def.description); + + if (def.params.length > 0) addTitle(host, 'SETTINGS'); + for (const param of def.params) { + const effective = resolveParamDef(host.state.project, param, asset); + const row = createFieldRow(effective, node.params[param.key] ?? effective.default, paramHandlers(host, assetId, nodeId, param.key)); + host.fields.set(`param:${param.key}`, row); + host.body.add(row.row); + } + + const free = inlineInputs(def).filter((p) => edgesInto(asset.graph, node.id, p.key).length === 0); + if (free.length > 0) addTitle(host, 'INPUTS (unconnected)'); + for (const port of free) { + const inline = inlineInputDef(port); + const row = createFieldRow(inline, node.params[port.key] ?? inline.default, paramHandlers(host, assetId, nodeId, port.key)); + host.fields.set(`input:${port.key}`, row); + host.body.add(row.row); + } + + addGap(host); + host.body.add(new Button('Delete node', { variant: 'danger', onClick: () => host.state.execute(new RemoveNodeCommand(assetId, nodeId)) })); +} + +/** Pushes the node's current params into the rows. False when the node is gone (rebuild). */ +export function refreshNodeSection(host: InspectorHost, assetId: string, nodeId: string): boolean { + const node = currentNode(host.state.project, assetId, nodeId); + const def = node && nodeDef(node.type); + if (!node || !def) return false; + for (const [key, row] of host.fields) { + const [scope, name] = key.split(':') as ['param' | 'input', string]; + const fallback = scope === 'param' ? def.params.find((p) => p.key === name)?.default : inlineInputDef(def.inputs.find((p) => p.key === name)!).default; + row.setValue(node.params[name] ?? fallback ?? ''); + } + return true; +} + +/** + * Variable params pick from the asset's variables, asset params (Spawn, Is asset) from the + * project's placeable assets; everything else is used as declared. + */ +function resolveParamDef(project: Project, param: PropertyDef, asset: Asset): PropertyDef { + if (param.kind !== 'enum') return param; + if (param.key === 'variable' && asset.type === 'behavior') { + return { ...param, options: asset.variables.map((v) => ({ value: v.key, label: v.key })) }; + } + if (param.key === 'asset') { + return { ...param, options: project.assets.filter((a) => isPlaceableType(a.type)).map((a) => ({ value: a.id, label: `${a.name} (${a.type})` })) }; + } + return param; +} + +/** Previews straight into the node's params; the command records the value from before the preview. */ +function paramHandlers(host: InspectorHost, assetId: string, nodeId: string, key: string): FieldHandlers { + let original: PropertyValue | undefined; + let had = false; + return { + onInput: (value) => { + const target = currentNode(host.state.project, assetId, nodeId); + if (!target) return; + if (original === undefined) { + had = target.params[key] !== undefined; + original = target.params[key] ?? value; + } + target.params[key] = value; + host.state.preview(); + }, + onChange: (value) => { + const target = currentNode(host.state.project, assetId, nodeId); + if (!target) return; + if (original !== undefined) { + if (had) target.params[key] = original; + else delete target.params[key]; + } + original = undefined; + host.state.execute(new SetNodeParamCommand(assetId, nodeId, key, value)); + }, + }; +} + +function currentNode(project: Project, assetId: string, nodeId: string): GraphNode | undefined { + const asset = findAsset(project, assetId); + return asset?.type === 'behavior' ? asset.graph.nodes.find((n) => n.id === nodeId) : undefined; +} diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts new file mode 100644 index 00000000..aa001565 --- /dev/null +++ b/src/editor/inspector/objectSection.ts @@ -0,0 +1,339 @@ +import { + ApplyOverrideToAssetCommand, + ApplyOverrideToContainerCommand, + ApplyPositionToContainerCommand, + RevertOverrideToTemplateCommand, + RevertPositionToTemplateCommand, +} from '../../core/commands/applyCommands'; +import type { Command } from '../../core/commands/Command'; +import { + ClearOverrideCommand, + MoveSceneObjectCommand, + RenameSceneObjectCommand, + SetEditorOffsetCommand, + SetOverrideCommand, + SetParentCommand, +} from '../../core/commands/sceneObjectCommands'; +import type { Asset, SceneAsset } from '../../core/model/Asset'; +import { type TemplateLink, templateLinkOf } from '../../core/model/container'; +import { findObject, isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy'; +import type { Axis, PropertyValue } from '../../core/model/Properties'; +import { findAsset, findScene, type Project } from '../../core/model/Project'; +import type { SceneObject } from '../../core/model/SceneObject'; +import { COMMON_PROPS, isCommonProp, type PropertyDef, schemaForAsset } from '../../core/properties/PropertySchema'; +import { deviatesFromAsset, deviatesFromTemplate, isOverridden, resolveSceneObjectProps } from '../../core/properties/resolve'; +import { Button, Menu, type MenuItem, TextField } from '../../ui'; +import { addCollider, createContainer, removeObjects } from '../actions'; +import { createFieldRow, type FieldActions, type FieldRow, labelledRow } from './fieldFactory'; +import { addGap, addNote, addTitle, type InspectorHost } from './host'; + +const POSITION_AXES: readonly Axis[] = ['x', 'y', 'z']; +const OFFSET_AXES = ['x', 'y'] as const; + +const offsetKey = (axis: 'x' | 'y') => `editorOffset${axis.toUpperCase()}`; + +/** Structure-defining facts of an object selection: parent, template link and schema. */ +export function objectSignature(project: Project, sceneId: string, objectIds: readonly string[], objectId: string): string { + if (objectIds.length > 1) return `multi|${objectIds.join(',')}`; + const scene = findScene(project, sceneId); + const object = scene && findObject(scene, objectId); + const asset = object && findAsset(project, object.assetId); + if (!object || !asset) return 'missing'; + return `${object.parentId}|${object.templateId}|` + schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(','); +} + +/** Several objects: what they are, and the actions that apply to all of them at once. */ +export function buildObjectsSection(host: InspectorHost, sceneId: string, objectIds: string[]): void { + const scene = findScene(host.state.project, sceneId); + if (!scene) return; + addTitle(host, `${objectIds.length} OBJECTS SELECTED`); + for (const id of objectIds) { + const object = findObject(scene, id); + const asset = object && findAsset(host.state.project, object.assetId); + if (object && asset) addNote(host, `${object.name || asset.name} · ${asset.type}`); + } + addGap(host); + addNote(host, 'Ctrl-click adds or removes objects.', 'small'); + addNote(host, 'Dragging one in the scene moves all of them.', 'small'); + addGap(host); + host.body.add(new Button('Create container', { onClick: () => createContainer(host.state, sceneId, objectIds) })); + addNote(host, 'Groups the selection into a reusable container', 'small'); + addNote(host, 'asset placed at its centre (Ctrl+G).', 'small'); + addGap(host); + host.body.add(new Button('Remove from scene', { variant: 'danger', onClick: () => removeObjects(host.state, sceneId, objectIds) })); +} + +/** One object: name, asset, parent, container link, the switch, position, editor offset, properties, collider button. */ +export function buildObjectSection(host: InspectorHost, sceneId: string, objectId: string): void { + const scene = findScene(host.state.project, sceneId); + const object = scene && findObject(scene, objectId); + const asset = object && findAsset(host.state.project, object.assetId); + if (!scene || !object || !asset) return; + + addTitle(host, `${asset.type.toUpperCase()} OBJECT`); + host.nameField = new TextField(object.name, { + placeholder: asset.name, + onChange: (name) => host.state.execute(new RenameSceneObjectCommand(sceneId, objectId, name.trim())), + }); + const nameRow = labelledRow('Name'); + nameRow.row.add(host.nameField, 1); + host.body.add(nameRow.row); + + const assetRow = labelledRow('Asset'); + assetRow.row.add(new Button(asset.name, { variant: 'ghost', onClick: () => host.state.select({ kind: 'asset', assetId: asset.id }) }), 1); + host.body.add(assetRow.row); + + host.body.add(parentRow(host, scene, object)); + + // Part of a placed container: values compare against the template, not just the asset. + const link = templateLinkOf(host.state.project, scene, object); + if (link) { + const linkRow = labelledRow('Part of'); + linkRow.row.add(new Button(link.asset.name, { variant: 'ghost', onClick: () => host.state.select({ kind: 'asset', assetId: link.asset.id }) }), 1); + host.body.add(linkRow.row); + } + + // The master switch, right where it is looked for: above everything type-specific. + for (const def of COMMON_PROPS) propertyRow(host, sceneId, objectId, def, resolveSceneObjectProps(asset, object)); + + addTitle(host, object.parentId ? 'POSITION (LOCAL TO PARENT)' : 'POSITION'); + for (const axis of POSITION_AXES) { + const moved = () => positionMoved(host.state, sceneId, objectId, axis); + componentRow(host, sceneId, objectId, object.position[axis], { + key: axis, + label: axis.toUpperCase(), + axis, + read: (o) => o.position, + write: (o, v) => (o.position = v), + command: (v) => new MoveSceneObjectCommand(sceneId, objectId, v), + reset: () => (moved() ? [{ label: 'Reset to container', onSelect: () => host.state.execute(new RevertPositionToTemplateCommand(sceneId, objectId, axis)) }] : []), + apply: () => (moved() ? [{ label: 'Apply to container', onSelect: () => host.state.execute(new ApplyPositionToContainerCommand(sceneId, objectId, axis)) }] : []), + }); + } + + // Where the object is *drawn* in the scene view, nothing else – for pulling stacked objects apart. + addTitle(host, 'EDITOR OFFSET (SCENE VIEW ONLY)'); + for (const axis of OFFSET_AXES) { + componentRow(host, sceneId, objectId, object.editorOffset[axis], { + key: offsetKey(axis), + label: `Offset ${axis.toUpperCase()}`, + axis, + read: (o) => o.editorOffset, + write: (o, v) => (o.editorOffset = v), + command: (v) => new SetEditorOffsetCommand(sceneId, objectId, v), + // No apply: the offset never leaves the placed object. + reset: () => { + const current = currentObject(host.state, sceneId, objectId); + if (!current || current.editorOffset[axis] === 0) return []; + return [{ label: 'Reset to 0', onSelect: () => host.state.execute(new SetEditorOffsetCommand(sceneId, objectId, { ...current.editorOffset, [axis]: 0 })) }]; + }, + }); + } + + // Asset properties (or behavior variables) with overrides; the common switch is shown above. + const schema = schemaForAsset(asset); + const values = resolveSceneObjectProps(asset, object); + const own = schema.filter((def) => !isCommonProp(def.key)); + if (own.length > 0 || asset.type === 'behavior') addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES'); + if (own.length === 0 && asset.type === 'behavior') addNote(host, 'The behavior declares no variables.'); + for (const def of own) propertyRow(host, sceneId, objectId, def, values); + markObjectRows(host, object, asset, link); + + if (asset.type === 'container') { + const count = asset.objects.length; + addTitle(host, 'CONTENT'); + addNote(host, count === 0 ? 'The container is empty.' : `Instance with ${count} template object(s).`); + if (count > 0) addNote(host, 'Its content is listed below it in the hierarchy.', 'small'); + } + + if (asset.type !== 'collider') { + addGap(host); + host.body.add(new Button('+ Add collider', { variant: 'ghost', onClick: () => addCollider(host.state, sceneId, objectId) })); + } +} + +/** Pushes the current model values into the rows. False when the object is gone (rebuild). */ +export function refreshObjectSection(host: InspectorHost, sceneId: string, objectId: string): boolean { + const scene = findScene(host.state.project, sceneId); + const object = scene && findObject(scene, objectId); + const asset = object && findAsset(host.state.project, object.assetId); + if (!scene || !object || !asset) return false; + host.nameField?.setValue(object.name); + for (const axis of POSITION_AXES) host.fields.get(axis)?.setValue(object.position[axis]); + for (const axis of OFFSET_AXES) host.fields.get(offsetKey(axis))?.setValue(object.editorOffset[axis]); + const values = resolveSceneObjectProps(asset, object); + for (const def of schemaForAsset(asset)) host.fields.get(def.key)?.setValue(values[def.key] ?? def.default); + markObjectRows(host, object, asset, templateLinkOf(host.state.project, scene, object)); + return true; +} + +interface ComponentSpec { + key: string; + label: string; + axis: keyof V & string; + read: (object: SceneObject) => V; + write: (object: SceneObject, value: V) => void; + command: (value: V) => Command; + reset?: FieldActions; + apply?: FieldActions; +} + +/** + * A number row editing one component of a vector on the object (position, editor offset). + * Scrubbing previews straight into the model; the command is built from the value before the + * preview so undo returns there, not to the last previewed frame. + */ +function componentRow(host: InspectorHost, sceneId: string, objectId: string, value: number, spec: ComponentSpec): void { + let original: V | undefined; + const withAxis = (vector: V, next: PropertyValue): V => ({ ...vector, [spec.axis]: Number(next) }); + const row = createFieldRow( + { key: spec.key, label: spec.label, kind: 'number', default: 0, step: 1 }, + value, + { + onInput: (next) => { + const target = currentObject(host.state, sceneId, objectId); + if (!target) return; + if (!original) original = { ...spec.read(target) }; + spec.write(target, withAxis(spec.read(target), next)); + host.state.preview(); + }, + onChange: (next) => { + const target = currentObject(host.state, sceneId, objectId); + if (!target) return; + const previous = original ?? { ...spec.read(target) }; + if (original) spec.write(target, { ...original }); + original = undefined; + host.state.execute(spec.command(withAxis(previous, next))); + }, + }, + spec.reset, + spec.apply, + ); + host.fields.set(spec.key, row); + host.body.add(row.row); +} + +/** + * One editable property of a scene 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 was instantiated from and, one + * step further up, the asset – so ↺/↑ can become a menu. Only bases the value really differs + * from are listed, so a menu never contains an entry that would change nothing. Dropping the + * override is what makes the object follow the asset again: the template is only read when + * the container is placed, never at draw time. + */ +function propertyRow(host: InspectorHost, sceneId: string, objectId: string, def: PropertyDef, values: Record): FieldRow { + let original: PropertyValue | undefined; + let hadOverride = false; + const bases = () => deviations(host.state, sceneId, objectId, def.key); + const row = createFieldRow( + def, + values[def.key] ?? def.default, + { + onInput: (value) => { + const target = currentObject(host.state, sceneId, objectId); + if (!target) return; + if (original === undefined) { + hadOverride = isOverridden(target, def.key); + original = target.overrides[def.key] ?? values[def.key] ?? def.default; + } + target.overrides[def.key] = value; + host.state.preview(); + }, + onChange: (value) => { + const target = currentObject(host.state, sceneId, objectId); + if (!target) return; + if (original !== undefined) { + if (hadOverride) target.overrides[def.key] = original; + else delete target.overrides[def.key]; + } + original = undefined; + host.state.execute(new SetOverrideCommand(sceneId, objectId, def.key, value)); + }, + }, + () => { + const { template, asset } = bases(); + return [ + ...(template ? [{ label: 'Reset to container', onSelect: () => host.state.execute(new RevertOverrideToTemplateCommand(sceneId, objectId, def.key)) }] : []), + ...(asset ? [{ label: 'Reset to asset', onSelect: () => host.state.execute(new ClearOverrideCommand(sceneId, objectId, def.key)) }] : []), + ]; + }, + () => { + const { template, asset } = bases(); + return [ + ...(template ? [{ label: 'Apply to container', onSelect: () => host.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)) }] : []), + ...(asset ? [{ label: 'Apply to asset', onSelect: () => host.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)) }] : []), + ]; + }, + ); + host.fields.set(def.key, row); + host.body.add(row.row); + return row; +} + +/** + * "Parent: ▾" – picks another object of the same scene, or none. The object's own + * subtree is excluded (that would be a cycle). For behaviors the parent is the owner. + */ +function parentRow(host: InspectorHost, scene: SceneAsset, object: SceneObject) { + const { row } = labelledRow('Parent'); + const nameOf = (o: SceneObject) => o.name || findAsset(host.state.project, o.assetId)?.name || '?'; + const parent = findObject(scene, object.parentId); + const button = new Button(`${parent ? nameOf(parent) : 'None'} ▾`); + button.onClick = () => { + const items: MenuItem[] = [{ label: 'None', onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, '')) }]; + for (const { object: candidate, depth } of treeOrder(scene)) { + if (isSelfOrAncestor(scene, object.id, candidate.id)) continue; + const name = `${' '.repeat(depth)}${nameOf(candidate)}`; + items.push({ label: candidate.id === object.parentId ? `● ${name}` : name, onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, candidate.id)) }); + } + new Menu(items).openBelow(button); + }; + row.add(button, 1); + return row; +} + +/** + * Bold marks exactly what the ↺/↑ buttons offer: for a container instance a deviation from + * the template (position per axis, overrides), for any other object one from the asset. The + * editor offset has one base only – no offset at all. + */ +function markObjectRows(host: InspectorHost, object: SceneObject, asset: Asset, link: TemplateLink | undefined): void { + for (const axis of POSITION_AXES) host.fields.get(axis)?.setModified(Boolean(link) && object.position[axis] !== link!.template.position[axis]); + for (const axis of OFFSET_AXES) host.fields.get(offsetKey(axis))?.setModified(object.editorOffset[axis] !== 0); + for (const def of schemaForAsset(asset)) { + const deviates = link ? deviatesFromTemplate(object, link.template, def.key) : deviatesFromAsset(object, asset, def.key); + host.fields.get(def.key)?.setModified(deviates); + } +} + +/** + * The two bases a property can be reset to or applied upwards, each only when it actually + * differs: the template of the container the object belongs to, and the asset behind it. + * Evaluated on demand so a menu never acts on a stale snapshot. + */ +function deviations(state: InspectorHost['state'], sceneId: string, objectId: string, key: string): { template: boolean; asset: boolean } { + const scene = findScene(state.project, sceneId); + const object = scene && findObject(scene, objectId); + const asset = object && findAsset(state.project, object.assetId); + if (!scene || !object || !asset) return { template: false, asset: false }; + const link = templateLinkOf(state.project, scene, object); + return { + template: Boolean(link) && deviatesFromTemplate(object, link!.template, key), + asset: deviatesFromAsset(object, asset, key), + }; +} + +/** Whether the object sits somewhere else on `axis` than its container template says. */ +function positionMoved(state: InspectorHost['state'], sceneId: string, objectId: string, axis: Axis): boolean { + const scene = findScene(state.project, sceneId); + const object = scene && findObject(scene, objectId); + const link = scene && object && templateLinkOf(state.project, scene, object); + return Boolean(link) && object!.position[axis] !== link!.template.position[axis]; +} + +function currentObject(state: InspectorHost['state'], sceneId: string, objectId: string): SceneObject | undefined { + const scene = findScene(state.project, sceneId); + return scene && findObject(scene, objectId); +} diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index 310ae8f1..cf902cc3 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -1,36 +1,31 @@ import { CreateAssetCommand, DeleteAssetCommand, SetActiveSceneCommand } from '../../core/commands/assetCommands'; -import { nextAssetName } from '../../core/commands/projectCommands'; +import { nextAssetName } from '../../core/commands/naming'; import { PlaceAssetCommand } from '../../core/commands/sceneObjectCommands'; -import { ASSET_TYPES, type AssetType } from '../../core/model/AssetTypes'; +import { ASSET_TYPE_LABELS, ASSET_TYPES, type AssetType, hasColorSwatch } from '../../core/model/AssetTypes'; import type { Vec2 } from '../../core/model/Properties'; import { findAsset, findUsages } from '../../core/model/Project'; import { Button, Dialog, ListView, Menu, type MenuItem, Panel, ScrollContainer } from '../../ui'; import { type EditorState, objectSelection } from '../state'; -const TYPE_LABELS: Record = { scene: 'Scene', camera: 'Camera', sprite: 'Sprite', text: 'Text', behavior: 'Behavior', collider: 'Collider', container: 'Container' }; - /** Lists all assets of the project; creates, places and deletes them. */ export class AssetPanel extends Panel { private readonly list = new ListView(); + private readonly scroll = new ScrollContainer(); constructor(private readonly state: EditorState, private readonly placementPoint: () => Vec2) { super({ title: 'Assets', padding: 0, gap: 0 }); this.minWidth = 160; const add = new Button('+', { variant: 'ghost', width: 28 }); - add.onClick = () => { - const g = add.getGlobalPosition(); - new Menu(ASSET_TYPES.map((type) => ({ label: TYPE_LABELS[type], onSelect: () => this.createAsset(type) }))).open(g.x, g.y + add.h + 2); - }; + add.onClick = () => new Menu(ASSET_TYPES.map((type) => ({ label: ASSET_TYPE_LABELS[type], onSelect: () => this.createAsset(type) }))).openBelow(add); this.header.add(add); this.list.emptyText = 'No assets – press +'; this.list.onSelect = (id) => state.select(id ? { kind: 'asset', assetId: id } : null); this.list.onActivate = (id) => this.activate(id); this.list.onContextMenu = (id, x, y) => this.openContextMenu(id, x, y); - const scroll = new ScrollContainer(); - scroll.setContent(this.list); - this.content.add(scroll, 1); + this.scroll.setContent(this.list); + this.content.add(this.scroll, 1); state.on('project', () => this.refresh()); state.on('load', () => this.refresh()); @@ -45,11 +40,11 @@ export class AssetPanel extends Panel { id: asset.id, label: asset.name, hint: asset.type === 'scene' && asset.id === active ? 'scene ●' : asset.type, - swatch: asset.type === 'sprite' || asset.type === 'text' ? Number(asset.props.color ?? 0) : undefined, + swatch: hasColorSwatch(asset.type) ? Number(asset.props.color ?? 0) : undefined, })), ); this.syncSelection(); - (this.content.items[0] as ScrollContainer | undefined)?.relayoutContent(); + this.scroll.relayoutContent(); } private syncSelection(): void { @@ -58,7 +53,7 @@ export class AssetPanel extends Panel { } private createAsset(type: AssetType): void { - const command = new CreateAssetCommand(type, nextAssetName(this.state.project, TYPE_LABELS[type])); + const command = new CreateAssetCommand(type, nextAssetName(this.state.project, ASSET_TYPE_LABELS[type])); if (this.state.execute(command)) this.state.select({ kind: 'asset', assetId: command.asset.id }); } @@ -103,8 +98,8 @@ export class AssetPanel extends Panel { : asset.type === 'container' && usages > 0 ? `${usages} placed container(s) are removed from scenes together with their content.` : usages > 0 - ? `${usages} placed instance(s) are removed from scenes as well.` - : 'This cannot be undone with Ctrl+Z after reload.'; + ? `${usages} placed instance(s) are removed from scenes as well.` + : 'This cannot be undone with Ctrl+Z after reload.'; new Dialog(`Delete "${asset.name}"?`, detail, [ { label: 'Cancel' }, { label: 'Delete', danger: true, onClick: () => this.state.execute(new DeleteAssetCommand(id)) }, diff --git a/src/editor/panels/HierarchyPanel.ts b/src/editor/panels/HierarchyPanel.ts index fb198f00..af8166f9 100644 --- a/src/editor/panels/HierarchyPanel.ts +++ b/src/editor/panels/HierarchyPanel.ts @@ -1,10 +1,10 @@ -import { AddColliderCommand } from '../../core/commands/colliderCommands'; import { SetParentCommand } from '../../core/commands/sceneObjectCommands'; -import { ancestorIdsOf, visibleTreeOrder } from '../../core/model/hierarchy'; +import { hasColorSwatch } from '../../core/model/AssetTypes'; +import { ancestorIdsOf, findObject, visibleTreeOrder } from '../../core/model/hierarchy'; import { findAsset } from '../../core/model/Project'; import { resolveSceneObjectProps } from '../../core/properties/resolve'; import { ListView, Menu, Panel, ScrollContainer } from '../../ui'; -import { createContainer, removeObjects } from '../actions'; +import { addCollider, createContainer, removeObjects } from '../actions'; import { type EditorState, objectSelection } from '../state'; /** @@ -35,40 +35,15 @@ export class HierarchyPanel extends Panel { }; this.list.onDrop = (draggedId, targetId) => { const scene = state.activeScene; - if (!scene) return; - const object = scene.objects.find((o) => o.id === draggedId); - if (!object || object.parentId === (targetId ?? '')) return; + const object = scene && findObject(scene, draggedId); + if (!scene || !object || object.parentId === (targetId ?? '')) return; // Unfold the new parent, otherwise the dragged row would seem to vanish on drop. if (targetId) this.expanded.add(targetId); state.execute(new SetParentCommand(scene.id, draggedId, targetId ?? '')); }; this.list.onToggle = (id, expand) => this.setExpanded(id, expand); this.list.onActivate = (id) => this.setExpanded(id, !this.expanded.has(id)); - this.list.onContextMenu = (id, x, y) => { - const scene = state.activeScene; - if (!scene) return; - const object = scene.objects.find((o) => o.id === id); - const type = object && findAsset(state.project, object.assetId)?.type; - // Inside a multi-selection the menu acts on all selected objects. - const selected = state.selectedObjectIds.includes(id) ? state.selectedObjectIds : [id]; - const many = selected.length > 1; - new Menu([ - { label: 'Select asset', onSelect: () => object && state.select({ kind: 'asset', assetId: object.assetId }) }, - { - label: 'Add collider', - disabled: !type || type === 'collider' || many, - onSelect: () => { - const command = new AddColliderCommand(scene.id, id); - if (state.execute(command)) state.select(objectSelection(scene.id, [command.objectId])); - }, - }, - { label: many ? `Create container from ${selected.length} objects` : 'Create container', onSelect: () => createContainer(state, scene.id, selected) }, - { label: 'Unparent (move to top level)', disabled: !object?.parentId || many, onSelect: () => state.execute(new SetParentCommand(scene.id, id, '')) }, - { label: 'Expand all', onSelect: () => this.expandAll() }, - { label: 'Collapse all', onSelect: () => this.collapseAll() }, - { label: many ? `Remove ${selected.length} objects from scene` : 'Remove from scene', danger: true, onSelect: () => removeObjects(state, scene.id, selected) }, - ]).open(x, y); - }; + this.list.onContextMenu = (id, x, y) => this.openContextMenu(id, x, y); this.scroll.setContent(this.list); this.content.add(this.scroll, 1); @@ -103,6 +78,26 @@ export class HierarchyPanel extends Panel { this.refresh(); } + private openContextMenu(id: string, x: number, y: number): void { + const { state } = this; + const scene = state.activeScene; + if (!scene) return; + const object = findObject(scene, id); + const type = object && findAsset(state.project, object.assetId)?.type; + // Inside a multi-selection the menu acts on all selected objects. + const selected = state.selectedObjectIds.includes(id) ? state.selectedObjectIds : [id]; + const many = selected.length > 1; + new Menu([ + { label: 'Select asset', onSelect: () => object && state.select({ kind: 'asset', assetId: object.assetId }) }, + { label: 'Add collider', disabled: !type || type === 'collider' || many, onSelect: () => addCollider(state, scene.id, id) }, + { label: many ? `Create container from ${selected.length} objects` : 'Create container', onSelect: () => createContainer(state, scene.id, selected) }, + { label: 'Unparent (move to top level)', disabled: !object?.parentId || many, onSelect: () => state.execute(new SetParentCommand(scene.id, id, '')) }, + { label: 'Expand all', onSelect: () => this.expandAll() }, + { label: 'Collapse all', onSelect: () => this.collapseAll() }, + { label: many ? `Remove ${selected.length} objects from scene` : 'Remove from scene', danger: true, onSelect: () => removeObjects(state, scene.id, selected) }, + ]).open(x, y); + } + private setExpanded(id: string, expand: boolean): void { if (expand) this.expanded.add(id); else this.expanded.delete(id); @@ -136,7 +131,7 @@ export class HierarchyPanel extends Panel { label: object.name || asset?.name || '?', hint: asset?.type ?? '', depth, - swatch: asset?.type === 'sprite' ? Number(props.color ?? 0) : undefined, + swatch: asset && hasColorSwatch(asset.type) ? Number(props.color ?? 0) : undefined, expanded: hasChildren ? this.expanded.has(object.id) : undefined, }; }), diff --git a/src/editor/panels/Inspector.ts b/src/editor/panels/Inspector.ts deleted file mode 100644 index 68a7f9d4..00000000 --- a/src/editor/panels/Inspector.ts +++ /dev/null @@ -1,685 +0,0 @@ -import type { Container } from 'pixi.js'; -import { RenameAssetCommand, ResetAssetPropertyCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands'; -import { - ApplyOverrideToAssetCommand, - ApplyOverrideToContainerCommand, - ApplyPositionToContainerCommand, - RevertOverrideToTemplateCommand, - RevertPositionToTemplateCommand, -} from '../../core/commands/applyCommands'; -import { AddColliderCommand } from '../../core/commands/colliderCommands'; -import { RemoveNodeCommand, SetNodeParamCommand } from '../../core/commands/graphCommands'; -import { - ClearOverrideCommand, - MoveSceneObjectCommand, - RenameSceneObjectCommand, - SetEditorOffsetCommand, - SetOverrideCommand, - SetParentCommand, -} from '../../core/commands/sceneObjectCommands'; -import { AddVariableCommand, nextVariableName, RemoveVariableCommand, RenameVariableCommand } from '../../core/commands/variableCommands'; -import { edgesInto, inlineInputDef, inlineInputs, nodeDef } from '../../core/graph'; -import type { Asset, BehaviorAsset, SceneAsset } from '../../core/model/Asset'; -import { isPlaceableType } from '../../core/model/AssetTypes'; -import { type TemplateLink, templateLinkOf } from '../../core/model/container'; -import { isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy'; -import type { Position, PropertyValue, Vec2 } from '../../core/model/Properties'; -import { findAsset, findScene, findSceneObject } from '../../core/model/Project'; -import type { SceneObject } from '../../core/model/SceneObject'; -import { COMMON_PROPS, isCommonProp, type PropertyDef, schemaForAsset, VARIABLE_KINDS, type VariableKind } from '../../core/properties/PropertySchema'; -import { deviatesFromAsset, deviatesFromTemplate, isModified, isOverridden, resolveAssetProps, resolveSceneObjectProps } from '../../core/properties/resolve'; -import { Button, HStack, Label, Menu, type MenuItem, Panel, ScrollContainer, TextField, Theme, ui, VStack } from '../../ui'; -import { createContainer, removeObjects } from '../actions'; -import { createField, createFieldRow, FIELD_LABEL_WIDTH, type FieldRow } from '../inspector/fieldFactory'; -import { type EditorState, objectSelection, type Selection } from '../state'; - -const VARIABLE_KIND_LABELS: Record = { number: 'Number', boolean: 'Boolean', text: 'Text' }; -const VARIABLE_KIND_MARKS: Record = { number: '#', boolean: '✓', text: 'Aa' }; - -/** - * Shows the properties of the current selection. The field list is generated from the - * PropertySchema; scene objects additionally get Position, Owner and per-property - * overrides; behaviors get their variables; graph nodes get their settings and inline inputs. - */ -export class Inspector extends Panel { - private readonly scroll = new ScrollContainer(); - private body = new VStack({ padding: Theme.padding, gap: Theme.gap }); - private fields = new Map(); - private nameField: TextField | null = null; - private builtFor: Selection = null; - /** Describes the structure the fields were built for; a change means a rebuild. */ - private signature = ''; - /** Last live refresh (play mode), in ms – the world ticks far more often than the eye needs. */ - private lastLiveRefresh = 0; - - constructor(private readonly state: EditorState) { - super({ title: 'Inspector', padding: 0, gap: 0 }); - this.minWidth = 220; - this.scroll.setContent(this.body); - this.content.add(this.scroll, 1); - state.on('selection', () => this.rebuild()); - state.on('load', () => this.rebuild()); - state.on('project', () => this.refreshValues()); - state.on('runtime', () => this.refreshLive()); - this.rebuild(); - } - - /** - * Play mode: show what the behaviors are doing to the selected object, ~10× a second. - * Skipped while a field in here is being edited so the typed text is not overwritten. - */ - private refreshLive(): void { - const now = performance.now(); - if (now - this.lastLiveRefresh < 100) return; - for (let node: Container | null = ui().focus.focused; node; node = node.parent) if (node === this) return; - this.lastLiveRefresh = now; - this.refreshValues(); - } - - private rebuild(): void { - this.builtFor = this.state.selection; - this.body.destroy({ children: true }); - this.body = new VStack({ padding: Theme.padding, gap: Theme.gap }); - this.fields = new Map(); - this.nameField = null; - this.signature = this.currentSignature(); - - const s = this.state.selection; - if (!s) { - this.body.add(new Label('Select an asset, a scene object or a node.', { style: 'muted' })); - } else if (s.kind === 'asset') { - this.buildAsset(s.assetId); - } else if (s.kind === 'object') { - if (s.objectIds.length > 1) this.buildObjects(s.sceneId, s.objectIds); - else this.buildObject(s.sceneId, s.objectId); - } else { - this.buildNode(s.assetId, s.nodeId); - } - this.scroll.setContent(this.body); - } - - /** Structure-defining facts of the selection: schema keys, connected inputs. */ - private currentSignature(): string { - const s = this.state.selection; - if (!s) return ''; - if (s.kind === 'asset') { - const asset = findAsset(this.state.project, s.assetId); - return asset ? schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',') : 'missing'; - } - if (s.kind === 'object') { - if (s.objectIds.length > 1) return `multi|${s.objectIds.join(',')}`; - const object = this.currentObject(s.sceneId, s.objectId); - const asset = object && findAsset(this.state.project, object.assetId); - return asset ? `${object.parentId}|${object.templateId}|` + schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',') : 'missing'; - } - const asset = findAsset(this.state.project, s.assetId); - if (!asset || asset.type !== 'behavior') return 'missing'; - const node = asset.graph.nodes.find((n) => n.id === s.nodeId); - if (!node) return 'missing'; - const def = nodeDef(node.type); - const connected = def ? inlineInputs(def).filter((p) => edgesInto(asset.graph, node.id, p.key).length > 0).map((p) => p.key) : []; - return `${node.type}|${connected.join(',')}|${asset.variables.map((v) => v.key).join(',')}`; - } - - // --- assets ------------------------------------------------------------------------------ - - private buildAsset(assetId: string): void { - const asset = findAsset(this.state.project, assetId); - if (!asset) return; - this.body.add(new Label(`${asset.type.toUpperCase()} ASSET`, { style: 'title' })); - this.nameField = new TextField(asset.name, { - onChange: (name) => { - if (name.trim()) this.state.execute(new RenameAssetCommand(assetId, name.trim())); - else this.nameField?.setValue(asset.name); - }, - }); - this.body.add(this.labelled('Name', this.nameField)); - - if (asset.type === 'behavior') { - this.buildAssetProps(asset, COMMON_PROPS); - this.buildVariables(asset); - return; - } - - this.buildAssetProps(asset, schemaForAsset(asset)); - } - - /** Asset properties: values that differ from the schema default are marked and resettable. */ - private buildAssetProps(asset: Asset, schema: readonly PropertyDef[]): void { - const assetId = asset.id; - const values = resolveAssetProps(asset); - for (const def of schema) { - const row = createFieldRow(def, values[def.key] ?? def.default, this.assetHandlers(assetId, def), () => { - const target = findAsset(this.state.project, assetId); - if (!target || !isModified(target, def.key)) return []; - return [{ label: 'Reset to default', onSelect: () => this.state.execute(new ResetAssetPropertyCommand(assetId, def.key)) }]; - }); - row.setModified(isModified(asset, def.key)); - this.fields.set(def.key, row); - this.body.add(row.row); - } - } - - private assetHandlers(assetId: string, def: PropertyDef) { - let original: PropertyValue | undefined; - return { - onInput: (value: PropertyValue) => { - const target = findAsset(this.state.project, assetId); - if (!target) return; - if (original === undefined) original = target.props[def.key]; - target.props[def.key] = value; - this.state.preview(); - }, - onChange: (value: PropertyValue) => { - const target = findAsset(this.state.project, assetId); - if (!target) return; - if (original !== undefined) target.props[def.key] = original; - original = undefined; - this.state.execute(new SetAssetPropertyCommand(assetId, def.key, value)); - }, - }; - } - - /** Variables of a behavior: name, kind mark, default value, remove – plus an "add" menu. */ - private buildVariables(asset: BehaviorAsset): void { - const header = new HStack({ gap: Theme.gap, align: 'center' }); - header.add(new Label('VARIABLES', { style: 'title' }), 1); - const add = new Button('+ Variable ▾', { variant: 'ghost' }); - add.onClick = () => { - const g = add.getGlobalPosition(); - new Menu( - VARIABLE_KINDS.map((kind) => ({ - label: VARIABLE_KIND_LABELS[kind], - onSelect: () => { - const current = findAsset(this.state.project, asset.id); - if (current?.type === 'behavior') this.state.execute(new AddVariableCommand(asset.id, nextVariableName(current), kind)); - }, - })), - ).open(g.x, g.y + add.h + 2); - }; - header.add(add); - this.body.add(header); - - if (asset.variables.length === 0) this.body.add(new Label('No variables yet.', { style: 'muted' })); - const values = resolveAssetProps(asset); - for (const def of asset.variables) { - const row = new HStack({ gap: Theme.gap, align: 'center' }); - row.minHeight = Theme.rowHeight; - const mark = new Label(VARIABLE_KIND_MARKS[def.kind as VariableKind] ?? '?', { style: 'small', align: 'center' }); - mark.minWidth = 18; - row.add(mark); - const name = new TextField(def.key, { - width: 90, - onChange: (next) => { - if (!this.state.execute(new RenameVariableCommand(asset.id, def.key, next.trim()))) this.rebuild(); - }, - }); - row.add(name); - const field = createField(def, values[def.key] ?? def.default, this.assetHandlers(asset.id, def)); - row.add(field.widget, field.grow ? 1 : 0); - if (!field.grow) row.add(new Label(''), 1); - row.add(new Button('×', { variant: 'ghost', width: Theme.controlHeight, onClick: () => this.state.execute(new RemoveVariableCommand(asset.id, def.key)) })); - this.fields.set(def.key, { row, setValue: field.setValue, setModified: () => {} }); - this.body.add(row); - } - } - - // --- scene objects ------------------------------------------------------------------- - - /** Several objects: what they are, and the actions that apply to all of them at once. */ - private buildObjects(sceneId: string, objectIds: string[]): void { - const scene = findScene(this.state.project, sceneId); - if (!scene) return; - this.body.add(new Label(`${objectIds.length} OBJECTS SELECTED`, { style: 'title' })); - for (const id of objectIds) { - const object = findSceneObject(scene, id); - const asset = object && findAsset(this.state.project, object.assetId); - if (object && asset) this.body.add(new Label(`${object.name || asset.name} · ${asset.type}`, { style: 'muted' })); - } - this.body.add(new Label('')); - this.body.add(new Label('Ctrl-click adds or removes objects.', { style: 'small' })); - this.body.add(new Label('Dragging one in the scene moves all of them.', { style: 'small' })); - this.body.add(new Label('')); - this.body.add(new Button('Create container', { onClick: () => createContainer(this.state, sceneId, objectIds) })); - this.body.add(new Label('Groups the selection into a reusable container', { style: 'small' })); - this.body.add(new Label('asset placed at its centre (Ctrl+G).', { style: 'small' })); - this.body.add(new Label('')); - this.body.add(new Button('Remove from scene', { variant: 'danger', onClick: () => removeObjects(this.state, sceneId, objectIds) })); - } - - private buildObject(sceneId: string, objectId: string): void { - const scene = findScene(this.state.project, sceneId); - const object = scene && findSceneObject(scene, objectId); - const asset = object && findAsset(this.state.project, object.assetId); - if (!scene || !object || !asset) return; - - this.body.add(new Label(`${asset.type.toUpperCase()} OBJECT`, { style: 'title' })); - this.nameField = new TextField(object.name, { - placeholder: asset.name, - onChange: (name) => this.state.execute(new RenameSceneObjectCommand(sceneId, objectId, name.trim())), - }); - this.body.add(this.labelled('Name', this.nameField)); - - const assetRow = new HStack({ gap: Theme.gap, align: 'center' }); - const assetLabel = new Label('Asset', { style: 'muted' }); - assetLabel.minWidth = FIELD_LABEL_WIDTH; - assetRow.add(assetLabel); - assetRow.add(new Button(asset.name, { variant: 'ghost', onClick: () => this.state.select({ kind: 'asset', assetId: asset.id }) }), 1); - this.body.add(assetRow); - - this.body.add(this.parentRow(scene, object, sceneId)); - - // Part of a placed container: values compare against the template, not just the asset. - const link = templateLinkOf(this.state.project, scene, object); - if (link) { - const linkRow = new HStack({ gap: Theme.gap, align: 'center' }); - const linkLabel = new Label('Part of', { style: 'muted' }); - linkLabel.minWidth = FIELD_LABEL_WIDTH; - linkRow.add(linkLabel); - linkRow.add(new Button(link.asset.name, { variant: 'ghost', onClick: () => this.state.select({ kind: 'asset', assetId: link.asset.id }) }), 1); - this.body.add(linkRow); - } - - // The master switch, right where it is looked for: above everything type-specific. - for (const def of COMMON_PROPS) { - this.body.add(this.objectPropertyRow(sceneId, objectId, def, resolveSceneObjectProps(asset, object)).row); - } - - // Position – belongs to the scene object only, local to the parent. Z is the draw order (higher = on top). - const parent = object.parentId ? findSceneObject(scene, object.parentId) : undefined; - this.body.add(new Label(parent ? 'POSITION (LOCAL TO PARENT)' : 'POSITION', { style: 'title' })); - for (const axis of ['x', 'y', 'z'] as const) { - let original: Position | undefined; - const row = createFieldRow( - { key: axis, label: axis.toUpperCase(), kind: 'number', default: 0, step: 1 }, - object.position[axis], - { - onInput: (value) => { - const target = this.currentObject(sceneId, objectId); - if (!target) return; - if (!original) original = { ...target.position }; - target.position[axis] = Number(value); - this.state.preview(); - }, - onChange: (value) => { - const target = this.currentObject(sceneId, objectId); - if (!target) return; - const previous = original ?? { ...target.position }; - if (original) target.position = { ...original }; - original = undefined; - const next = { ...previous, [axis]: Number(value) }; - this.state.execute(new MoveSceneObjectCommand(sceneId, objectId, next, previous)); - }, - }, - () => - this.positionMoved(sceneId, objectId, axis) - ? [{ label: 'Reset to container', onSelect: () => this.state.execute(new RevertPositionToTemplateCommand(sceneId, objectId, axis)) }] - : [], - () => - this.positionMoved(sceneId, objectId, axis) - ? [{ label: 'Apply to container', onSelect: () => this.state.execute(new ApplyPositionToContainerCommand(sceneId, objectId, axis)) }] - : [], - ); - this.fields.set(axis, row); - this.body.add(row.row); - } - - // Editor offset – where the object is *drawn* in the scene view, nothing else. Handy for - // objects that sit on top of each other (stacked screens): pull them apart to work on them. - this.body.add(new Label('EDITOR OFFSET (SCENE VIEW ONLY)', { style: 'title' })); - for (const axis of ['x', 'y'] as const) { - let original: Vec2 | undefined; - const row = createFieldRow( - { key: `editorOffset${axis.toUpperCase()}`, label: `Offset ${axis.toUpperCase()}`, kind: 'number', default: 0, step: 1 }, - object.editorOffset[axis], - { - onInput: (value) => { - const target = this.currentObject(sceneId, objectId); - if (!target) return; - if (!original) original = { ...target.editorOffset }; - target.editorOffset[axis] = Number(value); - this.state.preview(); - }, - onChange: (value) => { - const target = this.currentObject(sceneId, objectId); - if (!target) return; - const previous = original ?? { ...target.editorOffset }; - if (original) target.editorOffset = { ...original }; - original = undefined; - this.state.execute(new SetEditorOffsetCommand(sceneId, objectId, { ...previous, [axis]: Number(value) }, previous)); - }, - }, - // Back to "drawn where it really is". No apply: the offset never leaves the scene object. - () => - this.currentObject(sceneId, objectId)?.editorOffset[axis] - ? [{ label: 'Reset to 0', onSelect: () => this.resetEditorOffset(sceneId, objectId, axis) }] - : [], - ); - this.fields.set(`editorOffset${axis.toUpperCase()}`, row); - this.body.add(row.row); - } - - // Asset properties (or behavior variables) with overrides. The common switch (Enabled) - // is shown on its own, above the position – it exists for every type and is not a - // "property of a sprite" in the same sense. - const schema = schemaForAsset(asset); - const values = resolveSceneObjectProps(asset, object); - const own = schema.filter((def) => !isCommonProp(def.key)); - if (own.length > 0 || asset.type === 'behavior') this.body.add(new Label(asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES', { style: 'title' })); - if (own.length === 0 && asset.type === 'behavior') this.body.add(new Label('The behavior declares no variables.', { style: 'muted' })); - for (const def of own) this.body.add(this.objectPropertyRow(sceneId, objectId, def, values).row); - this.markObjectRows(object, asset, link); - - if (asset.type === 'container') { - const count = asset.objects.length; - this.body.add(new Label('CONTENT', { style: 'title' })); - this.body.add(new Label(count === 0 ? 'The container is empty.' : `Instance with ${count} template object(s).`, { style: 'muted' })); - if (count > 0) this.body.add(new Label('Its content is listed below it in the hierarchy.', { style: 'small' })); - } - - // Sprites (and other bodies) get their collider with one click: a child sized like the object. - if (asset.type !== 'collider') { - this.body.add(new Label('')); - this.body.add( - new Button('+ Add collider', { - variant: 'ghost', - onClick: () => { - const command = new AddColliderCommand(sceneId, objectId); - if (this.state.execute(command)) this.state.select(objectSelection(sceneId, [command.objectId])); - }, - }), - ); - } - } - - /** - * One editable property of a scene object: writes an override, offers ↺ (back to the asset - * or, inside a container, to the template) and ↑ (apply upwards). Registered in `fields` - * so live refreshes and `markObjectRows` find it. - * - * Inside a container a value has two bases – the template it was instantiated from and, one - * step further up, the asset – so ↺/↑ can become a menu. Only bases the value really differs - * from are listed, so a menu never contains an entry that would change nothing. Dropping the - * override is what makes the object follow the asset again: the template is only read when - * the container is placed, never at draw time. - */ - private objectPropertyRow(sceneId: string, objectId: string, def: PropertyDef, values: Record): FieldRow { - let original: PropertyValue | undefined; - let hadOverride = false; - const row = createFieldRow( - def, - values[def.key] ?? def.default, - { - onInput: (value) => { - const target = this.currentObject(sceneId, objectId); - if (!target) return; - if (original === undefined) { - hadOverride = isOverridden(target, def.key); - original = target.overrides[def.key] ?? values[def.key] ?? def.default; - } - target.overrides[def.key] = value; - this.state.preview(); - }, - onChange: (value) => { - const target = this.currentObject(sceneId, objectId); - if (!target) return; - if (original !== undefined) { - if (hadOverride) target.overrides[def.key] = original; - else delete target.overrides[def.key]; - } - original = undefined; - this.state.execute(new SetOverrideCommand(sceneId, objectId, def.key, value)); - }, - }, - () => { - const { template, asset } = this.deviations(sceneId, objectId, def.key); - return [ - ...(template ? [{ label: 'Reset to container', onSelect: () => this.state.execute(new RevertOverrideToTemplateCommand(sceneId, objectId, def.key)) }] : []), - ...(asset ? [{ label: 'Reset to asset', onSelect: () => this.state.execute(new ClearOverrideCommand(sceneId, objectId, def.key)) }] : []), - ]; - }, - () => { - const { template, asset } = this.deviations(sceneId, objectId, def.key); - return [ - ...(template ? [{ label: 'Apply to container', onSelect: () => this.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)) }] : []), - ...(asset ? [{ label: 'Apply to asset', onSelect: () => this.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)) }] : []), - ]; - }, - ); - this.fields.set(def.key, row); - return row; - } - - /** - * "Parent: ▾" – picks another object of the same scene, or none. The object's own - * subtree is excluded (that would be a cycle). For behaviors the parent is the owner. - */ - private parentRow(scene: SceneAsset, object: SceneObject, sceneId: string): HStack { - const row = new HStack({ gap: Theme.gap, align: 'center' }); - row.minHeight = Theme.rowHeight; - const label = new Label('Parent', { style: 'muted' }); - label.minWidth = FIELD_LABEL_WIDTH; - row.add(label); - const nameOf = (o: SceneObject) => o.name || findAsset(this.state.project, o.assetId)?.name || '?'; - const parent = scene.objects.find((o) => o.id === object.parentId); - const button = new Button(`${parent ? nameOf(parent) : 'None'} ▾`); - button.onClick = () => { - const g = button.getGlobalPosition(); - const items: MenuItem[] = [{ label: 'None', onSelect: () => this.state.execute(new SetParentCommand(sceneId, object.id, '')) }]; - for (const { object: candidate, depth } of treeOrder(scene)) { - if (isSelfOrAncestor(scene, object.id, candidate.id)) continue; - const name = `${' '.repeat(depth)}${nameOf(candidate)}`; - items.push({ label: candidate.id === object.parentId ? `● ${name}` : name, onSelect: () => this.state.execute(new SetParentCommand(sceneId, object.id, candidate.id)) }); - } - new Menu(items).open(g.x, g.y + button.h + 2); - }; - row.add(button, 1); - return row; - } - - // --- graph nodes --------------------------------------------------------------------- - - private buildNode(assetId: string, nodeId: string): void { - const asset = findAsset(this.state.project, assetId); - if (!asset || asset.type !== 'behavior') return; - const node = asset.graph.nodes.find((n) => n.id === nodeId); - const def = node && nodeDef(node.type); - if (!node || !def) return; - - this.body.add(new Label(`${def.category.toUpperCase()} NODE`, { style: 'title' })); - this.body.add(new Label(def.label, { style: 'bodyBold' })); - this.body.add(new Label(def.description, { style: 'muted' })); - - const handlers = (key: string) => { - let original: PropertyValue | undefined; - let had = false; - return { - onInput: (value: PropertyValue) => { - const target = this.currentNode(assetId, nodeId); - if (!target) return; - if (original === undefined) { - had = target.params[key] !== undefined; - original = target.params[key] ?? value; - } - target.params[key] = value; - this.state.preview(); - }, - onChange: (value: PropertyValue) => { - const target = this.currentNode(assetId, nodeId); - if (!target) return; - if (original !== undefined) { - if (had) target.params[key] = original; - else delete target.params[key]; - } - original = undefined; - this.state.execute(new SetNodeParamCommand(assetId, nodeId, key, value)); - }, - }; - }; - - if (def.params.length > 0) this.body.add(new Label('SETTINGS', { style: 'title' })); - for (const param of def.params) { - const effective = this.resolveParamDef(param, asset); - const row = createFieldRow(effective, node.params[param.key] ?? effective.default, handlers(param.key)); - this.fields.set(`param:${param.key}`, row); - this.body.add(row.row); - } - - const free = inlineInputs(def).filter((p) => edgesInto(asset.graph, node.id, p.key).length === 0); - if (free.length > 0) this.body.add(new Label('INPUTS (unconnected)', { style: 'title' })); - for (const port of free) { - const inline = inlineInputDef(port); - const row = createFieldRow(inline, node.params[port.key] ?? inline.default, handlers(port.key)); - this.fields.set(`input:${port.key}`, row); - this.body.add(row.row); - } - - this.body.add(new Label('')); - this.body.add(new Button('Delete node', { variant: 'danger', onClick: () => this.state.execute(new RemoveNodeCommand(assetId, nodeId)) })); - } - - /** - * Variable params pick from the asset's variables, asset params (Spawn) from the project's - * placeable assets; everything else is used as declared. - */ - private resolveParamDef(param: PropertyDef, asset: Asset): PropertyDef { - if (param.kind === 'enum' && param.key === 'variable' && asset.type === 'behavior') { - return { ...param, options: asset.variables.map((v) => ({ value: v.key, label: v.key })) }; - } - if (param.kind === 'enum' && param.key === 'asset') { - const options = this.state.project.assets.filter((a) => isPlaceableType(a.type)).map((a) => ({ value: a.id, label: `${a.name} (${a.type})` })); - return { ...param, options }; - } - return param; - } - - // --- refresh --------------------------------------------------------------------------- - - /** Push current model values into the widgets (after undo, drag in viewport, …). */ - private refreshValues(): void { - const s = this.state.selection; - if (!sameTarget(s, this.builtFor) || this.currentSignature() !== this.signature) { - this.rebuild(); - return; - } - if (!s) return; - if (s.kind === 'asset') { - const asset = findAsset(this.state.project, s.assetId); - if (!asset) return this.rebuild(); - this.nameField?.setValue(asset.name); - const values = resolveAssetProps(asset); - for (const [key, row] of this.fields) { - row.setValue(values[key] ?? 0); - row.setModified(isModified(asset, key)); - } - return; - } - if (s.kind === 'node') { - const node = this.currentNode(s.assetId, s.nodeId); - const def = node && nodeDef(node.type); - if (!node || !def) return this.rebuild(); - for (const [key, row] of this.fields) { - const [scope, name] = key.split(':') as ['param' | 'input', string]; - const fallback = scope === 'param' ? def.params.find((p) => p.key === name)?.default : inlineInputDef(def.inputs.find((p) => p.key === name)!).default; - row.setValue(node.params[name] ?? fallback ?? ''); - } - return; - } - const scene = findScene(this.state.project, s.sceneId); - const object = this.currentObject(s.sceneId, s.objectId); - const asset = object && findAsset(this.state.project, object.assetId); - if (!scene || !object || !asset) return this.rebuild(); - this.nameField?.setValue(object.name); - this.fields.get('x')?.setValue(object.position.x); - this.fields.get('y')?.setValue(object.position.y); - this.fields.get('z')?.setValue(object.position.z); - this.fields.get('editorOffsetX')?.setValue(object.editorOffset.x); - this.fields.get('editorOffsetY')?.setValue(object.editorOffset.y); - const values = resolveSceneObjectProps(asset, object); - for (const def of schemaForAsset(asset)) this.fields.get(def.key)?.setValue(values[def.key] ?? def.default); - this.markObjectRows(object, asset, templateLinkOf(this.state.project, scene, object)); - } - - /** - * Which rows deviate from their base: for a container instance the base is the template - * (position and overrides), for any other object the asset. Bold marks exactly what the - * ↺/↑ buttons offer – a value that matches its base is neither highlighted nor actionable. - */ - private markObjectRows(object: SceneObject, asset: Asset, link: TemplateLink | undefined): void { - for (const axis of ['x', 'y', 'z'] as const) this.fields.get(axis)?.setModified(Boolean(link) && object.position[axis] !== link!.template.position[axis]); - // The offset has one base only: no offset at all. Bold means "shown somewhere else than it is". - for (const axis of ['x', 'y'] as const) this.fields.get(`editorOffset${axis.toUpperCase()}`)?.setModified(object.editorOffset[axis] !== 0); - for (const def of schemaForAsset(asset)) { - const deviates = link ? deviatesFromTemplate(object, link.template, def.key) : deviatesFromAsset(object, asset, def.key); - this.fields.get(def.key)?.setModified(deviates); - } - } - - /** - * The two bases a property of a scene object can be reset to or applied upwards, each only - * listed when it actually differs: the template of the container it belongs to (a value - * copied when the container was placed) and the asset behind it. Inside a container both - * can apply at once – then the row shows a menu, otherwise a single button, and nothing - * at all when the value matches everywhere. - */ - private deviations(sceneId: string, objectId: string, key: string): { template: boolean; asset: boolean } { - const object = this.currentObject(sceneId, objectId); - const asset = object && findAsset(this.state.project, object.assetId); - if (!object || !asset) return { template: false, asset: false }; - const link = this.linkOf(sceneId, objectId); - return { - template: Boolean(link) && deviatesFromTemplate(object, link!.template, key), - asset: deviatesFromAsset(object, asset, key), - }; - } - - /** Drops the editor offset on one axis (the row's ↺). */ - private resetEditorOffset(sceneId: string, objectId: string, axis: 'x' | 'y'): void { - const object = this.currentObject(sceneId, objectId); - if (!object || object.editorOffset[axis] === 0) return; - this.state.execute(new SetEditorOffsetCommand(sceneId, objectId, { ...object.editorOffset, [axis]: 0 })); - } - - /** Whether the object sits somewhere else on `axis` than the container template says. */ - private positionMoved(sceneId: string, objectId: string, axis: 'x' | 'y' | 'z'): boolean { - const object = this.currentObject(sceneId, objectId); - const link = this.linkOf(sceneId, objectId); - return Boolean(object && link) && object!.position[axis] !== link!.template.position[axis]; - } - - private currentObject(sceneId: string, objectId: string) { - const scene = findScene(this.state.project, sceneId); - return scene && findSceneObject(scene, objectId); - } - - /** The object's template link as it is *now* – actions must not work off a stale snapshot. */ - private linkOf(sceneId: string, objectId: string): TemplateLink | undefined { - const scene = findScene(this.state.project, sceneId); - const object = this.currentObject(sceneId, objectId); - return scene && object ? templateLinkOf(this.state.project, scene, object) : undefined; - } - - private currentNode(assetId: string, nodeId: string) { - const asset = findAsset(this.state.project, assetId); - return asset?.type === 'behavior' ? asset.graph.nodes.find((n) => n.id === nodeId) : undefined; - } - - private labelled(text: string, field: TextField): HStack { - const row = new HStack({ gap: Theme.gap, align: 'center' }); - row.minHeight = Theme.rowHeight; - const label = new Label(text, { style: 'muted' }); - label.minWidth = FIELD_LABEL_WIDTH; - row.add(label); - row.add(field, 1); - return row; - } -} - -function sameTarget(a: Selection, b: Selection): boolean { - if (!a || !b) return a === b; - if (a.kind !== b.kind) return false; - if (a.kind === 'asset' && b.kind === 'asset') return a.assetId === b.assetId; - if (a.kind === 'object' && b.kind === 'object') return a.sceneId === b.sceneId && a.objectId === b.objectId && a.objectIds.join(',') === b.objectIds.join(','); - if (a.kind === 'node' && b.kind === 'node') return a.assetId === b.assetId && a.nodeId === b.nodeId; - return false; -} diff --git a/src/editor/panels/Toolbar.ts b/src/editor/panels/Toolbar.ts index e792dd81..18e3d254 100644 --- a/src/editor/panels/Toolbar.ts +++ b/src/editor/panels/Toolbar.ts @@ -4,9 +4,13 @@ import { listScenes } from '../../core/model/Project'; import { Button, HStack, Label, Spacer, TextField, Theme, type Widget } from '../../ui'; import type { EditorState, SaveStatus } from '../state'; +/** What the toolbar buttons trigger; the editor supplies the implementations. */ export interface ToolbarActions { + /** "Project ▾": list, create and delete projects. `anchor` is the button to open the menu under. */ openProjectMenu(anchor: Widget): void; + /** " ▾": switch the active scene. */ openSceneMenu(anchor: Widget): void; + /** Play in edit mode, Stop in play mode. */ togglePlay(): void; } diff --git a/src/editor/shortcuts.ts b/src/editor/shortcuts.ts new file mode 100644 index 00000000..159dba3d --- /dev/null +++ b/src/editor/shortcuts.ts @@ -0,0 +1,60 @@ +import { DeleteAssetCommand } from '../core/commands/assetCommands'; +import { RemoveNodeCommand } from '../core/commands/graphCommands'; +import { findAsset } from '../core/model/Project'; +import { Dialog, ui } from '../ui'; +import { createContainer, removeObjects } from './actions'; +import type { EditorState } from './state'; + +export interface ShortcutActions { + play(): void; + stop(): void; +} + +/** + * Global keyboard shortcuts (see README "Keyboard"). Text fields keep their own key handling; + * these only run when no focused widget consumed the key. + */ +export function installShortcuts(state: EditorState, actions: ShortcutActions): void { + ui().focus.globalHandlers.push((event) => { + const mod = event.ctrlKey || event.metaKey; + const key = event.key.toLowerCase(); + if (event.key === 'Escape' && state.mode === 'play') { + actions.stop(); + return true; + } + if (mod && key === 'z') { + if (event.shiftKey) state.redo(); + else state.undo(); + return true; + } + if (mod && key === 'y') { + state.redo(); + return true; + } + if (mod && key === 'g') { + const s = state.selection; + if (s?.kind === 'object') createContainer(state, s.sceneId, s.objectIds); + return true; + } + if (event.key === 'Delete' || event.key === 'Backspace') { + const s = state.selection; + if (s?.kind === 'object') removeObjects(state, s.sceneId, s.objectIds); + else if (s?.kind === 'node') state.execute(new RemoveNodeCommand(s.assetId, s.nodeId)); + else if (s?.kind === 'asset') { + const asset = findAsset(state.project, s.assetId); + if (asset) { + new Dialog(`Delete "${asset.name}"?`, 'Placed instances are removed as well.', [ + { label: 'Cancel' }, + { label: 'Delete', danger: true, onClick: () => state.execute(new DeleteAssetCommand(s.assetId)) }, + ]).openCentered(); + } + } + return s !== null; + } + if (mod && event.key === 'Enter' && state.mode === 'edit') { + actions.play(); + return true; + } + return false; + }); +} diff --git a/src/editor/state.ts b/src/editor/state.ts index 757e8cc1..851f3ba9 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -1,7 +1,8 @@ import { type Command, CommandError } from '../core/commands/Command'; import { CommandStack } from '../core/commands/CommandStack'; import type { SceneAsset } from '../core/model/Asset'; -import { createEmptyProject, findScene, type Project } from '../core/model/Project'; +import { findObject } from '../core/model/hierarchy'; +import { createEmptyProject, findAsset, findScene, type Project } from '../core/model/Project'; import { deserializeProject, serializeProject } from '../core/store/serialize'; export type Selection = @@ -27,8 +28,10 @@ export type EditorMode = 'edit' | 'play'; export type SaveStatus = 'saved' | 'dirty' | 'saving' | 'error' | 'conflict'; export type EditorEvent = - | 'project' // the model changed (command, undo, preview, load) - | 'load' // a different project was loaded + /** The model changed (command, undo, preview, load). */ + | 'project' + /** A different project was loaded. */ + | 'load' | 'selection' | 'mode' | 'save' @@ -61,6 +64,7 @@ export class EditorState { private readonly listeners = new Map>(); + /** 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())); @@ -72,7 +76,7 @@ export class EditorState { for (const listener of this.listeners.get(event) ?? []) listener(); } - // --- project lifecycle --------------------------------------------------------------- + // --- project lifecycle --------------------------------------------------------------------- loadProject(project: Project, revision: number): void { this.edit = null; @@ -125,7 +129,7 @@ export class EditorState { return findScene(this.project, this.project.activeSceneId); } - // --- editing ------------------------------------------------------------------------- + // --- editing ------------------------------------------------------------------------------- /** * Runs a command through the undo stack. Returns false (and sets a notice) when invalid. @@ -147,13 +151,11 @@ export class EditorState { undo(): void { if (!this.commands.undo()) return; - this.validateSelection(); this.markDirty(); } redo(): void { if (!this.commands.redo()) return; - this.validateSelection(); this.markDirty(); } @@ -178,10 +180,10 @@ export class EditorState { this.emit('save'); } - // --- selection ----------------------------------------------------------------------- + // --- selection ----------------------------------------------------------------------------- select(selection: Selection): void { - // Tolerate the short form `{ kind: 'object', sceneId, objectId }` (tests, older callers). + // Test hooks pass the short form `{ kind: 'object', sceneId, objectId }` without `objectIds`. if (selection?.kind === 'object' && !selection.objectIds) selection = objectSelection(selection.sceneId, [selection.objectId]); if (sameSelection(this.selection, selection)) return; this.selection = selection; @@ -205,14 +207,14 @@ export class EditorState { private validateSelection(): void { const s = this.selection; if (!s) return; - if (s.kind === 'asset' && !this.project.assets.some((a) => a.id === s.assetId)) this.select(null); + if (s.kind === 'asset' && !findAsset(this.project, s.assetId)) this.select(null); if (s.kind === 'object') { const scene = findScene(this.project, s.sceneId); - const alive = scene ? s.objectIds.filter((id) => scene.objects.some((o) => o.id === id)) : []; + const alive = scene ? s.objectIds.filter((id) => findObject(scene, id)) : []; if (alive.length !== s.objectIds.length) this.select(objectSelection(s.sceneId, alive, s.objectId)); } if (s.kind === 'node') { - const asset = this.project.assets.find((a) => a.id === s.assetId); + const asset = findAsset(this.project, s.assetId); // Fall back to the behavior asset so the graph stays open when its node vanished (undo). if (!asset || asset.type !== 'behavior') this.select(null); else if (!asset.graph.nodes.some((n) => n.id === s.nodeId)) this.select({ kind: 'asset', assetId: s.assetId }); @@ -224,18 +226,13 @@ export class EditorState { const s = this.selection; if (!s) return null; if (s.kind === 'node') return s.assetId; - const assetId = s.kind === 'asset' ? s.assetId : findScene(this.project, s.sceneId)?.objects.find((o) => o.id === s.objectId)?.assetId; - const asset = assetId ? this.project.assets.find((a) => a.id === assetId) : undefined; + const scene = s.kind === 'object' ? findScene(this.project, s.sceneId) : undefined; + const assetId = s.kind === 'asset' ? s.assetId : scene && findObject(scene, s.objectId)?.assetId; + const asset = assetId ? findAsset(this.project, assetId) : undefined; return asset && asset.type === 'behavior' ? asset.id : null; } - // --- mode / status ------------------------------------------------------------------- - - setMode(mode: EditorMode): void { - if (this.mode === mode) return; - this.mode = mode; - this.emit('mode'); - } + // --- status -------------------------------------------------------------------------------- setSaveStatus(status: SaveStatus): void { if (this.saveStatus === status) return; diff --git a/src/playground.ts b/src/playground.ts index a2432683..192e67f6 100644 --- a/src/playground.ts +++ b/src/playground.ts @@ -22,7 +22,10 @@ import { * testing: focus cycling, typing, scrubbing, color picking, scrolling, popups. */ export function mountPlayground(app: Application, root: VStack): void { - const status = new Label('Interact with the widgets – events are logged here.', { style: 'muted', padding: Theme.padding }); + const status = new Label('Interact with the widgets – events are logged here.', { + style: 'muted', + padding: Theme.padding, + }); const log = (message: string) => status.setText(message); const row = new HStack({ gap: Theme.padding, padding: Theme.padding }); @@ -30,12 +33,24 @@ export function mountPlayground(app: Application, root: VStack): void { // Column 1: inputs const inputs = new Panel({ title: 'Inputs' }); inputs.content.add(new Label('Text field')); - inputs.content.add(new TextField('Crate', { placeholder: 'Name', onInput: (v) => log(`text input: ${v}`), onChange: (v) => log(`text change: ${v}`) })); + inputs.content.add(new TextField('Crate', { + placeholder: 'Name', + onInput: (v) => log(`text input: ${v}`), + onChange: (v) => log(`text change: ${v}`), + })); inputs.content.add(new Label('Number field (drag to scrub)')); - inputs.content.add(new NumberField(100, { min: 1, step: 1, onInput: (v) => log(`number input: ${v}`), onChange: (v) => log(`number change: ${v}`) })); + inputs.content.add(new NumberField(100, { + min: 1, + step: 1, + onInput: (v) => log(`number input: ${v}`), + onChange: (v) => log(`number change: ${v}`), + })); inputs.content.add(new NumberField(1, { min: 0.1, max: 10, step: 0.1, onChange: (v) => log(`zoom change: ${v}`) })); inputs.content.add(new Label('Color field')); - inputs.content.add(new ColorField(0xe0553f, { onInput: (c) => log(`color input: ${toHex(c)}`), onChange: (c) => log(`color change: ${toHex(c)}`) })); + inputs.content.add(new ColorField(0xe0553f, { + onInput: (c) => log(`color input: ${toHex(c)}`), + onChange: (c) => log(`color change: ${toHex(c)}`), + })); const checkRow = new HStack({ gap: Theme.gap, align: 'center' }); const checkbox = new Checkbox(true); checkbox.onChange = (v) => log(`checkbox: ${v}`); @@ -49,7 +64,10 @@ export function mountPlayground(app: Application, root: VStack): void { actions.content.add(new Button('Default', { onClick: () => log('default clicked') })); actions.content.add(new Button('Primary', { variant: 'primary', onClick: () => log('primary clicked') })); const toggle = new Button('Toggle me'); - toggle.onClick = () => { toggle.setToggled(!toggle.toggled); log(`toggled: ${toggle.toggled}`); }; + toggle.onClick = () => { + toggle.setToggled(!toggle.toggled); + log(`toggled: ${toggle.toggled}`); + }; actions.content.add(toggle); actions.content.add(new Button('Ghost', { variant: 'ghost', onClick: () => log('ghost clicked') })); const disabled = new Button('Disabled'); @@ -57,13 +75,12 @@ export function mountPlayground(app: Application, root: VStack): void { actions.content.add(disabled); const menuButton = new Button('Open menu…'); menuButton.onClick = () => { - const g = menuButton.getGlobalPosition(); new Menu([ { label: 'Scene', onSelect: () => log('menu: scene') }, { label: 'Camera', onSelect: () => log('menu: camera') }, { label: 'Sprite', onSelect: () => log('menu: sprite') }, { label: 'Delete', danger: true, onSelect: () => log('menu: delete') }, - ]).open(g.x, g.y + menuButton.h + 2); + ]).openBelow(menuButton); }; actions.content.add(menuButton); actions.content.add(new Button('Open dialog…', { @@ -87,7 +104,8 @@ export function mountPlayground(app: Application, root: VStack): void { }))); list.onSelect = (id) => log(`list select: ${id}`); list.onActivate = (id) => log(`list activate: ${id}`); - list.onContextMenu = (id, x, y) => new Menu([{ label: `Context for ${id}`, onSelect: () => log(`context: ${id}`) }]).open(x, y); + list.onContextMenu = (id, x, y) => + new Menu([{ label: `Context for ${id}`, onSelect: () => log(`context: ${id}`) }]).open(x, y); scroll.setContent(list); listPanel.content.add(scroll, 1); row.add(listPanel, 1); diff --git a/src/runtime/World.ts b/src/runtime/World.ts index 2fe23077..c42de33c 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -1,12 +1,13 @@ import { isHandleEnabled, type PointerState, worldPositionOf } from '../core/graph'; -import { footprintContains } from '../core/properties/bounds'; -import { colliderShape, shapeContains } from '../core/properties/collider'; -import { instantiateContainer } from '../core/model/container'; import { newId } from '../core/ids'; import type { Asset } from '../core/model/Asset'; import { isPlaceableType } from '../core/model/AssetTypes'; +import { instantiateContainer } from '../core/model/container'; +import { findObject } from '../core/model/hierarchy'; import { findAsset, findScene, type Project } from '../core/model/Project'; import { createSceneObject, type SceneObject } from '../core/model/SceneObject'; +import { footprintContains } from '../core/properties/bounds'; +import { colliderShape, shapeContains } from '../core/properties/collider'; import { resolveSceneObjectProps } from '../core/properties/resolve'; import type { Entity } from './Entity'; import type { System } from './systems/System'; @@ -49,6 +50,13 @@ export class World { return world; } + /** Seconds since the world started. */ + get time(): number { + return this.elapsed; + } + + // --- model sync ------------------------------------------------------------------------------ + /** * 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 @@ -69,7 +77,7 @@ export class World { for (let i = this.entities.length - 1; i >= 0; i--) if (!keep.has(this.entities[i]!)) this.entities.splice(i, 1); // Link parents once every entity exists (storage order is not guaranteed to be parent-first). for (const entity of this.entities) { - const object = scene.objects.find((o) => o.id === entity.objectId); + const object = findObject(scene, entity.objectId); entity.parent = object?.parentId ? this.findByObjectId(object.parentId) ?? null : null; } } @@ -139,7 +147,7 @@ export class World { if (asset && asset.type !== 'scene' && !alive.has(object.id)) scene.objects.splice(i, 1); } for (const entity of this.entities) { - const object = scene.objects.find((o) => o.id === entity.objectId); + const object = findObject(scene, entity.objectId); const asset = object && findAsset(this.project, entity.assetId); if (!object || !asset) continue; const p = object.position; @@ -150,15 +158,9 @@ export class World { return spawned || scene.objects.length !== before; } - /** World position of an entity: local position plus those of all ancestors. */ - worldPosition(entity: Entity): { x: number; y: number; z: number } { - return worldPositionOf(entity); - } - - childrenOf(entity: Entity): Entity[] { - return this.entities.filter((e) => e.parent === entity); - } + // --- simulation ------------------------------------------------------------------------------ + /** Registers a system; it starts immediately and is ticked in registration order. */ addSystem(system: System): this { this.systems.push(system); system.start?.(this); @@ -189,6 +191,15 @@ export class World { else this.pointer.released = true; } + /** Stops the systems and drops every entity; the world is unusable afterwards. */ + destroy(): void { + for (const system of this.systems) system.stop?.(this); + this.systems.length = 0; + this.entities.length = 0; + } + + // --- queries --------------------------------------------------------------------------------- + /** * Whether a world point lies on an entity: on the colliders of its subtree when it has * any, otherwise on the box of a sprite or text in it. Disabled parts never answer, so a @@ -198,8 +209,12 @@ export class World { const point = { x, y }; const parts = [entity, ...this.descendantsOf(entity)].filter((e) => isHandleEnabled(e)); const colliders = parts.filter((e) => e.type === 'collider'); - if (colliders.length > 0) return colliders.some((c) => shapeContains(colliderShape(c.props, this.worldPosition(c)), point)); - return parts.some((e) => (e.type === 'sprite' || e.type === 'text') && footprintContains(e.type, e.props, this.worldPosition(e), point)); + if (colliders.length > 0) return colliders.some((c) => shapeContains(colliderShape(c.props, worldPositionOf(c)), point)); + return parts.some((e) => (e.type === 'sprite' || e.type === 'text') && footprintContains(e.type, e.props, worldPositionOf(e), point)); + } + + childrenOf(entity: Entity): Entity[] { + return this.entities.filter((e) => e.parent === entity); } /** Everything parented below an entity, at any depth. */ @@ -215,14 +230,11 @@ export class World { return result; } - get time(): number { - return this.elapsed; - } - findByObjectId(objectId: string): Entity | undefined { return this.entities.find((e) => e.objectId === objectId); } + /** The first entity with that name; an object without a name of its own carries its asset's name. */ findByName(name: string): Entity | undefined { return this.entities.find((e) => e.name === name); } @@ -237,10 +249,4 @@ export class World { const index = this.entities.indexOf(entity); if (index >= 0) this.entities.splice(index, 1); } - - destroy(): void { - for (const system of this.systems) system.stop?.(this); - this.systems.length = 0; - this.entities.length = 0; - } } diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index 95dc70f4..5262861b 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -42,6 +42,11 @@ export class BehaviorSystem implements System { this.syncInstances(world); } + stop(): void { + this.instances = []; + this.world = null; + } + /** * 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 @@ -56,7 +61,10 @@ export class BehaviorSystem implements System { if (!asset || asset.type !== 'behavior') continue; const trace: Trace = { edges: new Map(), nodes: new Map() }; const interpreter = new GraphInterpreter(asset.graph); - interpreter.trace = this.traceListener(trace, world); + interpreter.trace = { + onEdge: (id) => trace.edges.set(id, world.time), + onNode: (id) => trace.nodes.set(id, world.time), + } satisfies TraceListener; const instance = { entity, interpreter, trace }; this.instances.push(instance); this.run(instance, (i, ctx) => i.fire('OnStart', ctx), 0); @@ -117,22 +125,21 @@ export class BehaviorSystem implements System { fire('OnCollisionExit', this.collisions.exits); } - stop(): void { - this.instances = []; - this.world = null; - } - /** Live highlight data for the graph editor: merged over all instances of the asset. */ traceFor(assetId: string): Trace { const merged: Trace = { edges: new Map(), nodes: new Map() }; + const latest = (into: Map, from: Map) => { + for (const [id, t] of from) into.set(id, Math.max(t, into.get(id) ?? -Infinity)); + }; for (const instance of this.instances) { if (instance.entity.assetId !== assetId) continue; - for (const [id, t] of instance.trace.edges) merged.edges.set(id, Math.max(t, merged.edges.get(id) ?? -Infinity)); - for (const [id, t] of instance.trace.nodes) merged.nodes.set(id, Math.max(t, merged.nodes.get(id) ?? -Infinity)); + latest(merged.edges, instance.trace.edges); + latest(merged.nodes, instance.trace.nodes); } return merged; } + /** Runs graph code for one instance; an exception stops play and is logged, never thrown. */ private run(instance: Instance, body: (interpreter: GraphInterpreter, ctx: NodeContext) => void, dt: number): void { const world = this.world; if (!world) return; @@ -146,6 +153,9 @@ export class BehaviorSystem implements System { } private context(entity: Entity, world: World, dt: number): NodeContext { + // Handles arriving from the graph are the entities themselves, or test doubles with the same id. + const resolve = (handle: EntityHandle | null): Entity | undefined => + handle ? world.entities.find((e) => e === handle || e.id === handle.id) : undefined; return { self: entity, owner: entity.parent, @@ -156,16 +166,16 @@ export class BehaviorSystem implements System { isKeyDown: (key) => this.input.isDown(key), pointer: world.pointer, hitTest: (target, x, y) => { - const found = world.entities.find((e) => e === target || e.id === target.id); + const found = resolve(target); return Boolean(found && world.hitTest(found, x, y)); }, isTouching: (a, b) => { - const ea = world.entities.find((e) => e === a || e.id === a.id); - const eb = world.entities.find((e) => e === b || e.id === b.id); + const ea = resolve(a); + const eb = resolve(b); return Boolean(this.collisions && ea && eb && this.collisions.touching(world, ea, eb)); }, moveAndSlide: (target, dx, dy) => { - const found = world.entities.find((e) => e === target || e.id === target.id); + const found = resolve(target); if (!found) return { blockedX: false, blockedY: false }; if (this.collisions) return this.collisions.moveAndSlide(world, found, dx, dy); found.x += dx; @@ -176,12 +186,9 @@ export class BehaviorSystem implements System { world.log.push(message); console.log(`[behavior ${entity.name}]`, message); }, - spawn: (assetId, position, parent) => { - const parentEntity = parent ? world.entities.find((e) => e === parent || e.id === parent.id) ?? null : null; - return world.spawn(assetId, position, parentEntity); - }, - destroy: (target: EntityHandle) => { - const found = world.entities.find((e) => e === target || e.id === target.id); + spawn: (assetId, position, parent) => world.spawn(assetId, position, resolve(parent) ?? null), + destroy: (target) => { + const found = resolve(target); if (found) world.remove(found); }, stop: () => { @@ -192,11 +199,4 @@ export class BehaviorSystem implements System { }, }; } - - private traceListener(trace: Trace, world: World): TraceListener { - return { - onEdge: (id) => trace.edges.set(id, world.time), - onNode: (id) => trace.nodes.set(id, world.time), - }; - } } diff --git a/src/runtime/systems/CollisionSystem.ts b/src/runtime/systems/CollisionSystem.ts index b0ff78a3..bb8502d3 100644 --- a/src/runtime/systems/CollisionSystem.ts +++ b/src/runtime/systems/CollisionSystem.ts @@ -1,5 +1,5 @@ -import { isHandleEnabled } from '../../core/graph'; -import { type Axis, axisCorrection, colliderShape, colliderSize, isTrigger, shapesOverlap } from '../../core/properties/collider'; +import { isHandleEnabled, worldPositionOf } from '../../core/graph'; +import { axisCorrection, colliderShape, colliderSize, isTrigger, type PlaneAxis, shapesOverlap } from '../../core/properties/collider'; import type { Entity } from '../Entity'; import type { World } from '../World'; import type { System } from './System'; @@ -36,7 +36,7 @@ export class CollisionSystem implements System { const a = colliders[i]!; const b = colliders[j]!; if (bodyOf(a) === bodyOf(b)) continue; - if (this.overlapping(world, a, b)) current.set(`${a.id}|${b.id}`, { a, b }); + if (this.overlapping(a, b)) current.set(`${a.id}|${b.id}`, { a, b }); } } this.enters = []; @@ -55,15 +55,15 @@ export class CollisionSystem implements System { } /** Live test of two collider entities (world positions applied). */ - overlapping(world: World, a: Entity, b: Entity): boolean { - return shapesOverlap(colliderShape(a.props, world.worldPosition(a)), colliderShape(b.props, world.worldPosition(b))); + overlapping(a: Entity, b: Entity): boolean { + return shapesOverlap(shapeOf(a), shapeOf(b)); } /** Whether any collider of `a` overlaps any collider of `b` (see `collidersOf`). */ touching(world: World, a: Entity, b: Entity): boolean { for (const ca of collidersOf(world, a)) { for (const cb of collidersOf(world, b)) { - if (ca !== cb && this.overlapping(world, ca, cb)) return true; + if (ca !== cb && this.overlapping(ca, cb)) return true; } } return false; @@ -80,20 +80,20 @@ export class CollisionSystem implements System { */ moveAndSlide(world: World, entity: Entity, dx: number, dy: number): { blockedX: boolean; blockedY: boolean } { const own = collidersOf(world, entity).filter((c) => !isTrigger(c.props) && isHandleEnabled(c)); - const obstacles = own.length === 0 ? [] : world.entities.filter((c) => c.type === 'collider' && !isTrigger(c.props) && isHandleEnabled(c) && !this.travelsWith(c, entity)); - return { blockedX: this.slideAxis(world, entity, own, obstacles, 'x', dx), blockedY: this.slideAxis(world, entity, own, obstacles, 'y', dy) }; + const obstacles = own.length === 0 ? [] : world.entities.filter((c) => c.type === 'collider' && !isTrigger(c.props) && isHandleEnabled(c) && !travelsWith(c, entity)); + return { blockedX: this.slideAxis(entity, own, obstacles, 'x', dx), blockedY: this.slideAxis(entity, own, obstacles, 'y', dy) }; } - private slideAxis(world: World, entity: Entity, own: Entity[], obstacles: Entity[], axis: Axis, delta: number): boolean { + private slideAxis(entity: Entity, own: Entity[], obstacles: Entity[], axis: PlaneAxis, 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)))); - for (let i = 0; i < steps; i++) if (this.stepAxis(world, entity, own, obstacles, axis, delta / steps)) return true; + for (let i = 0; i < steps; i++) if (this.stepAxis(entity, own, obstacles, axis, delta / steps)) return true; return false; } /** One indivisible move along `axis`; returns whether an obstacle stopped it. */ - private stepAxis(world: World, entity: Entity, own: Entity[], obstacles: Entity[], axis: Axis, delta: number): boolean { + private stepAxis(entity: Entity, own: Entity[], obstacles: Entity[], axis: PlaneAxis, delta: number): boolean { const dir: 1 | -1 = delta > 0 ? 1 : -1; // How deep each pair already sits in the other before the step. A step may never deepen // that, but resolving it is not our job (no jolt, and leaving a wall is always possible). @@ -102,49 +102,53 @@ export class CollisionSystem implements System { const before: [Entity, Entity, number][] = []; for (const a of own) { for (const b of obstacles) { - const p = this.penetration(world, a, b, axis, dir); + const p = penetration(a, b, axis, dir); before.push([a, b, p > SKIN ? p : 0]); } } entity[axis] += delta; let correction = 0; for (const [a, b, was] of before) { - const t = this.penetration(world, a, b, axis, dir) - was; + const t = penetration(a, b, axis, dir) - was; if (t > correction) correction = t; } if (correction <= 0) return false; entity[axis] -= dir * Math.min(correction, Math.abs(delta)); return true; } +} - /** - * How far `a` would have to be pushed back against `dir` to just clear `b` (0 when they are - * apart). Once a's centre has passed b's centre along `dir` the shorter way out is forwards, - * so nothing blocks – that keeps an object that ended up inside something able to leave. - */ - private penetration(world: World, a: Entity, b: Entity, axis: Axis, dir: 1 | -1): number { - const mover = colliderShape(a.props, world.worldPosition(a)); - const obstacle = colliderShape(b.props, world.worldPosition(b)); - if (dir * (mover[axis] - obstacle[axis]) >= 0) return 0; - return axisCorrection(mover, obstacle, axis, dir); - } +function shapeOf(collider: Entity) { + return colliderShape(collider.props, worldPositionOf(collider)); +} - /** Whether `collider` belongs to `entity` or to something parented (at any depth) under it. */ - private travelsWith(collider: Entity, entity: Entity): boolean { - for (let e: Entity | null = collider; e; e = e.parent) if (e === entity) return true; - return false; - } +/** + * How far `a` would have to be pushed back against `dir` to just clear `b` (0 when they are + * apart). Once a's centre has passed b's centre along `dir` the shorter way out is forwards, + * so nothing blocks – that keeps an object that ended up inside something able to leave. + */ +function penetration(a: Entity, b: Entity, axis: PlaneAxis, dir: 1 | -1): number { + const mover = shapeOf(a); + const obstacle = shapeOf(b); + if (dir * (mover[axis] - obstacle[axis]) >= 0) return 0; + return axisCorrection(mover, obstacle, axis, dir); +} + +/** Whether `collider` belongs to `entity` or to something parented (at any depth) under it. */ +function travelsWith(collider: Entity, entity: Entity): boolean { + for (let e: Entity | null = collider; e; e = e.parent) if (e === entity) return true; + return false; } /** Longest safe step along `axis`: half of the thinnest collider involved (never below 1). */ -function stepLimit(own: Entity[], obstacles: Entity[], axis: Axis): number { +function stepLimit(own: Entity[], obstacles: Entity[], axis: PlaneAxis): number { let min = Infinity; for (const e of own) min = Math.min(min, extentOf(e, axis)); for (const e of obstacles) min = Math.min(min, extentOf(e, axis)); return Math.max(1, min / 2); } -function extentOf(collider: Entity, axis: Axis): number { +function extentOf(collider: Entity, axis: PlaneAxis): number { const size = colliderSize(collider.props); return axis === 'x' ? size.w : size.h; } diff --git a/src/runtime/systems/Input.ts b/src/runtime/systems/Input.ts index 03b52758..a9d2af11 100644 --- a/src/runtime/systems/Input.ts +++ b/src/runtime/systems/Input.ts @@ -15,6 +15,7 @@ export class Input { private readonly onKeyUp = (e: KeyboardEvent) => { if (this.down.delete(e.key)) this.released.add(e.key); }; + /** Losing the window releases every key, so nothing stays "held" behind the user's back. */ private readonly onBlur = () => { for (const key of this.down) this.released.add(key); this.down.clear(); @@ -49,6 +50,7 @@ export class Input { return this.released; } + /** Keys currently held. */ get heldKeys(): ReadonlySet { return this.down; } @@ -58,9 +60,4 @@ export class Input { this.pressed.clear(); this.released.clear(); } - - /** -1, 0 or 1 for a pair of keys. */ - axis(negative: string, positive: string): number { - return (this.isDown(positive) ? 1 : 0) - (this.isDown(negative) ? 1 : 0); - } } diff --git a/src/runtime/systems/System.ts b/src/runtime/systems/System.ts index d6670897..fcca427c 100644 --- a/src/runtime/systems/System.ts +++ b/src/runtime/systems/System.ts @@ -1,6 +1,6 @@ import type { World } from '../World'; -/** A behaviour that runs every frame over the world's entities. */ +/** A simulation step that runs every tick over the world's entities (collisions, behavior graphs, …). */ export interface System { start?(world: World): void; update(world: World, dt: number): void; diff --git a/src/ui/FocusManager.ts b/src/ui/FocusManager.ts index d9e164e9..5ffa09ff 100644 --- a/src/ui/FocusManager.ts +++ b/src/ui/FocusManager.ts @@ -1,3 +1,8 @@ +/** + * Keyboard focus lives in the canvas, not in the DOM: exactly one widget is focused at a time + * and key events reach it through this manager, which listens on `window`. The keyboard bridge + * input is the only DOM element that ever holds real focus, and its keys are routed separately. + */ import type { Container } from 'pixi.js'; import { collectFocusables, type Widget } from './Widget'; @@ -6,6 +11,8 @@ import { collectFocusables, type Widget } from './Widget'; * cycle through focusable widgets in tree order below `root`. */ export class FocusManager { + /** Global handlers for keys nobody consumed (Delete, Ctrl+Z, …). */ + readonly globalHandlers: ((event: KeyboardEvent) => boolean)[] = []; private focusedWidget: Widget | null = null; private readonly onKeyDown = (event: KeyboardEvent) => this.handleKeyDown(event); @@ -17,6 +24,7 @@ export class FocusManager { return this.focusedWidget; } + /** Moves focus; the previous widget is blurred before the new one is focused. */ focus(widget: Widget | null): void { if (widget === this.focusedWidget) return; const previous = this.focusedWidget; @@ -49,6 +57,7 @@ export class FocusManager { if (w && (w.destroyed || !isAttached(w, this.root) || !w.enabled)) this.focus(null); } + /** Focus the next (or previous) focusable widget in tree order, wrapping around. */ cycle(backwards: boolean): void { const list = collectFocusables(this.root); if (list.length === 0) return this.focus(null); @@ -59,9 +68,6 @@ export class FocusManager { this.focus(list[next] ?? null); } - /** Global handlers for keys nobody consumed (Delete, Ctrl+Z, …). */ - readonly globalHandlers: ((event: KeyboardEvent) => boolean)[] = []; - private handleKeyDown(event: KeyboardEvent): void { // Keys typed into the keyboard bridge input are forwarded by the bridge itself. if ((event.target as HTMLElement | null)?.tagName === 'INPUT') return; diff --git a/src/ui/KeyboardBridge.ts b/src/ui/KeyboardBridge.ts index b6cd1d26..b1a44633 100644 --- a/src/ui/KeyboardBridge.ts +++ b/src/ui/KeyboardBridge.ts @@ -4,6 +4,8 @@ * IME composition, the mobile on-screen keyboard, clipboard and selection semantics; the * canvas field mirrors `value` / selection from here. Nothing visible is DOM. */ + +/** A canvas text field while it is being edited; the bridge forwards the input's events to it. */ export interface TextEditTarget { /** Called whenever value or selection changed. */ onBridgeInput(value: string, selectionStart: number, selectionEnd: number): void; @@ -13,6 +15,7 @@ export interface TextEditTarget { onBridgeBlur(): void; } +/** Owns the hidden input and relays it to the single attached `TextEditTarget`. */ export class KeyboardBridge { private readonly input: HTMLInputElement; private target: TextEditTarget | null = null; @@ -41,7 +44,8 @@ export class KeyboardBridge { document.body.appendChild(input); this.input = input; - const sync = () => this.target?.onBridgeInput(input.value, input.selectionStart ?? 0, input.selectionEnd ?? 0); + const sync = () => + this.target?.onBridgeInput(input.value, input.selectionStart ?? 0, input.selectionEnd ?? 0); input.addEventListener('input', sync); input.addEventListener('keyup', sync); document.addEventListener('selectionchange', () => { @@ -61,10 +65,6 @@ export class KeyboardBridge { }); } - get activeTarget(): TextEditTarget | null { - return this.target; - } - /** Start editing: mirror the field's value into the input and grab DOM focus. */ attach(target: TextEditTarget, value: string, selectionStart = value.length, selectionEnd = selectionStart): void { this.suppressBlur = true; @@ -81,15 +81,21 @@ export class KeyboardBridge { this.input.style.top = `${Math.max(0, y)}px`; } + /** + * Push a value/selection the canvas side changed (arrow stepping, caret placement) into the + * input, so the next keystroke edits the same text the user sees. + */ setValue(value: string, selectionStart = value.length, selectionEnd = selectionStart): void { this.input.value = value; if (document.activeElement === this.input) this.input.setSelectionRange(selectionStart, selectionEnd); } + /** Selects the whole input; the field learns about it through the usual `onBridgeInput` sync. */ selectAll(): void { this.input.select(); } + /** Stop editing for `target`. Ignored when another field attached meanwhile, so a late blur cannot steal it. */ detach(target: TextEditTarget): void { if (this.target !== target) return; this.target = null; diff --git a/src/ui/Theme.ts b/src/ui/Theme.ts index eabb46a3..bdb8f882 100644 --- a/src/ui/Theme.ts +++ b/src/ui/Theme.ts @@ -1,6 +1,10 @@ +/** + * Design tokens for the canvas UI. Widgets take every color and text style from here and + * the sizes they share (row/control heights, spacing, the few named control sizes); sizes + * that only one widget uses may stay local to it. + */ import { TextStyle } from 'pixi.js'; -/** Design tokens for the canvas UI. Widgets never hard-code colors or sizes. */ export const Theme = { font: 'Inter, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', monoFont: '"JetBrains Mono", Consolas, "Courier New", monospace', @@ -11,6 +15,16 @@ export const Theme = { padding: 8, gap: 6, radius: 4, + /** Horizontal inset of text inside text fields. */ + fieldPadding: 6, + /** Side length of the checkbox square (centred inside the control height). */ + checkboxSize: 16, + /** Side length of the small color marker in list rows. */ + swatchSize: 12, + /** Width of the scrollbar thumb. */ + scrollbarWidth: 6, + /** Horizontal indent per nesting level in tree lists. */ + treeIndent: 14, colors: { background: 0x1b1d22, panel: 0x24272e, @@ -61,6 +75,7 @@ export const Theme = { const styleCache = new Map(); +/** Named text styles; every piece of text in the UI uses one of these. */ export type TextStyleToken = 'body' | 'bodyBold' | 'muted' | 'small' | 'title' | 'mono' | 'accent'; /** Shared TextStyle instances so Pixi can cache glyph measurements per token. */ @@ -82,7 +97,13 @@ export function textStyle(token: TextStyleToken = 'body'): TextStyle { style = new TextStyle({ ...base, fontSize: Theme.smallFontSize, fill: Theme.colors.textMuted }); break; case 'title': - style = new TextStyle({ ...base, fontSize: Theme.smallFontSize, fontWeight: '700', letterSpacing: 0.5, fill: Theme.colors.textMuted }); + style = new TextStyle({ + ...base, + fontSize: Theme.smallFontSize, + fontWeight: '700', + letterSpacing: 0.5, + fill: Theme.colors.textMuted, + }); break; case 'mono': style = new TextStyle({ ...base, fontFamily: Theme.monoFont }); diff --git a/src/ui/UIContext.ts b/src/ui/UIContext.ts index e6326d71..8c135bbd 100644 --- a/src/ui/UIContext.ts +++ b/src/ui/UIContext.ts @@ -16,17 +16,23 @@ export interface UIContext { let context: UIContext | null = null; +/** Creates the shared services. `root` is where Tab cycling searches; `overlay` must be drawn above it. */ export function initUI(app: Application, root: Container, overlay: Container): UIContext { context = { app, focus: new FocusManager(root), keyboard: new KeyboardBridge(), overlay }; // Capture phase on the stage runs before any widget handler: a press outside the focused // widget blurs it (committing text edits) before the press can change selection. - app.stage.addEventListener('pointerdown', (e) => context?.focus.blurIfOutside((e as FederatedPointerEvent).target as Container), { capture: true }); + app.stage.addEventListener( + 'pointerdown', + (e) => context?.focus.blurIfOutside((e as FederatedPointerEvent).target as Container), + { capture: true }, + ); // Clicking the canvas must not steal DOM focus from the keyboard bridge input. app.canvas.addEventListener('mousedown', (e) => e.preventDefault()); app.canvas.addEventListener('contextmenu', (e) => e.preventDefault()); return context; } +/** The shared services; throws when called before `initUI()`. */ export function ui(): UIContext { if (!context) throw new Error('UI not initialised – call initUI() first'); return context; diff --git a/src/ui/Widget.ts b/src/ui/Widget.ts index 5c186b46..cfae3aac 100644 --- a/src/ui/Widget.ts +++ b/src/ui/Widget.ts @@ -1,3 +1,8 @@ +/** + * Root of the canvas widget toolkit. Everything visible is a Pixi container; there is no DOM + * layout. Sizes are pushed top-down (`resize()`), preferred sizes are pulled bottom-up + * (`measure()`), and repaints are coalesced per frame through `invalidate()`. + */ import { Container, Rectangle } from 'pixi.js'; export interface Size { @@ -24,6 +29,8 @@ export abstract class Widget extends Container { focusable = false; focused = false; enabled = true; + /** Maintained by `trackHover()`; read in `redraw()` to paint the hover state. */ + protected hovered = false; private dirty = false; @@ -37,6 +44,10 @@ export abstract class Widget extends Container { return { w: this.minWidth, h: this.minHeight }; } + /** + * Sets the size, re-runs `layout()` only when it changed and always repaints. Sizes are + * pushed down by the parent; a widget never resizes itself. + */ resize(w: number, h: number): void { const changed = w !== this.w || h !== this.h; this.w = w; @@ -62,6 +73,7 @@ export abstract class Widget extends Container { }); } + /** Disabled widgets receive no pointer events, drop out of Tab cycling and render at half alpha. */ setEnabled(enabled: boolean): void { if (this.enabled === enabled) return; this.enabled = enabled; @@ -85,14 +97,35 @@ export abstract class Widget extends Container { onKeyDown(_event: KeyboardEvent): boolean { return false; } + + /** + * Keeps `hovered` in sync with pointer over/out and repaints. `onOut` runs when the pointer + * leaves, before the repaint (e.g. to drop a pressed state that can no longer complete). + */ + protected trackHover(onOut?: () => void): void { + this.on('pointerover', () => { + this.hovered = true; + this.invalidate(); + }); + this.on('pointerout', () => { + this.hovered = false; + onOut?.(); + this.invalidate(); + }); + } + + /** + * Runs `fn` when a pointer pressed on this widget is released, whether over the widget + * (`pointerup`) or anywhere else (`pointerupoutside`). Use it to end drags and scrubs. + */ + protected onPointerRelease(fn: () => void): void { + this.on('pointerup', fn); + this.on('pointerupoutside', fn); + } } /** A concrete, empty widget: a sized container for custom drawing or manual child placement. */ -export class Box extends Widget { - override measure(): Size { - return { w: this.minWidth, h: this.minHeight }; - } -} +export class Box extends Widget {} /** Depth-first collection of focusable, visible widgets in tree order. */ export function collectFocusables(root: Container, out: Widget[] = []): Widget[] { diff --git a/src/ui/color.ts b/src/ui/color.ts index 3c6597fa..7767629f 100644 --- a/src/ui/color.ts +++ b/src/ui/color.ts @@ -6,6 +6,7 @@ export interface HSV { v: number; // 0..1 } +/** Lower-case "#rrggbb"; bits above 24 are dropped. */ export function toHex(color: number): string { return '#' + (color & 0xffffff).toString(16).padStart(6, '0'); } @@ -21,6 +22,7 @@ export function parseHex(text: string): number | null { return null; } +/** Greys report hue 0 and saturation 0, so a round trip through `hsvToRgb` is exact. */ export function rgbToHsv(color: number): HSV { const r = ((color >> 16) & 0xff) / 255; const g = ((color >> 8) & 0xff) / 255; @@ -39,6 +41,7 @@ export function rgbToHsv(color: number): HSV { return { h, s: max === 0 ? 0 : d / max, v: max }; } +/** Channels are rounded to whole bytes; `h` must be in [0, 360). */ export function hsvToRgb({ h, s, v }: HSV): number { const c = v * s; const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); @@ -54,10 +57,33 @@ export function hsvToRgb({ h, s, v }: HSV): number { return (to(r) << 16) | (to(g) << 8) | to(b); } -/** Perceived brightness 0..1, used to pick readable text on a swatch. */ -export function luminance(color: number): number { - const r = (color >> 16) & 0xff; - const g = (color >> 8) & 0xff; - const b = color & 0xff; - return (0.299 * r + 0.587 * g + 0.114 * b) / 255; +// --- gradient rasterisation ------------------------------------------------------------------ +// Pixi has no 2D gradients worth the trouble, so pickers paint these into a 2D canvas once and +// show the result as a texture. + +/** Paints the full hue spectrum left to right over `width` × `height` at the context's origin. */ +export function paintHueBar(ctx: CanvasRenderingContext2D, width: number, height: number): void { + const grad = ctx.createLinearGradient(0, 0, width, 0); + for (let i = 0; i <= 6; i++) grad.addColorStop(i / 6, `hsl(${i * 60}, 100%, 50%)`); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, width, height); +} + +/** + * Paints the saturation (x) / value (y) square for `hue`: white on the left fading to the pure + * hue on the right, with black fading in towards the bottom. + */ +export function paintSvSquare(ctx: CanvasRenderingContext2D, size: number, hue: number): void { + ctx.fillStyle = `hsl(${hue}, 100%, 50%)`; + ctx.fillRect(0, 0, size, size); + const white = ctx.createLinearGradient(0, 0, size, 0); + white.addColorStop(0, 'rgba(255,255,255,1)'); + white.addColorStop(1, 'rgba(255,255,255,0)'); + ctx.fillStyle = white; + ctx.fillRect(0, 0, size, size); + const black = ctx.createLinearGradient(0, 0, 0, size); + black.addColorStop(0, 'rgba(0,0,0,0)'); + black.addColorStop(1, 'rgba(0,0,0,1)'); + ctx.fillStyle = black; + ctx.fillRect(0, 0, size, size); } diff --git a/src/ui/draw.ts b/src/ui/draw.ts new file mode 100644 index 00000000..d718ca93 --- /dev/null +++ b/src/ui/draw.ts @@ -0,0 +1,21 @@ +import type { Graphics } from 'pixi.js'; +import { Theme } from './Theme'; + +export interface ControlBoxOptions { + fill: number; + border: number; + /** Corner radius; defaults to `Theme.radius`. */ + radius?: number; + x?: number; + y?: number; +} + +/** + * The shared look of every control: a rounded fill with a 1 px border. The stroke is inset by + * half a pixel so it lands on whole device pixels and stays crisp instead of blurring over two. + */ +export function drawControlBox(g: Graphics, w: number, h: number, options: ControlBoxOptions): void { + const { fill, border, radius = Theme.radius, x = 0, y = 0 } = options; + g.roundRect(x, y, w, h, radius).fill(fill); + g.roundRect(x + 0.5, y + 0.5, w - 1, h - 1, radius).stroke({ width: 1, color: border }); +} diff --git a/src/ui/index.ts b/src/ui/index.ts index 87e86167..9478e482 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -1,7 +1,9 @@ export { Widget, Box, type Size } from './Widget'; -export { HStack, VStack, Spacer, Stack } from './layout/Stack'; -export { Theme, textStyle } from './Theme'; +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, type ControlBoxOptions } from './draw'; +export { clamp, centreY } from './math'; export { Label } from './widgets/Label'; export { Button } from './widgets/Button'; export { Checkbox } from './widgets/Checkbox'; @@ -9,8 +11,11 @@ export { Panel } from './widgets/Panel'; export { Splitter } from './widgets/Splitter'; export { ScrollContainer } from './widgets/ScrollContainer'; export { ListView, type ListItem, type PickModifiers } from './widgets/ListView'; -export { Popup, Menu, Dialog, type MenuItem } from './widgets/Popup'; +export { Popup, type PopupOptions } from './widgets/Popup'; +export { Menu, type MenuItem } from './widgets/Menu'; +export { Dialog, type DialogButton } from './widgets/Dialog'; export { TextField } from './widgets/TextField'; export { NumberField } from './widgets/NumberField'; -export { ColorField, ColorPicker } from './widgets/ColorField'; +export { ColorField } from './widgets/ColorField'; +export { ColorPicker } from './widgets/ColorPicker'; export * from './color'; diff --git a/src/ui/layout/Stack.ts b/src/ui/layout/Stack.ts index ba28991a..719029a1 100644 --- a/src/ui/layout/Stack.ts +++ b/src/ui/layout/Stack.ts @@ -24,10 +24,12 @@ export abstract class Stack extends Widget { this.align = options.align ?? 'stretch'; } + /** The children the layout distributes space among: visible widgets, in child order. */ get items(): Widget[] { return this.children.filter((c): c is Widget => c instanceof Widget && c.visible); } + /** Appends `widget` with the given flex share. Does not lay out; call `relayout()` once the stack is sized. */ add(widget: T, flex = 0): T { widget.flex = flex; this.addChild(widget); @@ -91,12 +93,14 @@ export abstract class Stack extends Widget { } } +/** Column layout. */ export class VStack extends Stack { constructor(options: StackOptions = {}) { super('y', options); } } +/** Row layout. */ export class HStack extends Stack { constructor(options: StackOptions = {}) { super('x', options); diff --git a/src/ui/math.ts b/src/ui/math.ts new file mode 100644 index 00000000..59522522 --- /dev/null +++ b/src/ui/math.ts @@ -0,0 +1,9 @@ +/** Clamps `value` into `[lo, hi]`. When `lo > hi` (e.g. a popup wider than the screen) `lo` wins. */ +export function clamp(value: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, value)); +} + +/** Whole-pixel top offset that centres an item of `itemHeight` inside `containerHeight`. */ +export function centreY(containerHeight: number, itemHeight: number): number { + return Math.round((containerHeight - itemHeight) / 2); +} diff --git a/src/ui/widgets/Button.ts b/src/ui/widgets/Button.ts index e08fa815..3183ad1e 100644 --- a/src/ui/widgets/Button.ts +++ b/src/ui/widgets/Button.ts @@ -1,4 +1,6 @@ import { type FederatedPointerEvent, Graphics, Text, CanvasTextMetrics } from 'pixi.js'; +import { drawControlBox } from '../draw'; +import { centreY } from '../math'; import { Theme, textStyle } from '../Theme'; import { type Size, Widget } from '../Widget'; @@ -10,11 +12,12 @@ export interface ButtonOptions { onClick?: () => void; } +/** Push button; Enter/Space activate it while focused. Clicks fire only for the primary pointer button. */ export class Button extends Widget { private readonly bg = new Graphics(); private readonly labelText: Text; - private hovered = false; private pressed = false; + /** Caller-owned toggle state: renders like a primary button but is not flipped by clicks. */ toggled = false; variant: NonNullable; onClick?: () => void; @@ -30,8 +33,8 @@ export class Button extends Widget { this.labelText = new Text({ text, style: textStyle(this.variant === 'primary' ? 'accent' : 'body') }); this.addChild(this.bg, this.labelText); - this.on('pointerover', () => { this.hovered = true; this.invalidate(); }); - this.on('pointerout', () => { this.hovered = false; this.pressed = false; this.invalidate(); }); + // Leaving while pressed cancels the click: a release elsewhere must not activate. + this.trackHover(() => { this.pressed = false; }); this.on('pointerdown', (e: FederatedPointerEvent) => { if (e.button !== 0) return; this.pressed = true; @@ -43,13 +46,17 @@ export class Button extends Widget { this.invalidate(); this.onClick?.(); }); - this.on('pointerupoutside', () => { this.pressed = false; this.invalidate(); }); + this.on('pointerupoutside', () => { + this.pressed = false; + this.invalidate(); + }); } get text(): string { return this.labelText.text; } + /** Repaints only; the parent must relayout when the measured width matters. */ setText(text: string): void { this.labelText.text = text; this.invalidate(); @@ -63,7 +70,10 @@ export class Button extends Widget { override measure(): Size { const metrics = CanvasTextMetrics.measureText(this.labelText.text, this.labelText.style); - return { w: Math.max(this.minWidth, Math.ceil(metrics.width) + Theme.padding * 2), h: Math.max(this.minHeight, Theme.controlHeight) }; + return { + w: Math.max(this.minWidth, Math.ceil(metrics.width) + Theme.padding * 2), + h: Math.max(this.minHeight, Theme.controlHeight), + }; } override onKeyDown(event: KeyboardEvent): boolean { @@ -76,21 +86,28 @@ export class Button extends Widget { override redraw(): void { const c = Theme.colors; + const flat = this.variant === 'default' || this.variant === 'ghost'; let fill: number | null = c.control; let border: number | null = c.border; if (this.variant === 'primary') { fill = c.accent; border = null; } if (this.variant === 'danger') { fill = c.danger; border = null; } if (this.variant === 'ghost') { fill = null; border = null; } if (this.toggled) { fill = c.accent; border = null; } - if (this.hovered && !this.toggled) fill = this.variant === 'default' || this.variant === 'ghost' ? c.controlHover : fill; - if (this.pressed) fill = this.variant === 'default' || this.variant === 'ghost' ? c.controlActive : fill; + if (this.hovered && !this.toggled && flat) fill = c.controlHover; + if (this.pressed && flat) fill = c.controlActive; + const stroke = this.focused ? c.accent : border; this.bg.clear(); - if (fill !== null) this.bg.roundRect(0, 0, this.w, this.h, Theme.radius).fill(fill); - if (border !== null || this.focused) { - this.bg.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: 1, color: this.focused ? c.accent : border ?? c.border }); + if (fill !== null && stroke !== null) { + drawControlBox(this.bg, this.w, this.h, { fill, border: stroke }); + } else if (fill !== null) { + this.bg.roundRect(0, 0, this.w, this.h, Theme.radius).fill(fill); + } else if (stroke !== null) { + this.bg.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: 1, color: stroke }); } - this.labelText.style = textStyle(this.toggled || this.variant === 'primary' || this.variant === 'danger' ? 'accent' : 'body'); - this.labelText.position.set(Math.round((this.w - this.labelText.width) / 2), Math.round((this.h - this.labelText.height) / 2)); + const onAccent = this.toggled || this.variant === 'primary' || this.variant === 'danger'; + this.labelText.style = textStyle(onAccent ? 'accent' : 'body'); + const label = this.labelText; + label.position.set(Math.round((this.w - label.width) / 2), centreY(this.h, label.height)); } } diff --git a/src/ui/widgets/Checkbox.ts b/src/ui/widgets/Checkbox.ts index 40e760cd..8af20556 100644 --- a/src/ui/widgets/Checkbox.ts +++ b/src/ui/widgets/Checkbox.ts @@ -1,11 +1,12 @@ import { Graphics } from 'pixi.js'; +import { drawControlBox } from '../draw'; +import { centreY } from '../math'; import { Theme } from '../Theme'; -import { type Size, Widget } from '../Widget'; +import { Widget } from '../Widget'; /** A square toggle used for boolean properties. */ export class Checkbox extends Widget { private readonly box = new Graphics(); - private hovered = false; onChange?: (checked: boolean) => void; constructor(public checked = false) { @@ -15,27 +16,24 @@ export class Checkbox extends Widget { this.minWidth = Theme.controlHeight; this.minHeight = Theme.controlHeight; this.addChild(this.box); - this.on('pointerover', () => { this.hovered = true; this.invalidate(); }); - this.on('pointerout', () => { this.hovered = false; this.invalidate(); }); + this.trackHover(); this.on('pointertap', () => this.toggle()); } + /** Model → view update: does not fire `onChange`. */ setChecked(checked: boolean): void { if (this.checked === checked) return; this.checked = checked; this.invalidate(); } + /** User-driven flip that fires `onChange`. */ toggle(): void { this.checked = !this.checked; this.invalidate(); this.onChange?.(this.checked); } - override measure(): Size { - return { w: this.minWidth, h: this.minHeight }; - } - override onKeyDown(event: KeyboardEvent): boolean { if (event.key === ' ' || event.key === 'Enter') { this.toggle(); @@ -46,14 +44,17 @@ export class Checkbox extends Widget { override redraw(): void { const c = Theme.colors; - const size = 16; - const x = 0; - const y = Math.round((this.h - size) / 2); + const size = Theme.checkboxSize; + const y = centreY(this.h, size); this.box.clear(); - this.box.roundRect(x, y, size, size, 3).fill(this.checked ? c.accent : this.hovered ? c.controlHover : c.control); - this.box.roundRect(x + 0.5, y + 0.5, size - 1, size - 1, 3).stroke({ width: 1, color: this.focused ? c.accent : c.border }); + drawControlBox(this.box, size, size, { + fill: this.checked ? c.accent : this.hovered ? c.controlHover : c.control, + border: this.focused ? c.accent : c.border, + radius: 3, + y, + }); if (this.checked) { - this.box.moveTo(x + 4, y + 8.5).lineTo(x + 7, y + 11.5).lineTo(x + 12.5, y + 5).stroke({ width: 2, color: c.accentText }); + this.box.moveTo(4, y + 8.5).lineTo(7, y + 11.5).lineTo(12.5, y + 5).stroke({ width: 2, color: c.accentText }); } } } diff --git a/src/ui/widgets/ColorField.ts b/src/ui/widgets/ColorField.ts index 0b11def3..2cc612e7 100644 --- a/src/ui/widgets/ColorField.ts +++ b/src/ui/widgets/ColorField.ts @@ -1,9 +1,10 @@ -import { CanvasSource, type FederatedPointerEvent, Graphics, Sprite, Texture } from 'pixi.js'; -import { hsvToRgb, parseHex, rgbToHsv, toHex, type HSV } from '../color'; +import { Graphics } from 'pixi.js'; +import { parseHex, toHex } from '../color'; +import { drawControlBox } from '../draw'; import { HStack } from '../layout/Stack'; import { Theme } from '../Theme'; -import { Box, type Size, Widget } from '../Widget'; -import { Popup } from './Popup'; +import { Widget } from '../Widget'; +import { ColorPicker } from './ColorPicker'; import { TextField } from './TextField'; export interface ColorFieldOptions { @@ -29,8 +30,7 @@ export class ColorField extends Widget { this.minHeight = Theme.controlHeight; this.minWidth = 120; this.row = new HStack({ gap: Theme.gap, align: 'stretch' }); - const swatchHolder = new SwatchButton(this.swatch, () => this.openPicker()); - this.row.add(swatchHolder); + this.row.add(new SwatchButton(this.swatch, () => this.openPicker())); this.hex = new TextField(toHex(color), { style: 'mono', onInput: (text) => { @@ -57,10 +57,12 @@ export class ColorField extends Widget { this.addChild(this.row); } + /** The last valid color: an invalid hex being typed does not change it. */ get value(): number { return this.color; } + /** Model → view update: refreshes the hex text and an open picker without firing callbacks. */ setValue(color: number): void { if (color === this.color) return; this.color = color; @@ -70,7 +72,6 @@ export class ColorField extends Widget { } private openPicker(): void { - const global = this.getGlobalPosition(); const picker = new ColorPicker(this.color); this.picker = picker; picker.onInput = (c) => { @@ -83,11 +84,7 @@ export class ColorField extends Widget { this.picker = null; if (picker.startColor !== this.color) this.onChange?.(this.color); }; - picker.open(global.x, global.y + this.h + 4); - } - - override measure(): Size { - return { w: this.minWidth, h: this.minHeight }; + picker.openBelow(this); } override layout(): void { @@ -96,134 +93,18 @@ export class ColorField extends Widget { override redraw(): void { this.swatch.clear(); - const size = this.h; - this.swatch.roundRect(0, 0, size, size, Theme.radius).fill(this.color); - this.swatch.roundRect(0.5, 0.5, size - 1, size - 1, Theme.radius).stroke({ width: 1, color: Theme.colors.border }); + drawControlBox(this.swatch, this.h, this.h, { fill: this.color, border: Theme.colors.border }); } } +/** Square tap target for the swatch. The field draws the swatch; this only reports taps. */ class SwatchButton extends Widget { - constructor(graphics: Graphics, onClick: () => void) { + constructor(graphics: Graphics, onTap: () => void) { super(); this.cursor = 'pointer'; + this.minWidth = Theme.controlHeight; + this.minHeight = Theme.controlHeight; this.addChild(graphics); - this.on('pointertap', onClick); - } - - override measure(): Size { - return { w: Theme.controlHeight, h: Theme.controlHeight }; - } -} - -const SV_SIZE = 168; -const HUE_HEIGHT = 14; - -/** - * HSV picker: saturation/value square + hue bar. Gradients are rasterised once into a - * small 2D canvas and shown as textures (Pixi has no 2D gradients worth the trouble). - */ -export class ColorPicker extends Popup { - readonly startColor: number; - private hsv: HSV; - private readonly svCanvas = document.createElement('canvas'); - private readonly svSource: CanvasSource; - private readonly svSprite: Sprite; - private readonly hueSprite: Sprite; - private readonly marks = new Graphics(); - private readonly body = new Box(); - private dragging: 'sv' | 'hue' | null = null; - onInput?: (color: number) => void; - - constructor(color: number) { - super({ padding: Theme.padding }); - this.startColor = color; - this.hsv = rgbToHsv(color); - - this.svCanvas.width = SV_SIZE; - this.svCanvas.height = SV_SIZE; - this.svSource = new CanvasSource({ resource: this.svCanvas }); - this.svSprite = new Sprite(new Texture({ source: this.svSource })); - - const hueCanvas = document.createElement('canvas'); - hueCanvas.width = SV_SIZE; - hueCanvas.height = HUE_HEIGHT; - const ctx = hueCanvas.getContext('2d')!; - const grad = ctx.createLinearGradient(0, 0, SV_SIZE, 0); - for (let i = 0; i <= 6; i++) grad.addColorStop(i / 6, `hsl(${i * 60}, 100%, 50%)`); - ctx.fillStyle = grad; - ctx.fillRect(0, 0, SV_SIZE, HUE_HEIGHT); - this.hueSprite = new Sprite(Texture.from(hueCanvas)); - this.hueSprite.position.set(0, SV_SIZE + Theme.gap); - - this.body.minWidth = SV_SIZE; - this.body.minHeight = SV_SIZE + Theme.gap + HUE_HEIGHT; - this.body.addChild(this.svSprite, this.hueSprite, this.marks); - this.body.on('pointerdown', (e: FederatedPointerEvent) => { - const local = this.body.toLocal(e.global); - this.dragging = local.y <= SV_SIZE ? 'sv' : 'hue'; - this.applyPointer(local.x, local.y); - }); - this.body.on('globalpointermove', (e: FederatedPointerEvent) => { - if (!this.dragging) return; - const local = this.body.toLocal(e.global); - this.applyPointer(local.x, local.y); - }); - const stop = () => { this.dragging = null; }; - this.body.on('pointerup', stop); - this.body.on('pointerupoutside', stop); - this.content.add(this.body); - this.renderSv(); - } - - setColor(color: number): void { - this.hsv = rgbToHsv(color); - this.renderSv(); - this.drawMarks(); - } - - private applyPointer(x: number, y: number): void { - if (this.dragging === 'sv') { - this.hsv.s = Math.max(0, Math.min(1, x / SV_SIZE)); - this.hsv.v = 1 - Math.max(0, Math.min(1, y / SV_SIZE)); - } else { - this.hsv.h = Math.max(0, Math.min(359.99, (x / SV_SIZE) * 360)); - this.renderSv(); - } - this.drawMarks(); - this.onInput?.(hsvToRgb(this.hsv)); - } - - private renderSv(): void { - const ctx = this.svCanvas.getContext('2d')!; - ctx.fillStyle = `hsl(${this.hsv.h}, 100%, 50%)`; - ctx.fillRect(0, 0, SV_SIZE, SV_SIZE); - const white = ctx.createLinearGradient(0, 0, SV_SIZE, 0); - white.addColorStop(0, 'rgba(255,255,255,1)'); - white.addColorStop(1, 'rgba(255,255,255,0)'); - ctx.fillStyle = white; - ctx.fillRect(0, 0, SV_SIZE, SV_SIZE); - const black = ctx.createLinearGradient(0, 0, 0, SV_SIZE); - black.addColorStop(0, 'rgba(0,0,0,0)'); - black.addColorStop(1, 'rgba(0,0,0,1)'); - ctx.fillStyle = black; - ctx.fillRect(0, 0, SV_SIZE, SV_SIZE); - this.svSource.update(); - } - - private drawMarks(): void { - const { h, s, v } = this.hsv; - this.marks.clear(); - const x = s * SV_SIZE; - const y = (1 - v) * SV_SIZE; - this.marks.circle(x, y, 5).stroke({ width: 2, color: 0xffffff }); - this.marks.circle(x, y, 6).stroke({ width: 1, color: 0x000000, alpha: 0.6 }); - const hx = (h / 360) * SV_SIZE; - const hy = SV_SIZE + Theme.gap; - this.marks.rect(hx - 2, hy - 1, 4, HUE_HEIGHT + 2).stroke({ width: 2, color: 0xffffff }); - } - - override layout(): void { - super.layout(); - this.drawMarks(); + this.on('pointertap', onTap); } } diff --git a/src/ui/widgets/ColorPicker.ts b/src/ui/widgets/ColorPicker.ts new file mode 100644 index 00000000..b03551e4 --- /dev/null +++ b/src/ui/widgets/ColorPicker.ts @@ -0,0 +1,102 @@ +import { CanvasSource, type FederatedPointerEvent, Graphics, Sprite, Texture } from 'pixi.js'; +import { hsvToRgb, paintHueBar, paintSvSquare, rgbToHsv, type HSV } from '../color'; +import { clamp } from '../math'; +import { Theme } from '../Theme'; +import { Box } from '../Widget'; +import { Popup } from './Popup'; + +const SV_SIZE = 168; +const HUE_HEIGHT = 14; + +/** + * HSV picker popup: a saturation/value square above a hue bar, both rasterised into 2D + * canvases and shown as textures. Emits `onInput` while dragging; the owner compares against + * `startColor` on close to decide whether a change happened. + */ +export class ColorPicker extends Popup { + readonly startColor: number; + private hsv: HSV; + private readonly svCanvas = document.createElement('canvas'); + private readonly svSource: CanvasSource; + private readonly marks = new Graphics(); + private readonly body = new Box(); + private dragging: 'sv' | 'hue' | null = null; + onInput?: (color: number) => void; + + constructor(color: number) { + super({ padding: Theme.padding }); + this.startColor = color; + this.hsv = rgbToHsv(color); + + this.svCanvas.width = SV_SIZE; + this.svCanvas.height = SV_SIZE; + this.svSource = new CanvasSource({ resource: this.svCanvas }); + const svSprite = new Sprite(new Texture({ source: this.svSource })); + + const hueCanvas = document.createElement('canvas'); + hueCanvas.width = SV_SIZE; + hueCanvas.height = HUE_HEIGHT; + paintHueBar(hueCanvas.getContext('2d')!, SV_SIZE, HUE_HEIGHT); + const hueSprite = new Sprite(Texture.from(hueCanvas)); + hueSprite.position.set(0, SV_SIZE + Theme.gap); + + this.body.minWidth = SV_SIZE; + this.body.minHeight = SV_SIZE + Theme.gap + HUE_HEIGHT; + this.body.addChild(svSprite, hueSprite, this.marks); + this.body.on('pointerdown', (e: FederatedPointerEvent) => { + const local = this.body.toLocal(e.global); + this.dragging = local.y <= SV_SIZE ? 'sv' : 'hue'; + this.applyPointer(local.x, local.y); + }); + this.body.on('globalpointermove', (e: FederatedPointerEvent) => { + if (!this.dragging) return; + const local = this.body.toLocal(e.global); + this.applyPointer(local.x, local.y); + }); + // The press starts on the body; its release bubbles up to the popup either way. + this.onPointerRelease(() => { this.dragging = null; }); + this.content.add(this.body); + this.renderSv(); + } + + /** Model → view update while open (the field's hex input changed); does not fire `onInput`. */ + setColor(color: number): void { + this.hsv = rgbToHsv(color); + this.renderSv(); + this.drawMarks(); + } + + private applyPointer(x: number, y: number): void { + if (this.dragging === 'sv') { + this.hsv.s = clamp(x / SV_SIZE, 0, 1); + this.hsv.v = 1 - clamp(y / SV_SIZE, 0, 1); + } else { + this.hsv.h = clamp((x / SV_SIZE) * 360, 0, 359.99); // hsvToRgb needs h < 360 + this.renderSv(); + } + this.drawMarks(); + this.onInput?.(hsvToRgb(this.hsv)); + } + + private renderSv(): void { + paintSvSquare(this.svCanvas.getContext('2d')!, SV_SIZE, this.hsv.h); + this.svSource.update(); + } + + private drawMarks(): void { + const { h, s, v } = this.hsv; + this.marks.clear(); + const x = s * SV_SIZE; + const y = (1 - v) * SV_SIZE; + this.marks.circle(x, y, 5).stroke({ width: 2, color: 0xffffff }); + this.marks.circle(x, y, 6).stroke({ width: 1, color: 0x000000, alpha: 0.6 }); + const hx = (h / 360) * SV_SIZE; + const hy = SV_SIZE + Theme.gap; + this.marks.rect(hx - 2, hy - 1, 4, HUE_HEIGHT + 2).stroke({ width: 2, color: 0xffffff }); + } + + override layout(): void { + super.layout(); + this.drawMarks(); + } +} diff --git a/src/ui/widgets/Dialog.ts b/src/ui/widgets/Dialog.ts new file mode 100644 index 00000000..42677892 --- /dev/null +++ b/src/ui/widgets/Dialog.ts @@ -0,0 +1,31 @@ +import { HStack, Spacer } from '../layout/Stack'; +import { Theme } from '../Theme'; +import { Button } from './Button'; +import { Label } from './Label'; +import { Popup } from './Popup'; + +export interface DialogButton { + label: string; + primary?: boolean; + danger?: boolean; + /** Runs after the dialog closed; omit for a plain "Cancel". */ + onClick?: () => void; +} + +/** A modal box with title, message and a right-aligned button row. Any button closes it. */ +export class Dialog extends Popup { + constructor(title: string, message: string, buttons: DialogButton[]) { + super({ modal: true, padding: Theme.padding * 2, gap: Theme.padding }); + this.minWidth = 320; + this.content.add(new Label(title, { style: 'bodyBold' })); + if (message) this.content.add(new Label(message, { style: 'muted' })); + const row = new HStack({ gap: Theme.gap, align: 'center' }); + row.add(new Spacer(), 1); // pushes the buttons to the right + for (const b of buttons) { + const button = new Button(b.label, { variant: b.primary ? 'primary' : b.danger ? 'danger' : 'default' }); + button.onClick = this.closeThen(b.onClick); + row.add(button); + } + this.content.add(row); + } +} diff --git a/src/ui/widgets/Label.ts b/src/ui/widgets/Label.ts index fe25152b..440307c0 100644 --- a/src/ui/widgets/Label.ts +++ b/src/ui/widgets/Label.ts @@ -1,4 +1,5 @@ import { CanvasTextMetrics, Text } from 'pixi.js'; +import { centreY } from '../math'; import { Theme, textStyle, type TextStyleToken } from '../Theme'; import { type Size, Widget } from '../Widget'; @@ -27,6 +28,7 @@ export class Label extends Widget { this.minHeight = Theme.rowHeight; } + /** The full text, not the truncated version on screen. */ get text(): string { return this.fullText; } @@ -37,6 +39,7 @@ export class Label extends Widget { this.invalidate(); } + /** Repaints only; the measured size may change, so the parent must relayout when that matters. */ setStyle(token: TextStyleToken): void { this.textNode.style = textStyle(token); this.invalidate(); @@ -44,7 +47,10 @@ export class Label extends Widget { override measure(): Size { const metrics = CanvasTextMetrics.measureText(this.fullText, this.textNode.style); - return { w: Math.max(this.minWidth, Math.ceil(metrics.width) + this.padding * 2), h: Math.max(this.minHeight, Math.ceil(metrics.height)) }; + return { + w: Math.max(this.minWidth, Math.ceil(metrics.width) + this.padding * 2), + h: Math.max(this.minHeight, Math.ceil(metrics.height)), + }; } override redraw(): void { @@ -54,7 +60,7 @@ export class Label extends Widget { let x = this.padding; if (this.align === 'center') x = (this.w - tw) / 2; else if (this.align === 'right') x = this.w - this.padding - tw; - this.textNode.position.set(Math.round(x), Math.round((this.h - this.textNode.height) / 2)); + this.textNode.position.set(Math.round(x), centreY(this.h, this.textNode.height)); } } diff --git a/src/ui/widgets/ListRow.ts b/src/ui/widgets/ListRow.ts new file mode 100644 index 00000000..71958d64 --- /dev/null +++ b/src/ui/widgets/ListRow.ts @@ -0,0 +1,98 @@ +import { Graphics, Text } from 'pixi.js'; +import { drawControlBox } from '../draw'; +import { centreY } from '../math'; +import { Theme, textStyle } from '../Theme'; +import { Widget } from '../Widget'; +import { truncate } from './Label'; +import type { ListItem } from './ListView'; + +/** Width reserved in front of every row of a tree list for the expand/collapse chevron. */ +const CHEVRON_SLOT = 14; + +/** + * One row of a `ListView`: hover/selection background, tree guide, chevron, swatch, label and + * hint. Purely visual – pointer handling and selection state live in the list. + */ +export class ListRow extends Widget { + private readonly bg = new Graphics(); + private readonly labelText = new Text({ text: '', style: textStyle('body') }); + private readonly hint = new Text({ text: '', style: textStyle('small') }); + private selected = false; + private listFocused = false; + + constructor(readonly item: ListItem, height: number, private readonly chevronSlot: boolean) { + super(); + this.cursor = 'pointer'; + this.minHeight = height; + this.addChild(this.bg, this.labelText, this.hint); + this.trackHover(); + } + + /** `listFocused` picks the selection color: strong while the list has focus, muted otherwise. */ + setSelected(selected: boolean, listFocused: boolean): void { + if (selected === this.selected && listFocused === this.listFocused) return; + this.selected = selected; + this.listFocused = listFocused; + this.invalidate(); + } + + /** Left edge of the chevron slot (or of the content itself in a flat list). */ + private get indent(): number { + return Theme.padding + (this.item.depth ?? 0) * Theme.treeIndent; + } + + /** Global centre of the chevron; null for leaves and in flat lists. */ + chevronGlobal(): { x: number; y: number } | null { + if (!this.chevronSlot || this.item.expanded === undefined) return null; + const p = this.getGlobalPosition(); + return { x: p.x + this.indent + CHEVRON_SLOT / 2, y: p.y + this.h / 2 }; + } + + /** Whether a click at this local x lands on the chevron rather than on the row. */ + hitsChevron(localX: number): boolean { + if (!this.chevronSlot || this.item.expanded === undefined) return false; + return localX >= this.indent - 2 && localX < this.indent + CHEVRON_SLOT; + } + + override redraw(): void { + const c = Theme.colors; + const depth = this.item.depth ?? 0; + const indent = this.indent; + this.bg.clear(); + if (this.selected) this.bg.rect(0, 0, this.w, this.h).fill(this.listFocused ? c.selection : c.controlActive); + else if (this.hovered) this.bg.rect(0, 0, this.w, this.h).fill(c.controlHover); + // Tree guide: a short "└" mark in front of nested rows. + if (depth > 0) { + const gx = indent - 8; + this.bg.moveTo(gx, 0).lineTo(gx, this.h / 2).lineTo(indent - 2, this.h / 2).stroke({ width: 1, color: c.border }); + } + let x = indent; + if (this.chevronSlot) { + if (this.item.expanded !== undefined) this.drawChevron(x + 4, this.h / 2, this.item.expanded); + x += CHEVRON_SLOT; + } + if (this.item.swatch !== undefined) { + const size = Theme.swatchSize; + drawControlBox(this.bg, size, size, { + fill: this.item.swatch, + border: c.border, + radius: 2, + x, + y: centreY(this.h, size), + }); + x += size + Theme.gap; + } + this.hint.text = this.item.hint ?? ''; + const hintW = this.hint.text ? this.hint.width + Theme.padding : 0; + this.labelText.text = truncate(this.item.label, this.labelText.style, this.w - x - hintW - Theme.padding); + this.labelText.position.set(x, centreY(this.h, this.labelText.height)); + this.hint.position.set(Math.round(this.w - Theme.padding - this.hint.width), centreY(this.h, this.hint.height)); + } + + /** ▸ when collapsed, ▾ when expanded – drawn, not glyphs, so it stays crisp at any size. */ + private drawChevron(x: number, y: number, expanded: boolean): void { + const color = this.selected ? Theme.colors.text : Theme.colors.textMuted; + if (expanded) this.bg.moveTo(x - 3, y - 2).lineTo(x + 5, y - 2).lineTo(x + 1, y + 3).fill(color); + else this.bg.moveTo(x - 1, y - 4).lineTo(x + 4, y).lineTo(x - 1, y + 4).fill(color); + } +} diff --git a/src/ui/widgets/ListView.ts b/src/ui/widgets/ListView.ts index dca643c8..9077cde9 100644 --- a/src/ui/widgets/ListView.ts +++ b/src/ui/widgets/ListView.ts @@ -1,7 +1,8 @@ import { type FederatedPointerEvent, Graphics, Text } from 'pixi.js'; +import { centreY } from '../math'; import { Theme, textStyle } from '../Theme'; import { type Size, Widget } from '../Widget'; -import { truncate } from './Label'; +import { ListRow } from './ListRow'; export interface ListItem { id: string; @@ -22,9 +23,6 @@ export interface ListItem { /** Pixels the pointer must travel before a press turns into a drag. */ const DRAG_THRESHOLD = 4; -/** Width reserved in front of every row of a tree list for the expand/collapse chevron. */ -const CHEVRON_SLOT = 14; - /** Modifier keys held while a row was clicked. */ export interface PickModifiers { /** Ctrl (or ⌘) – toggle the row in a multi-selection. */ @@ -32,6 +30,15 @@ export interface PickModifiers { shift: boolean; } +interface DragState { + id: string; + startX: number; + startY: number; + /** Set once the pointer travelled past `DRAG_THRESHOLD`. */ + active: boolean; + targetId: string | null; +} + /** * List of rows with one selected row (by id, so it survives `setItems()` rebuilds) and, * optionally, further *highlighted* rows the owner manages (a multi-selection). Emits select, @@ -41,7 +48,7 @@ export interface PickModifiers { */ export class ListView extends Widget { private items: ListItem[] = []; - private rows: Row[] = []; + private rows: ListRow[] = []; private selectedId: string | null = null; private highlighted = new Set(); onSelect?: (id: string | null, modifiers?: PickModifiers) => void; @@ -58,7 +65,7 @@ export class ListView extends Widget { emptyText = 'Nothing here yet'; private readonly emptyLabel = new Text({ text: '', style: textStyle('muted') }); private readonly dropMarker = new Graphics(); - private drag: { id: string; startX: number; startY: number; active: boolean; targetId: string | null } | null = null; + private drag: DragState | null = null; constructor() { super(); @@ -69,43 +76,23 @@ export class ListView extends Widget { if (e.target === this) this.select(null); }); this.on('globalpointermove', (e: FederatedPointerEvent) => this.onDragMove(e)); - const finish = () => this.onDragEnd(); - this.on('pointerup', finish); - this.on('pointerupoutside', finish); + this.onPointerRelease(() => this.onDragEnd()); } get selected(): string | null { return this.selectedId; } - /** Id of the row currently hovered by a drag, for tests and styling. */ - get dropTarget(): string | null { - return this.drag?.active ? this.drag.targetId : null; - } - + /** + * Rebuilds all rows. The selection survives when its id is still present and is dropped + * silently otherwise; highlights are kept as they are (the owner refreshes them). + */ setItems(items: ListItem[]): void { this.items = items; for (const row of this.rows) row.destroy({ children: true }); this.rows = items.map((item, index) => { - const row = new Row(item, this.rowHeight, Boolean(this.onToggle)); - row.on('pointerdown', (e: FederatedPointerEvent) => { - const modifiers: PickModifiers = { ctrl: e.ctrlKey || e.metaKey, shift: e.shiftKey }; - // The chevron only folds the row – it never selects, so folding keeps the selection put. - if (e.button === 0 && row.hitsChevron(row.toLocal(e.global).x)) { - this.onToggle?.(item.id, !item.expanded); - } else if (e.button === 2) { - // A right-click inside a multi-selection keeps it (the menu acts on all of it). - if (!this.highlighted.has(item.id)) this.select(item.id); - this.onContextMenu?.(item.id, e.global.x, e.global.y); - } else if (modifiers.ctrl || modifiers.shift) { - this.onSelect?.(item.id, modifiers); - } else { - // Clicking a highlighted row keeps the multi-selection so it can be dragged as a whole. - if (!this.highlighted.has(item.id) || item.id === this.selectedId) this.select(item.id); - if (e.detail === 2) this.onActivate?.(item.id); - else if (this.onDrop) this.drag = { id: item.id, startX: e.global.x, startY: e.global.y, active: false, targetId: null }; - } - }); + const row = new ListRow(item, this.rowHeight, Boolean(this.onToggle)); + row.on('pointerdown', (e: FederatedPointerEvent) => this.onRowPress(row, e)); row.position.set(0, index * this.rowHeight); this.addChild(row); return row; @@ -116,6 +103,7 @@ export class ListView extends Widget { this.redraw(); } + /** Selects by id (null clears). `silent` skips `onSelect`, for model → view sync. */ select(id: string | null, silent = false): void { if (id === this.selectedId) return; this.selectedId = id; @@ -129,11 +117,7 @@ export class ListView extends Widget { this.redraw(); } - get highlightedIds(): string[] { - return [...this.highlighted]; - } - - /** Global centre of a row's chevron – null when the row has none (test hook). */ + /** Global centre of a row's chevron, for callers that must hit its real area; null when the row has none. */ chevronGlobal(id: string): { x: number; y: number } | null { return this.rows.find((r) => r.item.id === id)?.chevronGlobal() ?? null; } @@ -173,13 +157,37 @@ export class ListView extends Widget { } override redraw(): void { - for (const row of this.rows) row.setSelected(row.item.id === this.selectedId || this.highlighted.has(row.item.id), this.focused); + for (const row of this.rows) { + row.setSelected(row.item.id === this.selectedId || this.highlighted.has(row.item.id), this.focused); + } this.emptyLabel.text = this.items.length === 0 ? this.emptyText : ''; - this.emptyLabel.position.set(Theme.padding, Math.round((this.rowHeight - this.emptyLabel.height) / 2)); + this.emptyLabel.position.set(Theme.padding, centreY(this.rowHeight, this.emptyLabel.height)); this.drawDropMarker(); } - // --- drag & drop ------------------------------------------------------------------------- + // --- pointer input ------------------------------------------------------------------------- + + private onRowPress(row: ListRow, e: FederatedPointerEvent): void { + const { item } = row; + const modifiers: PickModifiers = { ctrl: e.ctrlKey || e.metaKey, shift: e.shiftKey }; + // The chevron only folds the row – it never selects, so folding keeps the selection put. + if (e.button === 0 && row.hitsChevron(row.toLocal(e.global).x)) { + this.onToggle?.(item.id, !item.expanded); + } else if (e.button === 2) { + // A right-click inside a multi-selection keeps it (the menu acts on all of it). + if (!this.highlighted.has(item.id)) this.select(item.id); + this.onContextMenu?.(item.id, e.global.x, e.global.y); + } else if (modifiers.ctrl || modifiers.shift) { + this.onSelect?.(item.id, modifiers); + } else { + // Clicking a highlighted row keeps the multi-selection so it can be dragged as a whole. + if (!this.highlighted.has(item.id) || item.id === this.selectedId) this.select(item.id); + if (e.detail === 2) this.onActivate?.(item.id); + else if (this.onDrop) { + this.drag = { id: item.id, startX: e.global.x, startY: e.global.y, active: false, targetId: null }; + } + } + } private onDragMove(e: FederatedPointerEvent): void { const drag = this.drag; @@ -212,88 +220,12 @@ export class ListView extends Widget { if (!drag?.active) return; const index = this.items.findIndex((i) => i.id === drag.targetId); if (index >= 0) { - this.dropMarker.rect(1, index * this.rowHeight + 1, this.w - 2, this.rowHeight - 2).stroke({ width: 2, color: Theme.colors.accent }); + this.dropMarker + .rect(1, index * this.rowHeight + 1, this.w - 2, this.rowHeight - 2) + .stroke({ width: 2, color: Theme.colors.accent }); } else { const y = Math.min(this.h, this.items.length * this.rowHeight) - 1; this.dropMarker.rect(1, Math.max(0, y - 1), this.w - 2, 2).fill(Theme.colors.accent); } } } - -class Row extends Widget { - private readonly bg = new Graphics(); - private readonly labelText = new Text({ text: '', style: textStyle('body') }); - private readonly hint = new Text({ text: '', style: textStyle('small') }); - private hovered = false; - private selected = false; - private listFocused = false; - - constructor(readonly item: ListItem, height: number, private readonly chevronSlot: boolean) { - super(); - this.cursor = 'pointer'; - this.minHeight = height; - this.addChild(this.bg, this.labelText, this.hint); - this.on('pointerover', () => { this.hovered = true; this.invalidate(); }); - this.on('pointerout', () => { this.hovered = false; this.invalidate(); }); - } - - setSelected(selected: boolean, listFocused: boolean): void { - if (selected === this.selected && listFocused === this.listFocused) return; - this.selected = selected; - this.listFocused = listFocused; - this.invalidate(); - } - - /** Left edge of the chevron slot (or of the content itself in a flat list). */ - private get indent(): number { - return Theme.padding + (this.item.depth ?? 0) * 14; - } - - chevronGlobal(): { x: number; y: number } | null { - if (!this.chevronSlot || this.item.expanded === undefined) return null; - const p = this.getGlobalPosition(); - return { x: p.x + this.indent + CHEVRON_SLOT / 2, y: p.y + this.h / 2 }; - } - - /** Whether a click at this local x lands on the chevron rather than on the row. */ - hitsChevron(localX: number): boolean { - if (!this.chevronSlot || this.item.expanded === undefined) return false; - return localX >= this.indent - 2 && localX < this.indent + CHEVRON_SLOT; - } - - override redraw(): void { - const c = Theme.colors; - const depth = this.item.depth ?? 0; - const indent = this.indent; - this.bg.clear(); - if (this.selected) this.bg.rect(0, 0, this.w, this.h).fill(this.listFocused ? c.selection : c.controlActive); - else if (this.hovered) this.bg.rect(0, 0, this.w, this.h).fill(c.controlHover); - // Tree guide: a short "└" mark in front of nested rows. - if (depth > 0) { - const gx = indent - 8; - this.bg.moveTo(gx, 0).lineTo(gx, this.h / 2).lineTo(indent - 2, this.h / 2).stroke({ width: 1, color: c.border }); - } - let x = indent; - if (this.chevronSlot) { - if (this.item.expanded !== undefined) this.drawChevron(x + 4, this.h / 2, this.item.expanded); - x += CHEVRON_SLOT; - } - if (this.item.swatch !== undefined) { - this.bg.roundRect(x, (this.h - 12) / 2, 12, 12, 2).fill(this.item.swatch); - this.bg.roundRect(x + 0.5, (this.h - 12) / 2 + 0.5, 11, 11, 2).stroke({ width: 1, color: c.border }); - x += 18; - } - this.hint.text = this.item.hint ?? ''; - const hintW = this.hint.text ? this.hint.width + Theme.padding : 0; - this.labelText.text = truncate(this.item.label, this.labelText.style, this.w - x - hintW - Theme.padding); - this.labelText.position.set(x, Math.round((this.h - this.labelText.height) / 2)); - this.hint.position.set(Math.round(this.w - Theme.padding - this.hint.width), Math.round((this.h - this.hint.height) / 2)); - } - - /** ▸ when collapsed, ▾ when expanded – drawn, not glyphs, so it stays crisp at any size. */ - private drawChevron(x: number, y: number, expanded: boolean): void { - const color = this.selected ? Theme.colors.text : Theme.colors.textMuted; - if (expanded) this.bg.moveTo(x - 3, y - 2).lineTo(x + 5, y - 2).lineTo(x + 1, y + 3).fill(color); - else this.bg.moveTo(x - 1, y - 4).lineTo(x + 4, y).lineTo(x - 1, y + 4).fill(color); - } -} diff --git a/src/ui/widgets/Menu.ts b/src/ui/widgets/Menu.ts new file mode 100644 index 00000000..f5a33eeb --- /dev/null +++ b/src/ui/widgets/Menu.ts @@ -0,0 +1,23 @@ +import { Button } from './Button'; +import { Popup } from './Popup'; + +export interface MenuItem { + label: string; + onSelect: () => void; + danger?: boolean; + disabled?: boolean; +} + +/** A vertical list of clickable actions. Picking one closes the menu before it runs. */ +export class Menu extends Popup { + constructor(items: MenuItem[]) { + super({ padding: 4, gap: 2 }); + this.minWidth = 160; + for (const item of items) { + const button = new Button(item.label, { variant: item.danger ? 'danger' : 'ghost' }); + button.setEnabled(!item.disabled); + button.onClick = this.closeThen(item.onSelect); + this.content.add(button); + } + } +} diff --git a/src/ui/widgets/NumberField.ts b/src/ui/widgets/NumberField.ts index cc2e7138..a7126d33 100644 --- a/src/ui/widgets/NumberField.ts +++ b/src/ui/widgets/NumberField.ts @@ -1,11 +1,15 @@ import type { FederatedPointerEvent } from 'pixi.js'; +import { clamp } from '../math'; import { ui } from '../UIContext'; import { TextField, type TextFieldOptions } from './TextField'; export interface NumberFieldOptions extends Omit { min?: number; max?: number; - /** Increment per pixel while scrubbing and for arrow keys. */ + /** + * Increment per scrubbed pixel and per arrow key press. Its magnitude also sets the display + * precision: 0.1 shows one decimal, 1 shows none. + */ step?: number; /** Fires continuously while scrubbing or typing a valid number. */ onInput?: (value: number) => void; @@ -13,6 +17,9 @@ export interface NumberFieldOptions extends Omit void; } +/** Pixels the pointer must travel before a press turns into a scrub instead of a click. */ +const SCRUB_THRESHOLD = 3; + /** * Numeric input: click to type, drag horizontally to scrub (Shift = ×10, Alt = ×0.1), * arrow keys step. Values are clamped to min/max and formatted with the step's precision. @@ -23,7 +30,7 @@ export class NumberField extends TextField { private readonly max: number; private readonly step: number; private readonly decimals: number; - private scrub: { startX: number; startValue: number; moved: boolean; event: FederatedPointerEvent } | null = null; + private scrub: { startX: number; startValue: number; moved: boolean } | null = null; onNumberInput?: (value: number) => void; onNumberChange?: (value: number) => void; @@ -32,23 +39,23 @@ export class NumberField extends TextField { this.min = options.min ?? -Infinity; this.max = options.max ?? Infinity; this.step = options.step ?? 1; - this.decimals = Math.max(0, Math.min(6, Math.ceil(-Math.log10(this.step)))); + this.decimals = clamp(Math.ceil(-Math.log10(this.step)), 0, 6); this.onNumberInput = options.onInput; this.onNumberChange = options.onChange; - this.numeric = this.clamp(value); + this.numeric = this.clampValue(value); this.valueText = this.format(this.numeric); this.cursor = 'ew-resize'; this.onInput = (text) => { const n = Number(text.trim()); if (text.trim() !== '' && Number.isFinite(n)) { - this.numeric = this.clamp(n); + this.numeric = this.clampValue(n); this.onNumberInput?.(this.numeric); } }; this.onChange = (text) => { const n = Number(text.trim()); - this.numeric = this.clamp(Number.isFinite(n) && text.trim() !== '' ? n : this.numeric); + this.numeric = this.clampValue(Number.isFinite(n) && text.trim() !== '' ? n : this.numeric); this.valueText = this.format(this.numeric); this.invalidate(); this.onNumberChange?.(this.numeric); @@ -57,13 +64,13 @@ export class NumberField extends TextField { this.on('globalpointermove', (e: FederatedPointerEvent) => { if (!this.scrub) return; const dx = e.global.x - this.scrub.startX; - if (!this.scrub.moved && Math.abs(dx) < 3) return; + if (!this.scrub.moved && Math.abs(dx) < SCRUB_THRESHOLD) return; this.scrub.moved = true; const factor = e.shiftKey ? 10 : e.altKey ? 0.1 : 1; this.setNumber(this.scrub.startValue + dx * this.step * factor); this.onNumberInput?.(this.numeric); }); - const finish = () => { + this.onPointerRelease(() => { const scrub = this.scrub; this.scrub = null; if (!scrub) return; @@ -74,17 +81,17 @@ export class NumberField extends TextField { ui().focus.focus(this); this.selectAll(); } - }; - this.on('pointerup', finish); - this.on('pointerupoutside', finish); + }); } + /** The clamped numeric value; while typing, the last valid parse of the text. */ get number(): number { return this.numeric; } + /** Model → view update: clamps, and reformats the text unless the user is editing it. */ setNumber(value: number): void { - this.numeric = this.clamp(value); + this.numeric = this.clampValue(value); if (!this.isEditing) { this.valueText = this.format(this.numeric); this.invalidate(); @@ -98,14 +105,14 @@ export class NumberField extends TextField { return; } e.stopPropagation(); - this.scrub = { startX: e.global.x, startValue: this.numeric, moved: false, event: e }; + this.scrub = { startX: e.global.x, startValue: this.numeric, moved: false }; } override onBridgeKey(event: KeyboardEvent): boolean { if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { const dir = event.key === 'ArrowUp' ? 1 : -1; const factor = event.shiftKey ? 10 : event.altKey ? 0.1 : 1; - this.numeric = this.clamp(this.numeric + dir * this.step * factor); + this.numeric = this.clampValue(this.numeric + dir * this.step * factor); this.valueText = this.format(this.numeric); ui().keyboard.setValue(this.valueText, 0, this.valueText.length); this.selectAll(); @@ -115,13 +122,13 @@ export class NumberField extends TextField { return super.onBridgeKey(event); } - private clamp(value: number): number { + /** Clamps to min/max; NaN/Infinity keep the current value (0 before the first assignment). */ + private clampValue(value: number): number { if (!Number.isFinite(value)) return this.numeric ?? 0; - return Math.max(this.min, Math.min(this.max, value)); + return clamp(value, this.min, this.max); } private format(value: number): string { - const rounded = Number(value.toFixed(this.decimals)); - return String(rounded); + return String(Number(value.toFixed(this.decimals))); } } diff --git a/src/ui/widgets/Panel.ts b/src/ui/widgets/Panel.ts index cbf55e43..ec19da6e 100644 --- a/src/ui/widgets/Panel.ts +++ b/src/ui/widgets/Panel.ts @@ -38,6 +38,7 @@ export class Panel extends Widget { this.addChild(this.content); } + /** Titles render upper-case. A no-op on panels created without a title (they have no header). */ setTitle(title: string): void { this.titleLabel?.setText(title.toUpperCase()); } diff --git a/src/ui/widgets/Popup.ts b/src/ui/widgets/Popup.ts index 65bb3e5d..7f011e6e 100644 --- a/src/ui/widgets/Popup.ts +++ b/src/ui/widgets/Popup.ts @@ -1,10 +1,17 @@ import { type FederatedPointerEvent, Graphics, Rectangle } from 'pixi.js'; -import { HStack, VStack } from '../layout/Stack'; +import { drawControlBox } from '../draw'; +import { VStack } from '../layout/Stack'; +import { clamp } from '../math'; import { Theme } from '../Theme'; import { ui } from '../UIContext'; import { type Size, Widget } from '../Widget'; -import { Button } from './Button'; -import { Label } from './Label'; + +export interface PopupOptions { + padding?: number; + gap?: number; + /** Modal popups dim the screen and ignore click-away; only Escape or an action closes them. */ + modal?: boolean; +} /** * A floating box on the overlay layer with a click-away backdrop. Base for menus, @@ -16,9 +23,9 @@ export class Popup extends Widget { private readonly bg = new Graphics(); readonly content: VStack; onClose?: () => void; - private modal = false; + private readonly modal: boolean; - constructor(options: { padding?: number; gap?: number; modal?: boolean } = {}) { + constructor(options: PopupOptions = {}) { super(); this.modal = options.modal ?? false; this.content = new VStack({ padding: options.padding ?? Theme.padding, gap: options.gap ?? Theme.gap }); @@ -31,25 +38,39 @@ export class Popup extends Widget { this.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation()); } - get isOpen(): boolean { + private get isOpen(): boolean { return this.parent !== null; } + /** + * Mounts the popup on the overlay with its top-left corner at the given global position, + * shifted as needed to stay on screen. Takes keyboard focus away from the focused widget; + * Escape closes. + */ open(globalX: number, globalY: number): void { const { overlay, app, focus } = ui(); const screen = app.screen; - this.backdrop.clear().rect(0, 0, screen.width, screen.height).fill({ color: 0x000000, alpha: this.modal ? 0.4 : 0.001 }); + this.backdrop + .clear() + .rect(0, 0, screen.width, screen.height) + .fill({ color: 0x000000, alpha: this.modal ? 0.4 : 0.001 }); this.backdrop.hitArea = new Rectangle(0, 0, screen.width, screen.height); overlay.addChild(this.backdrop, this); const size = this.measure(); this.resize(size.w, size.h); - const x = Math.max(0, Math.min(screen.width - size.w, globalX)); - const y = Math.max(0, Math.min(screen.height - size.h, globalY)); + const x = clamp(globalX, 0, screen.width - size.w); + const y = clamp(globalY, 0, screen.height - size.h); this.position.set(Math.round(x), Math.round(y)); focus.blur(); window.addEventListener('keydown', this.onEscape, { capture: true }); } + /** Opens left-aligned with `anchor`, `gap` px below its bottom edge (dropdown menus, pickers). */ + openBelow(anchor: Widget, gap = 2): void { + const g = anchor.getGlobalPosition(); + this.open(g.x, g.y + anchor.h + gap); + } + /** Open centered on screen (dialogs). */ openCentered(): void { const screen = ui().app.screen; @@ -57,6 +78,7 @@ export class Popup extends Widget { this.open((screen.width - size.w) / 2, (screen.height - size.h) / 2); } + /** Unmounts and fires `onClose`. Safe to call on a popup that is not open. */ close(): void { if (!this.isOpen) return; window.removeEventListener('keydown', this.onEscape, { capture: true }); @@ -64,6 +86,14 @@ export class Popup extends Widget { this.onClose?.(); } + /** Click handler for action buttons: closes the popup first so `action` sees the overlay cleared. */ + protected closeThen(action?: () => void): () => void { + return () => { + this.close(); + action?.(); + }; + } + private readonly onEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.stopPropagation(); @@ -83,53 +113,7 @@ export class Popup extends Widget { override redraw(): void { const c = Theme.colors; this.bg.clear(); - this.bg.roundRect(2, 3, this.w, this.h, Theme.radius).fill({ color: 0x000000, alpha: 0.35 }); - this.bg.roundRect(0, 0, this.w, this.h, Theme.radius).fill(c.panelHeader); - this.bg.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: 1, color: c.border }); - } -} - -export interface MenuItem { - label: string; - onSelect: () => void; - danger?: boolean; - disabled?: boolean; -} - -/** A vertical list of clickable actions. */ -export class Menu extends Popup { - constructor(items: MenuItem[]) { - super({ padding: 4, gap: 2 }); - this.minWidth = 160; - for (const item of items) { - const button = new Button(item.label, { variant: item.danger ? 'danger' : 'ghost' }); - button.setEnabled(!item.disabled); - button.onClick = () => { - this.close(); - item.onSelect(); - }; - this.content.add(button); - } - } -} - -/** A modal box with title, message and buttons. */ -export class Dialog extends Popup { - constructor(title: string, message: string, buttons: { label: string; primary?: boolean; danger?: boolean; onClick?: () => void }[]) { - super({ modal: true, padding: Theme.padding * 2, gap: Theme.padding }); - this.minWidth = 320; - this.content.add(new Label(title, { style: 'bodyBold' })); - if (message) this.content.add(new Label(message, { style: 'muted' })); - const row = new HStack({ gap: Theme.gap, align: 'center' }); - row.add(new Label(''), 1); // pushes the buttons to the right - for (const b of buttons) { - const button = new Button(b.label, { variant: b.primary ? 'primary' : b.danger ? 'danger' : 'default' }); - button.onClick = () => { - this.close(); - b.onClick?.(); - }; - row.add(button); - } - this.content.add(row); + this.bg.roundRect(2, 3, this.w, this.h, Theme.radius).fill({ color: 0x000000, alpha: 0.35 }); // drop shadow + drawControlBox(this.bg, this.w, this.h, { fill: c.panelHeader, border: c.border }); } } diff --git a/src/ui/widgets/ScrollContainer.ts b/src/ui/widgets/ScrollContainer.ts index c5d7f4f5..fec4f662 100644 --- a/src/ui/widgets/ScrollContainer.ts +++ b/src/ui/widgets/ScrollContainer.ts @@ -1,6 +1,7 @@ import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics } from 'pixi.js'; +import { clamp } from '../math'; import { Theme } from '../Theme'; -import { type Size, Widget } from '../Widget'; +import { Widget } from '../Widget'; /** * Vertically scrolls a single content widget that is taller than the container. @@ -37,11 +38,11 @@ export class ScrollContainer extends Widget { const ratio = this.contentHeight / Math.max(1, this.h); this.scrollTo(this.dragScrollStart + (e.global.y - this.dragStart) * ratio); }); - const stop = () => { this.draggingBar = false; }; - this.scrollbar.on('pointerup', stop); - this.scrollbar.on('pointerupoutside', stop); + // The press starts on the scrollbar graphics; its release bubbles up to us either way. + this.onPointerRelease(() => { this.draggingBar = false; }); } + /** Replaces the scrolled widget. The previous content is detached, not destroyed. */ setContent(content: Widget): void { if (this.content) this.viewportNode.removeChild(this.content); this.content = content; @@ -53,12 +54,14 @@ export class ScrollContainer extends Widget { return this.content ? this.content.h : 0; } + /** Largest valid scroll offset; 0 while the content fits. */ get maxScroll(): number { return Math.max(0, this.contentHeight - this.h); } + /** Scrolls to `y`, clamped to `[0, maxScroll]`. */ scrollTo(y: number): void { - const clamped = Math.max(0, Math.min(this.maxScroll, y)); + const clamped = clamp(y, 0, this.maxScroll); if (clamped === this.scrollY) return; this.scrollY = clamped; this.applyScroll(); @@ -75,14 +78,10 @@ export class ScrollContainer extends Widget { if (!this.content) return; const size = this.content.measure(); this.content.resize(this.w, Math.max(size.h, this.h)); - this.scrollTo(this.scrollY); // re-clamp + this.scrollY = clamp(this.scrollY, 0, this.maxScroll); this.applyScroll(); } - override measure(): Size { - return { w: this.minWidth, h: this.minHeight }; - } - override layout(): void { this.relayoutContent(); } @@ -96,10 +95,10 @@ export class ScrollContainer extends Widget { this.maskShape.clear().rect(0, 0, this.w, this.h).fill(0xffffff); this.scrollbar.clear(); if (this.maxScroll <= 0) return; - const trackW = 6; + const trackW = Theme.scrollbarWidth; const x = this.w - trackW - 2; const barH = Math.max(20, (this.h / this.contentHeight) * this.h); const barY = (this.scrollY / this.maxScroll) * (this.h - barH); - this.scrollbar.roundRect(x, barY, trackW, barH, 3).fill({ color: Theme.colors.textMuted, alpha: 0.6 }); + this.scrollbar.roundRect(x, barY, trackW, barH, trackW / 2).fill({ color: Theme.colors.textMuted, alpha: 0.6 }); } } diff --git a/src/ui/widgets/Splitter.ts b/src/ui/widgets/Splitter.ts index 9ba4636f..55e6eb6f 100644 --- a/src/ui/widgets/Splitter.ts +++ b/src/ui/widgets/Splitter.ts @@ -1,12 +1,12 @@ import { type FederatedPointerEvent, Graphics } from 'pixi.js'; import { Theme } from '../Theme'; -import { type Size, Widget } from '../Widget'; +import { Widget } from '../Widget'; /** Thin draggable divider between two panes. Reports the drag delta; the parent resizes. */ export class Splitter extends Widget { + /** Size across the drag axis. The visible bar fills it, so this is also the grab area. */ static readonly THICKNESS = 5; private readonly bar = new Graphics(); - private hovered = false; private dragging = false; private last = 0; onDrag?: (delta: number) => void; @@ -17,8 +17,7 @@ export class Splitter extends Widget { this.minWidth = axis === 'x' ? Splitter.THICKNESS : 0; this.minHeight = axis === 'y' ? Splitter.THICKNESS : 0; this.addChild(this.bar); - this.on('pointerover', () => { this.hovered = true; this.invalidate(); }); - this.on('pointerout', () => { this.hovered = false; this.invalidate(); }); + this.trackHover(); this.on('pointerdown', (e: FederatedPointerEvent) => { this.dragging = true; this.last = this.axis === 'x' ? e.global.x : e.global.y; @@ -31,17 +30,15 @@ export class Splitter extends Widget { this.last = now; if (delta !== 0) this.onDrag?.(delta); }); - const stop = () => { this.dragging = false; this.invalidate(); }; - this.on('pointerup', stop); - this.on('pointerupoutside', stop); - } - - override measure(): Size { - return { w: this.minWidth, h: this.minHeight }; + this.onPointerRelease(() => { + this.dragging = false; + this.invalidate(); + }); } override redraw(): void { this.bar.clear(); - this.bar.rect(0, 0, this.w, this.h).fill(this.hovered || this.dragging ? Theme.colors.accent : Theme.colors.border); + const active = this.hovered || this.dragging; + this.bar.rect(0, 0, this.w, this.h).fill(active ? Theme.colors.accent : Theme.colors.border); } } diff --git a/src/ui/widgets/TextField.ts b/src/ui/widgets/TextField.ts index eb8c9fa5..ac251f36 100644 --- a/src/ui/widgets/TextField.ts +++ b/src/ui/widgets/TextField.ts @@ -1,5 +1,7 @@ import { CanvasTextMetrics, type FederatedPointerEvent, Graphics, Text } from 'pixi.js'; +import { drawControlBox } from '../draw'; import type { TextEditTarget } from '../KeyboardBridge'; +import { centreY, clamp } from '../math'; import { Theme, textStyle, type TextStyleToken } from '../Theme'; import { ui } from '../UIContext'; import { type Size, Widget } from '../Widget'; @@ -35,13 +37,12 @@ export class TextField extends Widget implements TextEditTarget { protected editing = false; protected selStart = 0; protected selEnd = 0; + private readonly clipShape = new Graphics(); private originalValue = ''; private scrollX = 0; - private hovered = false; private caretVisible = true; private blinkTimer = 0; private dragSelecting = false; - private readonly innerPad = 6; placeholder: string; onInput?: (value: string) => void; onChange?: (value: string) => void; @@ -60,26 +61,20 @@ export class TextField extends Widget implements TextEditTarget { this.minWidth = options.width ?? 80; this.textNode = new Text({ text: value, style: textStyle(options.style ?? 'body') }); this.placeholderNode = new Text({ text: this.placeholder, style: textStyle('muted') }); - const clip = new Graphics(); - this.addChild(this.bg, this.selectionShape, this.placeholderNode, this.textNode, this.caret, clip); - this.mask = clip; - this.clipShape = clip; + this.addChild(this.bg, this.selectionShape, this.placeholderNode, this.textNode, this.caret, this.clipShape); + this.mask = this.clipShape; - this.on('pointerover', () => { this.hovered = true; this.invalidate(); }); - this.on('pointerout', () => { this.hovered = false; this.invalidate(); }); + this.trackHover(); this.on('pointerdown', (e: FederatedPointerEvent) => this.handlePointerDown(e)); this.on('globalpointermove', (e: FederatedPointerEvent) => { if (!this.dragSelecting) return; const index = this.indexAt(this.toLocal(e.global).x); this.setSelection(this.selStart, index, true); }); - const stop = () => { this.dragSelecting = false; }; - this.on('pointerup', stop); - this.on('pointerupoutside', stop); + this.onPointerRelease(() => { this.dragSelecting = false; }); } - private readonly clipShape: Graphics; - + /** Current text, including an edit in progress that has not been committed yet. */ get value(): string { return this.valueText; } @@ -111,7 +106,7 @@ export class TextField extends Widget implements TextEditTarget { this.dragSelecting = true; } - // --- focus --------------------------------------------------------------------------- + // --- focus --------------------------------------------------------------------------------- override onFocus(): void { super.onFocus(); @@ -156,20 +151,22 @@ export class TextField extends Widget implements TextEditTarget { } private setSelection(start: number, end: number, pushToBridge: boolean): void { - this.selStart = Math.max(0, Math.min(this.valueText.length, start)); - this.selEnd = Math.max(0, Math.min(this.valueText.length, end)); + this.selStart = clamp(start, 0, this.valueText.length); + this.selEnd = clamp(end, 0, this.valueText.length); if (pushToBridge && this.editing) { + // The DOM only takes an ordered range; selStart/selEnd keep the anchor/focus order a + // drag selection needs. const lo = Math.min(this.selStart, this.selEnd); const hi = Math.max(this.selStart, this.selEnd); ui().keyboard.setValue(this.valueText, lo, hi); - // Keep our anchor/focus order; the DOM only knows lo/hi. } this.caretVisible = true; this.invalidate(); } - // --- TextEditTarget -------------------------------------------------------------------- + // --- TextEditTarget ------------------------------------------------------------------------ + /** Adopts the input's value and selection verbatim; fires `onInput` only when the text changed. */ onBridgeInput(value: string, selectionStart: number, selectionEnd: number): void { const changed = value !== this.valueText; this.valueText = value; @@ -180,6 +177,7 @@ export class TextField extends Widget implements TextEditTarget { if (changed) this.onInput?.(value); } + /** Enter submits then commits via blur, Escape reverts, Tab cycles focus; editing keys stay with the browser. */ onBridgeKey(event: KeyboardEvent): boolean { if (event.key === 'Enter') { this.onSubmit?.(this.valueText); @@ -205,7 +203,7 @@ export class TextField extends Widget implements TextEditTarget { if (ui().focus.focused === this) ui().focus.blur(); } - // --- geometry -------------------------------------------------------------------------- + // --- geometry ------------------------------------------------------------------------------ private textWidth(text: string): number { return CanvasTextMetrics.measureText(text, this.textNode.style).width; @@ -213,7 +211,7 @@ export class TextField extends Widget implements TextEditTarget { /** Character index closest to a local x coordinate. */ protected indexAt(localX: number): number { - const x = localX - this.innerPad + this.scrollX; + const x = localX - Theme.fieldPadding + this.scrollX; let best = 0; let bestDist = Infinity; for (let i = 0; i <= this.valueText.length; i++) { @@ -243,10 +241,13 @@ export class TextField extends Widget implements TextEditTarget { override redraw(): void { const c = Theme.colors; - const innerW = this.w - this.innerPad * 2; + const pad = Theme.fieldPadding; + const innerW = this.w - pad * 2; this.bg.clear(); - this.bg.roundRect(0, 0, this.w, this.h, Theme.radius).fill(this.hovered && !this.editing ? c.controlHover : c.control); - this.bg.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: 1, color: this.focused ? c.accent : c.border }); + drawControlBox(this.bg, this.w, this.h, { + fill: this.hovered && !this.editing ? c.controlHover : c.control, + border: this.focused ? c.accent : c.border, + }); this.clipShape.clear().rect(1, 1, this.w - 2, this.h - 2).fill(0xffffff); this.textNode.text = this.valueText; @@ -257,10 +258,10 @@ export class TextField extends Widget implements TextEditTarget { if (!this.editing) this.scrollX = 0; else if (caretX - this.scrollX > innerW) this.scrollX = caretX - innerW; else if (caretX - this.scrollX < 0) this.scrollX = caretX; - const textX = this.innerPad - this.scrollX; - const textY = Math.round((this.h - this.textNode.height) / 2); + const textX = pad - this.scrollX; + const textY = centreY(this.h, this.textNode.height); this.textNode.position.set(Math.round(textX), textY); - this.placeholderNode.position.set(this.innerPad, textY); + this.placeholderNode.position.set(pad, textY); this.selectionShape.clear(); this.caret.clear(); diff --git a/src/viewport/Viewport.ts b/src/viewport/Viewport.ts index bcf105be..8014e2d4 100644 --- a/src/viewport/Viewport.ts +++ b/src/viewport/Viewport.ts @@ -1,64 +1,27 @@ -import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Rectangle, Text, TextStyle } from 'pixi.js'; +import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text, TextStyle } from 'pixi.js'; import { CompoundCommand } from '../core/commands/CompoundCommand'; import { MoveSceneObjectCommand } from '../core/commands/sceneObjectCommands'; -import { isHandleEnabled } from '../core/graph'; -import { type AssetType, isSymbolType } from '../core/model/AssetTypes'; -import { type Position, type PropertyBag, sortByZ, type Vec2 } from '../core/model/Properties'; -import { editorPosition, subtreeRoots, treeOrder } from '../core/model/hierarchy'; -import { findAsset } from '../core/model/Project'; -import { BEHAVIOR_SYMBOL, CONTAINER_SYMBOL, footprintOf, textAnchor } from '../core/properties/bounds'; +import { isSymbolType } from '../core/model/AssetTypes'; +import { findObject, subtreeRoots } from '../core/model/hierarchy'; +import { type Position, sortByZ, type Vec2 } from '../core/model/Properties'; +import { footprintOf, textAnchor } from '../core/properties/bounds'; import { cameraFrame, rectAround, rectsIntersect, type WorldRect } from '../core/properties/camera'; -import { enabledAlongChain } from '../core/properties/enabled'; -import { type ColliderShape, colliderShape, isTrigger } from '../core/properties/collider'; -import { resolveSceneObjectProps } from '../core/properties/resolve'; -import type { World } from '../runtime/World'; -import { Theme, textStyle, Widget, type Size } from '../ui'; import { type EditorState, objectSelection } from '../editor/state'; - -/** What the viewport draws: the same shape whether it comes from the model or the world. */ -interface DrawItem { - id: string; - type: AssetType; - name: string; - x: number; - y: number; - /** - * Draw order (see `Position.z`). `x`/`y`/`z` are world values (parents already applied) – - * in the scene view including the editor offsets along the chain, which is what makes an - * offset object be drawn, selected and dragged at the shifted place. - */ - z: number; - props: PropertyBag; - /** Id of the parent item, empty when top-level. */ - parentId: string; - /** Effective switch: this object and all its parents are on (see `properties/enabled.ts`). */ - enabled: boolean; -} +import type { World } from '../runtime/World'; +import { type Size, Theme, textStyle, Widget } from '../ui'; +import { collectSceneItems, collectWorldItems, type DrawItem } from './items'; +import { dashedLine, drawItem } from './symbols'; /** Pixels the pointer must travel before a press on empty space becomes a rubber band. */ const MARQUEE_THRESHOLD = 3; -/** Screen-pixel half-width of the band along a collider's outline that grabs the collider. */ -const COLLIDER_GRAB_BAND = 6; - -/** Hit area covering only the outline band of a collider shape (local coordinates, world units). */ -function colliderOutlineHitArea(shape: ColliderShape, band: number): { contains(x: number, y: number): boolean } { - if (shape.kind === 'circle') return { contains: (x, y) => Math.abs(Math.hypot(x, y) - shape.r) <= band }; - const hw = shape.w / 2; - const hh = shape.h / 2; - return { - contains: (x, y) => { - const ax = Math.abs(x); - const ay = Math.abs(y); - return ax <= hw + band && ay <= hh + band && (ax >= hw - band || ay >= hh - band); - }, - }; -} +const GRID_STEP = 100; +const LETTERBOX_COLOR = 0x000000; /** * How world coordinates map onto the viewport: the world point (`x`, `y`) sits at the * centre of the screen rectangle `rect` (viewport-local pixels) at `scale` pixels per - * world unit. In edit mode the rectangle is the whole viewport; in play mode it is the - * camera's frame fitted into the viewport (letterboxed). + * world unit. In the scene view the rectangle is the whole viewport; in the game view it is + * the camera's frame fitted into the viewport (letterboxed). */ interface View { x: number; @@ -67,9 +30,6 @@ interface View { rect: { x: number; y: number; w: number; h: number }; } -const GRID_STEP = 100; -const LETTERBOX_COLOR = 0x000000; - export interface ViewportOptions { /** * Game view: draws the running world through the scene's camera, no editing. The default @@ -118,7 +78,7 @@ export class Viewport extends Widget { * hide them, no matter what z-order the scene ends up with. */ private readonly overlay = new Container(); - /** Dashed owner links (behavior → owner), drawn in world units beneath the symbols. */ + /** Dashed parent links (behavior → owner), drawn in world units beneath the symbols. */ private readonly links = new Graphics(); private readonly contentMask = new Graphics(); private readonly gizmos = new Graphics(); @@ -127,7 +87,7 @@ export class Viewport extends Widget { /** Rendered content of text objects: a sibling of the item's node, kept next to it in z. */ private readonly texts = new Map(); private playWorld: World | null = null; - /** Edit-mode view: world point at the viewport centre and zoom. */ + /** Scene view: world point at the viewport centre and zoom. */ private view = { x: 0, y: 0, zoom: 1 }; private panning: { startX: number; startY: number; viewX: number; viewY: number } | null = null; private dragging: ObjectDrag | null = null; @@ -149,100 +109,11 @@ export class Viewport extends Widget { this.content.mask = this.contentMask; this.cursor = 'default'; - this.on('pointerdown', (e: FederatedPointerEvent) => { - if (this.game) { - if (e.button === 0) { - this.reportPointer(e); - this.playWorld?.setPointerDown(true); - } - return; - } - if (e.button === 1 || (e.button === 0 && e.target === this && e.shiftKey)) { - this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y }; - e.stopPropagation(); - return; - } - if (e.button === 0 && e.target === this) { - // Empty space: a click deselects, a drag becomes a rubber band (decided on release). - const local = this.toLocal(e.global); - this.marquee = { start: { x: local.x, y: local.y }, current: { x: local.x, y: local.y }, active: false }; - e.stopPropagation(); - } - }); - this.on('globalpointermove', (e: FederatedPointerEvent) => { - if (this.game) { - this.reportPointer(e); - return; - } - if (this.panning) { - this.view.x = this.panning.viewX - (e.global.x - this.panning.startX) / this.view.zoom; - this.view.y = this.panning.viewY - (e.global.y - this.panning.startY) / this.view.zoom; - this.redraw(); - } else if (this.dragging) { - const local = this.toLocal(e.global); - const dx = (local.x - this.dragging.pointer.x) / this.view.zoom; - const dy = (local.y - this.dragging.pointer.y) / this.view.zoom; - if (!this.dragging.moved && Math.hypot(dx, dy) < 2) return; - this.dragging.moved = true; - const scene = this.state.activeScene; - if (!scene) return; - // A world-space delta is a local-space delta as well (no rotation/scale in the hierarchy). - for (const item of this.dragging.items) { - const object = scene.objects.find((o) => o.id === item.objectId); - if (object) object.position = { x: Math.round(item.start.x + dx), y: Math.round(item.start.y + dy), z: item.start.z }; - } - this.state.preview(); - } else if (this.marquee) { - const local = this.toLocal(e.global); - this.marquee.current = { x: local.x, y: local.y }; - if (!this.marquee.active && Math.hypot(local.x - this.marquee.start.x, local.y - this.marquee.start.y) < MARQUEE_THRESHOLD) return; - this.marquee.active = true; - this.redraw(); - } - }); - const finish = () => { - if (this.game) { - this.playWorld?.setPointerDown(false); - return; - } - this.panning = null; - const marquee = this.marquee; - this.marquee = null; - if (marquee) { - if (marquee.active) this.selectInMarquee(marquee); - else this.state.select(null); - this.redraw(); - } - const drag = this.dragging; - this.dragging = null; - if (!drag || !drag.moved) return; - const scene = this.state.activeScene; - if (!scene) return; - const moves: MoveSceneObjectCommand[] = []; - for (const item of drag.items) { - const object = scene.objects.find((o) => o.id === item.objectId); - if (!object) continue; - const next = { ...object.position }; - object.position = { ...item.start }; - moves.push(new MoveSceneObjectCommand(drag.sceneId, item.objectId, next, item.start)); - } - if (moves.length === 1) this.state.execute(moves[0]!); - else if (moves.length > 1) this.state.execute(new CompoundCommand('Move objects', moves)); - }; - this.on('pointerup', finish); - this.on('pointerupoutside', finish); - this.on('wheel', (e: FederatedWheelEvent) => { - if (this.game) return; - const local = this.toLocal(e.global); - const before = this.screenToWorld(local.x, local.y); - const factor = Math.exp(-e.deltaY * 0.0015); - this.view.zoom = Math.max(0.1, Math.min(8, this.view.zoom * factor)); - const after = this.screenToWorld(local.x, local.y); - this.view.x += before.x - after.x; - this.view.y += before.y - after.y; - this.redraw(); - e.preventDefault(); - }); + this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e)); + this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e)); + this.on('pointerup', () => this.onPointerUp()); + this.on('pointerupoutside', () => this.onPointerUp()); + this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e)); state.on('project', () => this.redraw()); state.on('load', () => { this.view = { x: 0, y: 0, zoom: 1 }; this.redraw(); }); @@ -252,6 +123,184 @@ export class Viewport extends Widget { state.on('runtime', () => { if (this.visible) this.redraw(); }); } + /** Game view: the world to draw (null = not playing). Ignored by the scene view. */ + setWorld(world: World | null): void { + this.playWorld = world; + this.redraw(); + } + + /** World coordinates at the centre of the current scene view (used to place assets). */ + worldCenter(): Vec2 { + return { x: Math.round(this.view.x), y: Math.round(this.view.y) }; + } + + override measure(): Size { + return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) }; + } + + // --- test hooks ------------------------------------------------------------------------------ + + /** Whether anything is rendered and how many items are on screen. */ + debugInfo(): { rendering: boolean; visibleItems: number } { + return { rendering: this.currentView() !== null, visibleItems: this.visibleCount }; + } + + /** Which layer an item is drawn in: symbols live in `overlay`, above all `objects`. */ + layerOf(objectId: string): 'objects' | 'overlay' | null { + const node = this.nodes.get(objectId); + if (!node) return null; + return node.parent === this.overlay ? 'overlay' : 'objects'; + } + + /** Object ids in the order they are drawn, back to front. */ + drawOrder(): string[] { + return this.objects.children.filter((node) => node.visible).map((node) => node.label); + } + + /** Global screen position of an object's drawn position, null when not drawn. */ + objectScreen(objectId: string): Vec2 | null { + const view = this.currentView(); + const item = this.lastItems.find((i) => i.id === objectId); + if (!item || !view) return null; + const local = this.worldToScreen(item.x, item.y, view); + const origin = this.getGlobalPosition(); + return { x: origin.x + local.x, y: origin.y + local.y }; + } + + // --- pointer --------------------------------------------------------------------------------- + + private onPointerDown(e: FederatedPointerEvent): void { + if (this.game) { + if (e.button === 0) { + this.reportPointer(e); + this.playWorld?.setPointerDown(true); + } + return; + } + if (e.button === 1 || (e.button === 0 && e.target === this && e.shiftKey)) { + this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y }; + e.stopPropagation(); + return; + } + if (e.button === 0 && e.target === this) { + // Empty space: a click deselects, a drag becomes a rubber band (decided on release). + const local = this.toLocal(e.global); + this.marquee = { start: { x: local.x, y: local.y }, current: { x: local.x, y: local.y }, active: false }; + e.stopPropagation(); + } + } + + private onPointerMove(e: FederatedPointerEvent): void { + if (this.game) { + this.reportPointer(e); + return; + } + if (this.panning) { + this.view.x = this.panning.viewX - (e.global.x - this.panning.startX) / this.view.zoom; + this.view.y = this.panning.viewY - (e.global.y - this.panning.startY) / this.view.zoom; + this.redraw(); + } else if (this.dragging) { + const local = this.toLocal(e.global); + const dx = (local.x - this.dragging.pointer.x) / this.view.zoom; + const dy = (local.y - this.dragging.pointer.y) / this.view.zoom; + if (!this.dragging.moved && Math.hypot(dx, dy) < 2) return; + this.dragging.moved = true; + const scene = this.state.activeScene; + if (!scene) return; + // A world-space delta is a local-space delta as well (no rotation/scale in the hierarchy). + for (const item of this.dragging.items) { + const object = findObject(scene, item.objectId); + if (object) object.position = { x: Math.round(item.start.x + dx), y: Math.round(item.start.y + dy), z: item.start.z }; + } + this.state.preview(); + } else if (this.marquee) { + const local = this.toLocal(e.global); + this.marquee.current = { x: local.x, y: local.y }; + if (!this.marquee.active && Math.hypot(local.x - this.marquee.start.x, local.y - this.marquee.start.y) < MARQUEE_THRESHOLD) return; + this.marquee.active = true; + this.redraw(); + } + } + + private onPointerUp(): void { + if (this.game) { + this.playWorld?.setPointerDown(false); + return; + } + this.panning = null; + const marquee = this.marquee; + this.marquee = null; + if (marquee) { + if (marquee.active) this.selectInMarquee(marquee); + else this.state.select(null); + this.redraw(); + } + const drag = this.dragging; + this.dragging = null; + if (!drag || !drag.moved) return; + const scene = this.state.activeScene; + if (!scene) return; + // The preview moved the objects already; put them back so the command records the start. + const moves: MoveSceneObjectCommand[] = []; + for (const item of drag.items) { + const object = findObject(scene, item.objectId); + if (!object) continue; + const next = { ...object.position }; + object.position = { ...item.start }; + moves.push(new MoveSceneObjectCommand(drag.sceneId, 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)); + } + + private onWheel(e: FederatedWheelEvent): void { + if (this.game) return; + const local = this.toLocal(e.global); + const before = this.screenToWorld(local.x, local.y); + const factor = Math.exp(-e.deltaY * 0.0015); + this.view.zoom = Math.max(0.1, Math.min(8, this.view.zoom * factor)); + const after = this.screenToWorld(local.x, local.y); + this.view.x += before.x - after.x; + this.view.y += before.y - after.y; + this.redraw(); + e.preventDefault(); + } + + private onItemPointerDown(objectId: string, e: FederatedPointerEvent): void { + if (this.game || e.button !== 0) return; + const scene = this.state.activeScene; + const object = scene && findObject(scene, objectId); + if (!scene || !object) return; + e.stopPropagation(); + if (e.ctrlKey || e.metaKey || e.shiftKey) { + this.state.toggleObject(scene.id, objectId); + return; + } + // Clicking a selected object keeps the selection so the whole group can be dragged. + if (!this.state.selectedObjectIds.includes(objectId)) this.state.select(objectSelection(scene.id, [objectId])); + // Only the tops of the selected subtrees move; their descendants follow through the hierarchy. + const items = subtreeRoots(scene, this.state.selectedObjectIds) + .map((id) => findObject(scene, id)) + .filter((o): o is NonNullable => Boolean(o)) + .map((o) => ({ objectId: o.id, start: { ...o.position } })); + const local = this.toLocal(e.global); + this.dragging = { sceneId: scene.id, items, pointer: { x: local.x, y: local.y }, moved: false }; + } + + /** Rubber band released: selects every object whose drawn position lies inside it. */ + private selectInMarquee(marquee: Marquee): void { + const scene = this.state.activeScene; + if (!scene) return; + const a = this.screenToWorld(marquee.start.x, marquee.start.y); + const b = this.screenToWorld(marquee.current.x, marquee.current.y); + const minX = Math.min(a.x, b.x); + const maxX = Math.max(a.x, b.x); + const minY = Math.min(a.y, b.y); + const maxY = Math.max(a.y, b.y); + const ids = this.lastItems.filter((i) => i.enabled && i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY).map((i) => i.id); + this.state.select(objectSelection(scene.id, ids)); + } + /** * Game view: hands the mouse position to the running world in world units, so graphs can * use it (Mouse position, Is hovered, On click). Outside the camera frame counts as "not @@ -267,49 +316,7 @@ export class Viewport extends Widget { world.movePointer(point.x, point.y, inside); } - /** Game view: the world to draw (null = not playing). Ignored by the scene view. */ - setWorld(world: World | null): void { - this.playWorld = world; - this.redraw(); - } - - /** World coordinates at the centre of the current edit view (used to place assets). */ - worldCenter(): Vec2 { - return { x: Math.round(this.view.x), y: Math.round(this.view.y) }; - } - - /** Read-only facts for tests: whether anything is rendered and how many items are on screen. */ - debugInfo(): { rendering: boolean; visibleItems: number } { - return { rendering: this.currentView() !== null, visibleItems: this.visibleCount }; - } - - /** Which layer an item is drawn in (test hook): symbols live in `overlay`, above all `objects`. */ - layerOf(objectId: string): 'objects' | 'overlay' | null { - const node = this.nodes.get(objectId); - if (!node) return null; - return node.parent === this.overlay ? 'overlay' : 'objects'; - } - - /** Object ids in the order they are drawn, back to front (test hook). */ - drawOrder(): string[] { - return this.objects.children.filter((node) => node.visible).map((node) => node.label); - } - - /** Global screen position of an object's world position (test hook), null when not drawn. */ - objectScreen(objectId: string): Vec2 | null { - const view = this.currentView(); - const item = this.lastItems.find((i) => i.id === objectId); - if (!item || !view) return null; - const local = this.worldToScreen(item.x, item.y, view); - const origin = this.getGlobalPosition(); - return { x: origin.x + local.x, y: origin.y + local.y }; - } - - override measure(): Size { - return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) }; - } - - // --- transforms ------------------------------------------------------------------------ + // --- transforms ------------------------------------------------------------------------------ private editView(): View { return { x: this.view.x, y: this.view.y, scale: this.view.zoom, rect: { x: 0, y: 0, w: this.w, h: this.h } }; @@ -344,42 +351,7 @@ export class Viewport extends Widget { return { x: (wx - view.x) * view.scale + cx, y: (wy - view.y) * view.scale + cy }; } - // --- drawing --------------------------------------------------------------------------- - - private collectItems(): { items: DrawItem[]; background: number } { - if (this.game) { - // Symbols (camera, behaviors) are editor-only; the running game shows scene content. - const world = this.playWorld; - if (!world) return { items: [], background: LETTERBOX_COLOR }; - return { - background: world.backgroundColor, - // Symbols (camera, behaviors) are editor-only, switched-off objects do not exist for the game. - items: world.entities - .filter((e) => !isSymbolType(e.type) && isHandleEnabled(e)) - .map((e) => ({ id: e.objectId, type: e.type, name: e.name, ...world.worldPosition(e), props: e.props, parentId: e.parent?.objectId ?? '', enabled: true })), - }; - } - const scene = this.state.activeScene; - if (!scene) return { items: [], background: Theme.colors.viewport }; - const items: DrawItem[] = []; - // Tree order (parents before children) is the tie-break for equal z. - for (const { object } of treeOrder(scene)) { - const asset = findAsset(this.state.project, object.assetId); - if (!asset) continue; - // `editorPosition`, not `worldPosition`: the scene view honours the editor offsets, which - // shift how things are drawn (and hit) here without touching the model the game runs on. - items.push({ id: object.id, type: asset.type, name: object.name || asset.name, ...editorPosition(scene, object), props: resolveSceneObjectProps(asset, object), parentId: object.parentId, enabled: true }); - } - // Effective switch of every item: off when it or any parent is off (it is then not drawn). - const byId = new Map(items.map((i) => [i.id, i])); - for (const item of items) item.enabled = enabledAlongChain(item, (i) => i.props, (i) => byId.get(i.parentId) ?? null); - return { items, background: Number(scene.props.backgroundColor ?? Theme.colors.viewport) }; - } - - /** Size of an item's footprint in world units (sprite box, text box, camera frame …). */ - private itemSize(item: DrawItem): { w: number; h: number } { - return footprintOf(item.type, item.props); - } + // --- drawing --------------------------------------------------------------------------------- override redraw(): void { const editing = !this.game; @@ -396,7 +368,9 @@ export class Viewport extends Widget { this.visibleCount = 0; if (!view) return; - const { items, background } = this.collectItems(); + const { items, background } = this.game + ? this.playWorld ? collectWorldItems(this.playWorld) : { items: [], background: LETTERBOX_COLOR } + : collectSceneItems(this.state, Theme.colors.viewport); this.lastItems = items; const { rect } = view; if (this.game || this.state.activeScene) this.bg.rect(rect.x, rect.y, rect.w, rect.h).fill(background); @@ -410,7 +384,7 @@ export class Viewport extends Widget { const seen = new Set(); for (const item of items) { seen.add(item.id); - const size = this.itemSize(item); + const size = footprintOf(item.type, item.props); // A switched-off object is not drawn at all – in the scene view as in the game. It stays // in the hierarchy panel, which is where it is selected and switched back on. const onScreen = item.enabled && rectsIntersect(rectAround(item.x, item.y, size.w, size.h), bounds); @@ -421,59 +395,26 @@ export class Viewport extends Widget { node.cursor = 'move'; node.on('pointerdown', (e: FederatedPointerEvent) => this.onItemPointerDown(item.id, e)); this.nodes.set(item.id, node); - (this.isSymbol(item) ? this.overlay : this.objects).addChild(node); + (isSymbolType(item.type) ? this.overlay : this.objects).addChild(node); } node.visible = onScreen; node.eventMode = editing && onScreen ? 'static' : 'none'; - this.syncLabel(item, editing && onScreen, view.scale); + this.syncLabel(item, editing && onScreen, view.scale, size); this.syncText(item, onScreen, view.scale); if (!onScreen) continue; this.visibleCount++; node.position.set(item.x, item.y); - node.clear(); - if (item.type === 'sprite') { - node.rect(-size.w / 2, -size.h / 2, size.w, size.h).fill(Number(item.props.color ?? 0xffffff)); - } else if (item.type === 'camera') { - node.rect(-size.w / 2, -size.h / 2, size.w, size.h).stroke({ width: 2 / view.scale, color: Theme.colors.textMuted }); - node.rect(-12, -8, 24, 16).fill(Theme.colors.textMuted); - node.moveTo(12, 0).lineTo(20, -6).lineTo(20, 6).closePath().fill(Theme.colors.textMuted); - } else if (item.type === 'behavior') { - // A diamond with a small "circuit" mark: the logic symbol. - const r = BEHAVIOR_SYMBOL / 2; - node.moveTo(0, -r).lineTo(r, 0).lineTo(0, r).lineTo(-r, 0).closePath().fill(Theme.colors.behavior); - node.moveTo(-6, -3).lineTo(-1, -3).lineTo(-1, 3).lineTo(6, 3).stroke({ width: 2, color: Theme.colors.accentText }); - node.circle(-6, -3, 2).fill(Theme.colors.accentText); - node.circle(6, 3, 2).fill(Theme.colors.accentText); - } else if (item.type === 'container') { - // A square with four small boxes inside: the group symbol. Its content is drawn as - // ordinary objects (they are ordinary objects, parented below the container). - const r = CONTAINER_SYMBOL / 2; - node.roundRect(-r, -r, CONTAINER_SYMBOL, CONTAINER_SYMBOL, 3).fill({ color: Theme.colors.container, alpha: 0.25 }).stroke({ width: 2, color: Theme.colors.container }); - for (const [bx, by] of [[-7, -7], [1, -7], [-7, 1], [1, 1]] as const) node.rect(bx, by, 6, 6).fill(Theme.colors.container); - } else if (item.type === 'text') { - node.hitArea = new Rectangle(-size.w / 2 - (textAnchor(item.props) - 0.5) * size.w, -size.h / 2, size.w, size.h); - } else if (item.type === 'collider') { - // Outline plus a faint fill so the shape reads as "area". Colliders usually sit right on - // top of their body, so only the outline band is hit-testable: clicks inside still reach - // the sprite underneath, the edge grabs the collider. - const shape = colliderShape(item.props, { x: 0, y: 0 }); - if (shape.kind === 'circle') node.circle(0, 0, shape.r); - else node.rect(-shape.w / 2, -shape.h / 2, shape.w, shape.h); - const color = isTrigger(item.props) ? Theme.colors.trigger : Theme.colors.collider; - node.fill({ color, alpha: 0.12 }).stroke({ width: 2 / view.scale, color }); - node.hitArea = colliderOutlineHitArea(shape, COLLIDER_GRAB_BAND / view.scale); - } + drawItem(node, item, size, view.scale); } this.drawParentLinks(items, editing, view.scale); for (const [id, node] of this.nodes) { - if (!seen.has(id)) { - this.texts.get(id)?.destroy(); - this.texts.delete(id); - node.destroy(); - this.nodes.delete(id); - this.labels.get(id)?.destroy(); - this.labels.delete(id); - } + if (seen.has(id)) continue; + this.texts.get(id)?.destroy(); + this.texts.delete(id); + node.destroy(); + this.nodes.delete(id); + this.labels.get(id)?.destroy(); + this.labels.delete(id); } this.syncDrawOrder(items); @@ -486,7 +427,7 @@ export class Viewport extends Widget { * deterministic and hierarchy changes are honoured too. */ private syncDrawOrder(items: DrawItem[]): void { - const ordered = sortByZ(items.filter((item) => !this.isSymbol(item))); + const ordered = sortByZ(items.filter((item) => !isSymbolType(item.type))); this.objects.removeChildren(); for (const item of ordered) { const node = this.nodes.get(item.id); @@ -497,11 +438,6 @@ export class Viewport extends Widget { } } - /** Items drawn as editor symbols rather than as scene content. */ - private isSymbol(item: DrawItem): boolean { - return isSymbolType(item.type); - } - /** * Dashed line from every parented symbol (behavior, camera) to its parent (edit mode only). * Sprites show their nesting in the hierarchy panel instead, to keep the scene uncluttered; @@ -512,7 +448,7 @@ export class Viewport extends Widget { if (!editing) return; const byId = new Map(items.map((i) => [i.id, i])); for (const item of items) { - if (!this.isSymbol(item) || !item.parentId || item.type === 'collider' || !item.enabled) continue; + if (!isSymbolType(item.type) || !item.parentId || item.type === 'collider' || !item.enabled) continue; const parent = byId.get(item.parentId); if (!parent || !parent.enabled) continue; const color = item.type === 'behavior' ? Theme.colors.behavior : Theme.colors.textMuted; @@ -523,9 +459,9 @@ export class Viewport extends Widget { } /** - * Content of a text object: a Pixi Text parented to the item's node, so it moves, scales and - * fades with it. Its resolution follows the zoom in coarse steps – re-rendering the glyph - * texture on every wheel notch would be wasteful, blurry text is worse. + * Content of a text object: a Pixi Text kept next to the item's node. Its resolution follows + * the zoom in coarse steps – re-rendering the glyph texture on every wheel notch would be + * wasteful, blurry text is worse. */ private syncText(item: DrawItem, onScreen: boolean, scale: number): void { let text = this.texts.get(item.id); @@ -551,9 +487,10 @@ export class Viewport extends Widget { if (text.resolution !== resolution) text.resolution = resolution; } - private syncLabel(item: DrawItem, show: boolean, scale: number): void { + /** Name label next to a symbol (scene view only). */ + private syncLabel(item: DrawItem, show: boolean, scale: number, size: { w: number; h: number }): void { let label = this.labels.get(item.id); - if (!show || !this.isSymbol(item)) { + if (!show || !isSymbolType(item.type)) { if (label) label.visible = false; return; } @@ -563,7 +500,6 @@ export class Viewport extends Widget { this.labels.set(item.id, label); this.overlay.addChild(label); } - const size = this.itemSize(item); label.visible = true; label.text = item.name; label.scale.set(1 / scale); @@ -594,7 +530,7 @@ export class Viewport extends Widget { // Nothing is outlined for a switched-off object – it is not drawn either. const item = items.find((i) => i.id === id); if (!item || !item.enabled) continue; - const { w, h } = this.itemSize(item); + const { w, h } = footprintOf(item.type, item.props); const tl = this.worldToScreen(item.x - w / 2, item.y - h / 2, view); const sw = w * view.scale; const sh = h * view.scale; @@ -614,49 +550,4 @@ export class Viewport extends Widget { this.gizmos.rect(x, y, w, h).fill({ color: Theme.colors.marquee, alpha: 0.12 }).stroke({ width: 1, color: Theme.colors.marquee }); } } - - /** Rubber band released: selects every object whose world position lies inside it. */ - private selectInMarquee(marquee: Marquee): void { - const scene = this.state.activeScene; - if (!scene) return; - const a = this.screenToWorld(marquee.start.x, marquee.start.y); - const b = this.screenToWorld(marquee.current.x, marquee.current.y); - const minX = Math.min(a.x, b.x); - const maxX = Math.max(a.x, b.x); - const minY = Math.min(a.y, b.y); - const maxY = Math.max(a.y, b.y); - const ids = this.lastItems.filter((i) => i.enabled && i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY).map((i) => i.id); - this.state.select(objectSelection(scene.id, ids)); - } - - private onItemPointerDown(objectId: string, e: FederatedPointerEvent): void { - if (this.game || e.button !== 0) return; - const scene = this.state.activeScene; - const object = scene?.objects.find((o) => o.id === objectId); - if (!scene || !object) return; - e.stopPropagation(); - if (e.ctrlKey || e.metaKey || e.shiftKey) { - this.state.toggleObject(scene.id, objectId); - return; - } - // Clicking a selected object keeps the selection so the whole group can be dragged. - if (!this.state.selectedObjectIds.includes(objectId)) this.state.select(objectSelection(scene.id, [objectId])); - // Only the tops of the selected subtrees move; their descendants follow through the hierarchy. - const roots = subtreeRoots(scene, this.state.selectedObjectIds); - const items = roots.map((id) => scene.objects.find((o) => o.id === id)).filter((o): o is NonNullable => Boolean(o)).map((o) => ({ objectId: o.id, start: { ...o.position } })); - const local = this.toLocal(e.global); - this.dragging = { sceneId: scene.id, items, pointer: { x: local.x, y: local.y }, moved: false }; - } -} - -/** Adds a dashed line path to `g` (Pixi has no dash style); the caller strokes it. */ -export function dashedLine(g: Graphics, x1: number, y1: number, x2: number, y2: number, dash: number, gap: number): void { - const length = Math.hypot(x2 - x1, y2 - y1); - if (length === 0) return; - const ux = (x2 - x1) / length; - const uy = (y2 - y1) / length; - for (let d = 0; d < length; d += dash + gap) { - const end = Math.min(length, d + dash); - g.moveTo(x1 + ux * d, y1 + uy * d).lineTo(x1 + ux * end, y1 + uy * end); - } } diff --git a/src/viewport/items.ts b/src/viewport/items.ts new file mode 100644 index 00000000..95873aec --- /dev/null +++ b/src/viewport/items.ts @@ -0,0 +1,66 @@ +import { isHandleEnabled, worldPositionOf } from '../core/graph'; +import type { AssetType } from '../core/model/AssetTypes'; +import { editorPosition, treeOrder } from '../core/model/hierarchy'; +import type { PropertyBag } from '../core/model/Properties'; +import { findAsset } from '../core/model/Project'; +import { enabledAlongChain } from '../core/properties/enabled'; +import { resolveSceneObjectProps } from '../core/properties/resolve'; +import type { World } from '../runtime/World'; +import { isSymbolType } from '../core/model/AssetTypes'; +import type { EditorState } from '../editor/state'; + +/** What the viewport draws: the same shape whether it comes from the model or the world. */ +export interface DrawItem { + id: string; + type: AssetType; + name: string; + /** + * World position (parents already applied) – in the scene view including the editor + * offsets along the chain, which is what makes an offset object be drawn, selected and + * dragged at the shifted place. `z` is the draw order. + */ + x: number; + y: number; + z: number; + props: PropertyBag; + /** Id of the parent item, empty when top-level. */ + parentId: string; + /** Effective switch: this object and all its parents are on (see `properties/enabled.ts`). */ + enabled: boolean; +} + +export interface DrawList { + items: DrawItem[]; + background: number; +} + +/** The scene of the model, in tree order (the tie-break for equal `z`), drawn at the editor positions. */ +export function collectSceneItems(state: EditorState, fallbackBackground: number): DrawList { + const scene = state.activeScene; + if (!scene) return { items: [], background: fallbackBackground }; + const items: DrawItem[] = []; + for (const { object } of treeOrder(scene)) { + const asset = findAsset(state.project, object.assetId); + if (!asset) continue; + items.push({ + id: object.id, + type: asset.type, + name: object.name || asset.name, + ...editorPosition(scene, object), + props: resolveSceneObjectProps(asset, object), + parentId: object.parentId, + enabled: true, + }); + } + const byId = new Map(items.map((i) => [i.id, i])); + for (const item of items) item.enabled = enabledAlongChain(item, (i) => i.props, (i) => byId.get(i.parentId) ?? null); + return { items, background: Number(scene.props.backgroundColor ?? fallbackBackground) }; +} + +/** The running world: scene content only – symbols are editor-only, switched-off objects do not exist for the game. */ +export function collectWorldItems(world: World): DrawList { + const items = world.entities + .filter((e) => !isSymbolType(e.type) && isHandleEnabled(e)) + .map((e) => ({ id: e.objectId, type: e.type, name: e.name, ...worldPositionOf(e), props: e.props, parentId: e.parent?.objectId ?? '', enabled: true })); + return { items, background: world.backgroundColor }; +} diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts new file mode 100644 index 00000000..a47aae71 --- /dev/null +++ b/src/viewport/symbols.ts @@ -0,0 +1,92 @@ +/** + * How the scene view draws its items: sprites as boxes, text as a hit box (the glyphs are a + * separate Pixi Text), and the editor symbols for cameras, behaviors, containers and + * colliders. Everything here draws in world units at the item's origin. + */ +import { Graphics, Rectangle } from 'pixi.js'; +import { BEHAVIOR_SYMBOL, CONTAINER_SYMBOL, textAnchor } from '../core/properties/bounds'; +import { type ColliderShape, colliderShape, isTrigger } from '../core/properties/collider'; +import { Theme } from '../ui'; +import type { DrawItem } from './items'; + +/** Screen-pixel half-width of the band along a collider's outline that grabs the collider. */ +const COLLIDER_GRAB_BAND = 6; + +/** + * Draws `item` into its node. `scale` is the current view scale (pixels per world unit), so + * strokes and hit bands can stay constant on screen. + */ +export function drawItem(node: Graphics, item: DrawItem, size: { w: number; h: number }, scale: number): void { + node.clear(); + node.hitArea = null; + switch (item.type) { + case 'sprite': + node.rect(-size.w / 2, -size.h / 2, size.w, size.h).fill(Number(item.props.color ?? 0xffffff)); + return; + case 'camera': + node.rect(-size.w / 2, -size.h / 2, size.w, size.h).stroke({ width: 2 / scale, color: Theme.colors.textMuted }); + node.rect(-12, -8, 24, 16).fill(Theme.colors.textMuted); + node.moveTo(12, 0).lineTo(20, -6).lineTo(20, 6).closePath().fill(Theme.colors.textMuted); + return; + case 'behavior': { + // A diamond with a small "circuit" mark: the logic symbol. + const r = BEHAVIOR_SYMBOL / 2; + node.moveTo(0, -r).lineTo(r, 0).lineTo(0, r).lineTo(-r, 0).closePath().fill(Theme.colors.behavior); + node.moveTo(-6, -3).lineTo(-1, -3).lineTo(-1, 3).lineTo(6, 3).stroke({ width: 2, color: Theme.colors.accentText }); + node.circle(-6, -3, 2).fill(Theme.colors.accentText); + node.circle(6, 3, 2).fill(Theme.colors.accentText); + return; + } + case 'container': { + // A square with four small boxes inside: the group symbol. Its content is drawn as + // ordinary objects (they are ordinary objects, parented below the container). + const r = CONTAINER_SYMBOL / 2; + node.roundRect(-r, -r, CONTAINER_SYMBOL, CONTAINER_SYMBOL, 3).fill({ color: Theme.colors.container, alpha: 0.25 }).stroke({ width: 2, color: Theme.colors.container }); + for (const [bx, by] of [[-7, -7], [1, -7], [-7, 1], [1, 1]] as const) node.rect(bx, by, 6, 6).fill(Theme.colors.container); + return; + } + case 'text': + node.hitArea = new Rectangle(-size.w / 2 - (textAnchor(item.props) - 0.5) * size.w, -size.h / 2, size.w, size.h); + return; + case 'collider': { + // Outline plus a faint fill so the shape reads as "area". Colliders usually sit right on + // top of their body, so only the outline band is hit-testable: clicks inside still reach + // the sprite underneath, the edge grabs the collider. + const shape = colliderShape(item.props, { x: 0, y: 0 }); + if (shape.kind === 'circle') node.circle(0, 0, shape.r); + else node.rect(-shape.w / 2, -shape.h / 2, shape.w, shape.h); + const color = isTrigger(item.props) ? Theme.colors.trigger : Theme.colors.collider; + node.fill({ color, alpha: 0.12 }).stroke({ width: 2 / scale, color }); + node.hitArea = colliderOutlineHitArea(shape, COLLIDER_GRAB_BAND / scale); + return; + } + case 'scene': + return; + } +} + +/** Hit area covering only the outline band of a collider shape (local coordinates, world units). */ +function colliderOutlineHitArea(shape: ColliderShape, band: number): { contains(x: number, y: number): boolean } { + if (shape.kind === 'circle') return { contains: (x, y) => Math.abs(Math.hypot(x, y) - shape.r) <= band }; + const hw = shape.w / 2; + const hh = shape.h / 2; + return { + contains: (x, y) => { + const ax = Math.abs(x); + const ay = Math.abs(y); + return ax <= hw + band && ay <= hh + band && (ax >= hw - band || ay >= hh - band); + }, + }; +} + +/** Adds a dashed line path to `g` (Pixi has no dash style); the caller strokes it. */ +export function dashedLine(g: Graphics, x1: number, y1: number, x2: number, y2: number, dash: number, gap: number): void { + const length = Math.hypot(x2 - x1, y2 - y1); + if (length === 0) return; + const ux = (x2 - x1) / length; + const uy = (y2 - y1) / length; + for (let d = 0; d < length; d += dash + gap) { + const end = Math.min(length, d + dash); + g.moveTo(x1 + ux * d, y1 + uy * d).lineTo(x1 + ux * end, y1 + uy * end); + } +} diff --git a/tests/core/apply.test.ts b/tests/core/apply.test.ts index 64236f33..7d537546 100644 --- a/tests/core/apply.test.ts +++ b/tests/core/apply.test.ts @@ -6,45 +6,34 @@ import { RevertOverrideToTemplateCommand, RevertPositionToTemplateCommand, } from '../../src/core/commands/applyCommands'; -import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; -import { CommandStack } from '../../src/core/commands/CommandStack'; import { CreateContainerCommand } from '../../src/core/commands/containerCommands'; -import { MoveSceneObjectCommand, PlaceAssetCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; +import { MoveSceneObjectCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; import type { ContainerAsset } from '../../src/core/model/Asset'; import { templateLinkOf } from '../../src/core/model/container'; -import { createEmptyProject, findAsset, findScene } from '../../src/core/model/Project'; +import { findAsset } from '../../src/core/model/Project'; +import { makeSpriteScene, place } from '../helpers'; /** - * A box asset, a container made from two boxes (a at (100,100), b at (10,0)), and a second - * placed instance of that container. `a` is the original, `a2` its copy in the instance. + * A container made from two boxes (a at (100,100), b at (10,0)) and a second placed instance + * of it. `a` is the original, `a2` its copy in the instance. */ function setup() { - const project = createEmptyProject('p', 'Apply'); - const stack = new CommandStack(project); - const scene = new CreateAssetCommand('scene', 'Level'); - const sprite = new CreateAssetCommand('sprite', 'Box'); - stack.execute(scene); - stack.execute(sprite); - const a = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 100, y: 100 }); - const b = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 10, y: 0 }); - stack.execute(a); - stack.execute(b); - const create = new CreateContainerCommand(scene.asset.id, [a.objectId, b.objectId]); - stack.execute(create); - const place = new PlaceAssetCommand(scene.asset.id, create.assetId, { x: 1000, y: 0 }); - stack.execute(place); - const sceneOf = () => findScene(project, scene.asset.id)!; - const object = (id: string) => sceneOf().objects.find((o) => o.id === id)!; - const container = () => findAsset(project, create.assetId) as ContainerAsset; - const templateOfA = () => container().objects.find((t) => t.id === object(a.objectId).templateId)!; - const a2 = sceneOf().objects.find((o) => o.parentId === place.objectId && o.templateId === object(a.objectId).templateId)!.id; - return { project, stack, sceneId: scene.asset.id, spriteId: sprite.asset.id, a: a.objectId, b: b.objectId, a2, object, container, templateOfA }; + const f = makeSpriteScene(); + const a = place(f, f.spriteId, { x: 100, y: 100 }); + const b = place(f, f.spriteId, { x: 10, y: 0 }); + const create = new CreateContainerCommand(f.sceneId, [a, b]); + f.stack.execute(create); + const instance = place(f, create.assetId, { x: 1000, y: 0 }); + const container = () => findAsset(f.project, create.assetId) as ContainerAsset; + const templateOfA = () => container().objects.find((t) => t.id === f.object(a).templateId)!; + const a2 = f.sceneOf().objects.find((o) => o.parentId === instance && o.templateId === f.object(a).templateId)!.id; + return { ...f, a, b, a2, container, templateOfA }; } describe('template link', () => { it('resolves the container and template for instance objects only', () => { - const { project, sceneId, a, a2, object, container, templateOfA } = setup(); - const scene = findScene(project, sceneId)!; + const { project, a, a2, sceneOf, object, container, templateOfA } = setup(); + const scene = sceneOf(); const link = templateLinkOf(project, scene, object(a2)); expect(link?.asset.id).toBe(container().id); expect(link?.template).toBe(templateOfA()); @@ -89,11 +78,10 @@ describe('position apply / revert', () => { }); it('rejects objects that are not part of a container', () => { - const { project, stack, sceneId, spriteId } = setup(); - const loose = new PlaceAssetCommand(sceneId, spriteId, { x: 0, y: 0 }); - stack.execute(loose); - expect(() => stack.execute(new ApplyPositionToContainerCommand(sceneId, loose.objectId))).toThrow(/not part of a placed container/); - expect(findScene(project, sceneId)!.objects.find((o) => o.id === loose.objectId)).toBeDefined(); + const f = setup(); + const loose = place(f, f.spriteId, { x: 0, y: 0 }); + expect(() => f.stack.execute(new ApplyPositionToContainerCommand(f.sceneId, loose))).toThrow(/not part of a placed container/); + expect(f.sceneOf().objects.find((o) => o.id === loose)).toBeDefined(); }); }); @@ -138,14 +126,14 @@ describe('override apply / revert', () => { }); it('applies to the asset for a plain object too', () => { - const { project, stack, sceneId, spriteId, object } = setup(); - const loose = new PlaceAssetCommand(sceneId, spriteId, { x: 0, y: 0 }); - stack.execute(loose); - stack.execute(new SetOverrideCommand(sceneId, loose.objectId, 'height', 33)); - stack.execute(new ApplyOverrideToAssetCommand(sceneId, loose.objectId, 'height')); + const f = setup(); + const { project, stack, sceneId, spriteId, object } = f; + const loose = place(f, spriteId, { x: 0, y: 0 }); + stack.execute(new SetOverrideCommand(sceneId, loose, 'height', 33)); + stack.execute(new ApplyOverrideToAssetCommand(sceneId, loose, 'height')); expect(findAsset(project, spriteId)!.props.height).toBe(33); - expect(object(loose.objectId).overrides.height).toBeUndefined(); - expect(() => stack.execute(new ApplyOverrideToAssetCommand(sceneId, loose.objectId, 'nope'))).toThrow(/not defined/); + expect(object(loose).overrides.height).toBeUndefined(); + expect(() => stack.execute(new ApplyOverrideToAssetCommand(sceneId, loose, 'nope'))).toThrow(/not defined/); }); it('reverts an override to the template value (or removes it when the template has none)', () => { diff --git a/tests/core/collider.test.ts b/tests/core/collider.test.ts index 022df6b4..8e8ed278 100644 --- a/tests/core/collider.test.ts +++ b/tests/core/collider.test.ts @@ -1,17 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; import { AddColliderCommand } from '../../src/core/commands/colliderCommands'; -import { CommandStack } from '../../src/core/commands/CommandStack'; -import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands'; -import { PlaceAssetCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; -import type { BehaviorAsset } from '../../src/core/model/Asset'; -import { createEmptyProject, findScene } from '../../src/core/model/Project'; +import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; +import { findAsset } from '../../src/core/model/Project'; import { axisCorrection, axisSeparation, colliderShape, shapesOverlap } from '../../src/core/properties/collider'; -import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; -import { BehaviorSystem } from '../../src/runtime/systems/BehaviorSystem'; -import { CollisionSystem } from '../../src/runtime/systems/CollisionSystem'; -import { Input } from '../../src/runtime/systems/Input'; -import { World } from '../../src/runtime/World'; +import { graphEditor, makeBehavior, makeSpriteScene, place, roundTrip, startWorld } from '../helpers'; const box = (x: number, y: number, w: number, h: number) => ({ kind: 'box' as const, x, y, w, h }); const circle = (x: number, y: number, r: number) => ({ kind: 'circle' as const, x, y, r }); @@ -56,44 +49,43 @@ describe('collider geometry', () => { }); }); -/** A scene with two crates (100×60) 300 apart, each with a collider child; a behavior on crate A. */ +/** Two crates (100×60) 300 apart, each with a collider child; a behavior "Hit" placed below crate a. */ function setup() { - const project = createEmptyProject('p', 'Colliders'); - const stack = new CommandStack(project); - const scene = new CreateAssetCommand('scene', 'Level'); - const sprite = new CreateAssetCommand('sprite', 'Crate'); - const behavior = new CreateAssetCommand('behavior', 'Hit'); - for (const c of [scene, sprite, behavior]) stack.execute(c); - sprite.asset.props.height = 60; - const a = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 0 }); - const b = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 300, y: 0 }); - stack.execute(a); - stack.execute(b); - const colA = new AddColliderCommand(scene.asset.id, a.objectId); - const colB = new AddColliderCommand(scene.asset.id, b.objectId); - stack.execute(colA); - stack.execute(colB); - const place = new PlaceAssetCommand(scene.asset.id, behavior.asset.id, { x: 0, y: 0 }, a.objectId); - stack.execute(place); - return { project, stack, sceneId: scene.asset.id, spriteId: sprite.asset.id, behavior: behavior.asset as BehaviorAsset, a: a.objectId, b: b.objectId, colA, colB }; + const f = makeSpriteScene('Crate'); + f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 60)); + const behavior = makeBehavior(f, 'Hit'); + const a = place(f, f.spriteId, { x: 0, y: 0 }); + const b = place(f, f.spriteId, { x: 300, y: 0 }); + const colA = new AddColliderCommand(f.sceneId, a); + const colB = new AddColliderCommand(f.sceneId, b); + f.stack.execute(colA); + f.stack.execute(colB); + place(f, behavior.id, { x: 0, y: 0 }, a); + return { ...f, behavior, a, b, colA, colB }; +} + +/** The crates in a running world with collision detection. */ +function runtime() { + const s = setup(); + const { world, collisions } = startWorld(s, { collisions: true }); + return { ...s, world, collisions, crateA: world.findByObjectId(s.a)!, crateB: world.findByObjectId(s.b)! }; } describe('AddColliderCommand', () => { it('creates a collider asset sized like the sprite, parented to it; undo removes both', () => { - const { project, stack, sceneId, a, colA } = setup(); - const asset = project.assets.find((x) => x.id === colA.assetId)!; + const { project, stack, sceneOf, object, a, colA } = setup(); + const asset = findAsset(project, colA.assetId)!; expect(asset.type).toBe('collider'); expect(asset.name).toBe('Crate collider'); expect(asset.props).toMatchObject({ shape: 'box', width: 100, height: 60 }); - const object = findScene(project, sceneId)!.objects.find((o) => o.id === colA.objectId)!; - expect(object.parentId).toBe(a); - expect(object.position).toEqual({ x: 0, y: 0, z: 0 }); + expect(object(colA.objectId).parentId).toBe(a); + expect(object(colA.objectId).position).toEqual({ x: 0, y: 0, z: 0 }); // Undo of the last three commands: behavior placement, collider B, collider A. stack.undo(); stack.undo(); stack.undo(); expect(project.assets.some((x) => x.id === colA.assetId)).toBe(false); - expect(findScene(project, sceneId)!.objects.some((o) => o.id === colA.objectId)).toBe(false); + expect(sceneOf().objects.some((o) => o.id === colA.objectId)).toBe(false); stack.redo(); expect(project.assets.some((x) => x.id === colA.assetId)).toBe(true); }); @@ -101,19 +93,13 @@ describe('AddColliderCommand', () => { it('refuses a collider on a collider and survives a round trip', () => { const { project, stack, sceneId, colA } = setup(); expect(() => stack.execute(new AddColliderCommand(sceneId, colA.objectId))).toThrow(/cannot have a collider/); - const loaded = deserializeProject(serializeProject(project)); - expect(loaded.assets.find((x) => x.id === colA.assetId)?.props).toMatchObject({ shape: 'box', width: 100, height: 60, radius: 50 }); + expect(findAsset(roundTrip(project), colA.assetId)?.props).toMatchObject({ shape: 'box', width: 100, height: 60, radius: 50 }); }); }); describe('CollisionSystem', () => { it('reports enter, stay and exit as colliders move (parents carry their colliders)', () => { - const { project, sceneId, a, b } = setup(); - const world = World.instantiate(project, sceneId); - const collisions = new CollisionSystem(); - world.addSystem(collisions); - const crateA = world.findByObjectId(a)!; - const crateB = world.findByObjectId(b)!; + const { world, collisions, crateA, crateB } = runtime(); world.tick(0.1); expect(collisions.enters).toHaveLength(0); crateB.x = 90; // boxes of width 100 overlap once the centres are closer than 100 @@ -132,14 +118,13 @@ describe('CollisionSystem', () => { }); it('respects overrides and circles; two colliders of the same body never collide', () => { - const { project, stack, sceneId, a, b, colA, colB } = setup(); + const s = setup(); + const { stack, sceneId, a, b, colA, colB } = s; stack.execute(new SetOverrideCommand(sceneId, colB.objectId, 'shape', 'circle')); stack.execute(new SetOverrideCommand(sceneId, colB.objectId, 'radius', 30)); const second = new AddColliderCommand(sceneId, a); stack.execute(second); // a second collider on crate A, overlapping colA completely - const world = World.instantiate(project, sceneId); - const collisions = new CollisionSystem(); - world.addSystem(collisions); + const { world, collisions } = startWorld(s, { collisions: true }); world.tick(0.1); expect(collisions.enters).toHaveLength(0); // colA and the second collider share a body world.findByObjectId(b)!.x = 75; // circle r=30 at 75 vs box half-width 50 → overlap @@ -152,14 +137,6 @@ describe('CollisionSystem', () => { describe('move and slide', () => { const SKIN = 0.05; // mirrors CollisionSystem: overlaps this shallow count as contact - function runtime() { - const s = setup(); - const world = World.instantiate(s.project, s.sceneId); - const collisions = new CollisionSystem(); - world.addSystem(collisions); - return { ...s, world, collisions, crateA: world.findByObjectId(s.a)!, crateB: world.findByObjectId(s.b)! }; - } - it('stops flush at a solid collider and reports the blocked axis', () => { const { world, collisions, crateA } = runtime(); // Crates are 100 wide, 300 apart: 250 of travel is possible before they touch (centres 100 apart). @@ -179,12 +156,11 @@ describe('move and slide', () => { }); it('triggers report but never block; own and descendant colliders are ignored', () => { - const { project, stack, sceneId, a, b, colB } = setup(); + const s = setup(); + const { stack, sceneId, a, b, colB } = s; // Make B's collider a trigger → A passes through, but the CollisionSystem still sees the overlap. stack.execute(new SetOverrideCommand(sceneId, colB.objectId, 'isTrigger', true)); - const world = World.instantiate(project, sceneId); - const collisions = new CollisionSystem(); - world.addSystem(collisions); + const { world, collisions } = startWorld(s, { collisions: true }); const crateA = world.findByObjectId(a)!; expect(collisions.moveAndSlide(world, crateA, 250, 0)).toEqual({ blockedX: false, blockedY: false }); expect(crateA.x).toBe(250); @@ -229,24 +205,22 @@ describe('move and slide', () => { }); it('the Move node with "Collide with solids" moves the owner and outputs the blocked axes', () => { - const { project, stack, sceneId, behavior, a } = setup(); + const s = setup(); + const { behavior, a } = s; // On update → Move(Owner, dx = 1000, dt, collide) → Log(blocked x) - const update = new AddNodeCommand(behavior.id, 'OnUpdate', 0, 0); - const owner = new AddNodeCommand(behavior.id, 'Owner', 0, 0); - const move = new AddNodeCommand(behavior.id, 'Move', 0, 0, { dx: 1000, collide: true }); - const log = new AddNodeCommand(behavior.id, 'Log', 0, 0); - for (const c of [update, owner, move, log]) stack.execute(c); - const connect = (from: AddNodeCommand, fp: string, to: AddNodeCommand, tp: string) => - stack.execute(new ConnectCommand(behavior.id, { node: from.node.id, port: fp }, { node: to.node.id, port: tp })); - connect(update, 'exec', move, 'exec'); - connect(update, 'dt', move, 'dt'); - connect(owner, 'object', move, 'object'); - connect(move, 'exec', log, 'exec'); - connect(move, 'blockedX', log, 'message'); - const world = World.instantiate(project, sceneId); - const collisions = new CollisionSystem(); - world.addSystem(collisions); - world.addSystem(new BehaviorSystem(project, new Input(), collisions)); + const edit = graphEditor(s, behavior); + const update = edit.addNode('OnUpdate'); + const owner = edit.addNode('Owner'); + const move = edit.addNode('Move', { dx: 1000, collide: true }); + const log = edit.addNode('Log'); + edit.wire([ + [update, 'exec', move, 'exec'], + [update, 'dt', move, 'dt'], + [owner, 'object', move, 'object'], + [move, 'exec', log, 'exec'], + [move, 'blockedX', log, 'message'], + ]); + const { world } = startWorld(s, { collisions: true, behaviors: true }); const crateA = world.findByObjectId(a)!; world.tick(0.1); // 100 units: free expect(crateA.x).toBe(100); @@ -258,33 +232,31 @@ describe('move and slide', () => { describe('collision events in behaviors', () => { it('fires On collision enter/exit on the body\'s behavior with the other body; Is touching agrees', () => { - const { project, stack, sceneId, behavior, b } = setup(); - // Graph: On collision enter → Log(other); On collision exit → Log("bye"); On update → Log(Is touching(Owner, Object by name "B")) - const enter = new AddNodeCommand(behavior.id, 'OnCollisionEnter', 0, 0); - const logEnter = new AddNodeCommand(behavior.id, 'Log', 0, 0); - const exit = new AddNodeCommand(behavior.id, 'OnCollisionExit', 0, 0); - const logExit = new AddNodeCommand(behavior.id, 'Log', 0, 0, { message: 'bye' }); - const update = new AddNodeCommand(behavior.id, 'OnUpdate', 0, 0); - const owner = new AddNodeCommand(behavior.id, 'Owner', 0, 0); - const byName = new AddNodeCommand(behavior.id, 'ObjectByName', 0, 0, { name: 'B' }); - const touching = new AddNodeCommand(behavior.id, 'IsTouching', 0, 0); - const logTouch = new AddNodeCommand(behavior.id, 'Log', 0, 0); - for (const c of [enter, logEnter, exit, logExit, update, owner, byName, touching, logTouch]) stack.execute(c); - const connect = (from: AddNodeCommand, fp: string, to: AddNodeCommand, tp: string) => - stack.execute(new ConnectCommand(behavior.id, { node: from.node.id, port: fp }, { node: to.node.id, port: tp })); - connect(enter, 'exec', logEnter, 'exec'); - connect(enter, 'other', logEnter, 'message'); - connect(exit, 'exec', logExit, 'exec'); - connect(update, 'exec', logTouch, 'exec'); - connect(owner, 'object', touching, 'a'); - connect(byName, 'object', touching, 'b'); - connect(touching, 'result', logTouch, 'message'); - findScene(project, sceneId)!.objects.find((o) => o.id === b)!.name = 'B'; + const s = setup(); + const { behavior, b, object } = s; + // On collision enter → Log(other); On collision exit → Log("bye"); On update → Log(Is touching(Owner, Object by name "B")) + const edit = graphEditor(s, behavior); + const enter = edit.addNode('OnCollisionEnter'); + const logEnter = edit.addNode('Log'); + const exit = edit.addNode('OnCollisionExit'); + const logExit = edit.addNode('Log', { message: 'bye' }); + const update = edit.addNode('OnUpdate'); + const owner = edit.addNode('Owner'); + const byName = edit.addNode('ObjectByName', { name: 'B' }); + const touching = edit.addNode('IsTouching'); + const logTouch = edit.addNode('Log'); + edit.wire([ + [enter, 'exec', logEnter, 'exec'], + [enter, 'other', logEnter, 'message'], + [exit, 'exec', logExit, 'exec'], + [update, 'exec', logTouch, 'exec'], + [owner, 'object', touching, 'a'], + [byName, 'object', touching, 'b'], + [touching, 'result', logTouch, 'message'], + ]); + object(b).name = 'B'; - const world = World.instantiate(project, sceneId); - const collisions = new CollisionSystem(); - world.addSystem(collisions); - world.addSystem(new BehaviorSystem(project, new Input(), collisions)); + const { world } = startWorld(s, { collisions: true, behaviors: true }); const crateB = world.findByObjectId(b)!; world.tick(0.1); expect(world.log).toEqual(['false']); diff --git a/tests/core/commands.test.ts b/tests/core/commands.test.ts index f2672579..cfadffff 100644 --- a/tests/core/commands.test.ts +++ b/tests/core/commands.test.ts @@ -7,7 +7,6 @@ import { SetActiveSceneCommand, SetAssetPropertyCommand, } from '../../src/core/commands/assetCommands'; -import { CommandStack } from '../../src/core/commands/CommandStack'; import { ClearOverrideCommand, MoveSceneObjectCommand, @@ -15,31 +14,22 @@ import { RemoveSceneObjectCommand, SetOverrideCommand, } from '../../src/core/commands/sceneObjectCommands'; -import { createEmptyProject, findScene } from '../../src/core/model/Project'; - -function setup() { - const project = createEmptyProject('p1', 'Test'); - const stack = new CommandStack(project); - const scene = new CreateAssetCommand('scene', 'Level 1'); - const sprite = new CreateAssetCommand('sprite', 'Box'); - stack.execute(scene); - stack.execute(sprite); - return { project, stack, sceneId: scene.asset.id, spriteId: sprite.asset.id }; -} +import { findAsset } from '../../src/core/model/Project'; +import { makeSpriteScene, place } from '../helpers'; describe('asset commands', () => { it('creates assets with defaults and activates the first scene', () => { - const { project, sceneId, spriteId } = setup(); + const { project, sceneId, spriteId } = makeSpriteScene(); expect(project.assets).toHaveLength(2); expect(project.activeSceneId).toBe(sceneId); - expect(project.assets.find((a) => a.id === spriteId)?.props).toEqual({ enabled: true, width: 100, height: 100, color: 0xe0553f }); + expect(findAsset(project, spriteId)?.props).toEqual({ enabled: true, width: 100, height: 100, color: 0xe0553f }); }); it('renames and sets properties with undo/redo', () => { - const { project, stack, spriteId } = setup(); + const { project, stack, spriteId } = makeSpriteScene(); + const sprite = () => findAsset(project, spriteId)!; stack.execute(new RenameAssetCommand(spriteId, 'Crate')); stack.execute(new SetAssetPropertyCommand(spriteId, 'width', 250)); - const sprite = () => project.assets.find((a) => a.id === spriteId)!; expect(sprite().name).toBe('Crate'); expect(sprite().props.width).toBe(250); stack.undo(); @@ -53,14 +43,14 @@ describe('asset commands', () => { }); it('rejects unknown properties', () => { - const { stack, spriteId } = setup(); + const { stack, spriteId } = makeSpriteScene(); expect(() => stack.execute(new SetAssetPropertyCommand(spriteId, 'x', 5))).toThrow(/not defined/); expect(() => stack.execute(new ResetAssetPropertyCommand(spriteId, 'x'))).toThrow(/not defined/); }); it('resets a property to its schema default with undo', () => { - const { project, stack, spriteId } = setup(); - const sprite = () => project.assets.find((a) => a.id === spriteId)!; + const { project, stack, spriteId } = makeSpriteScene(); + const sprite = () => findAsset(project, spriteId)!; stack.execute(new SetAssetPropertyCommand(spriteId, 'height', 60)); stack.execute(new ResetAssetPropertyCommand(spriteId, 'height')); expect(sprite().props.height).toBe(100); @@ -71,23 +61,24 @@ describe('asset commands', () => { }); it('deletes an asset together with its scene objects and restores both on undo', () => { - const { project, stack, sceneId, spriteId } = setup(); - stack.execute(new PlaceAssetCommand(sceneId, spriteId, { x: 1, y: 2 })); - stack.execute(new PlaceAssetCommand(sceneId, spriteId, { x: 3, y: 4 })); - expect(findScene(project, sceneId)!.objects).toHaveLength(2); + const f = makeSpriteScene(); + const { project, stack, spriteId, sceneOf } = f; + place(f, spriteId, { x: 1, y: 2 }); + place(f, spriteId, { x: 3, y: 4 }); + expect(sceneOf().objects).toHaveLength(2); stack.execute(new DeleteAssetCommand(spriteId)); expect(project.assets).toHaveLength(1); - expect(findScene(project, sceneId)!.objects).toHaveLength(0); + expect(sceneOf().objects).toHaveLength(0); stack.undo(); expect(project.assets).toHaveLength(2); - expect(findScene(project, sceneId)!.objects.map((o) => o.position)).toEqual([ + expect(sceneOf().objects.map((o) => o.position)).toEqual([ { x: 1, y: 2, z: 0 }, { x: 3, y: 4, z: 0 }, ]); }); it('switches the active scene when the active one is deleted', () => { - const { project, stack, sceneId } = setup(); + const { project, stack, sceneId } = makeSpriteScene(); const second = new CreateAssetCommand('scene', 'Level 2'); stack.execute(second); stack.execute(new SetActiveSceneCommand(second.asset.id)); @@ -100,52 +91,51 @@ describe('asset commands', () => { describe('scene object commands', () => { it('places assets and refuses scenes', () => { - const { project, stack, sceneId, spriteId } = setup(); - const place = new PlaceAssetCommand(sceneId, spriteId, { x: 5, y: 6 }); - stack.execute(place); - const object = findScene(project, sceneId)!.objects[0]!; - expect(object.id).toBe(place.objectId); + const f = makeSpriteScene(); + const { stack, sceneId, spriteId, sceneOf } = f; + const box = place(f, spriteId, { x: 5, y: 6 }); + const object = sceneOf().objects[0]!; + expect(object.id).toBe(box); expect(object.assetId).toBe(spriteId); expect(object.position).toEqual({ x: 5, y: 6, z: 0 }); expect(object.overrides).toEqual({}); expect(() => stack.execute(new PlaceAssetCommand(sceneId, sceneId, { x: 0, y: 0 }))).toThrow(/cannot be placed/); - expect(findScene(project, sceneId)!.objects).toHaveLength(1); + expect(sceneOf().objects).toHaveLength(1); }); it('moves, overrides, clears and removes with undo', () => { - const { project, stack, sceneId, spriteId } = setup(); - const place = new PlaceAssetCommand(sceneId, spriteId, { x: 0, y: 0 }); - stack.execute(place); - const object = () => findScene(project, sceneId)!.objects.find((o) => o.id === place.objectId)!; + const f = makeSpriteScene(); + const { stack, sceneId, spriteId, sceneOf, object } = f; + const box = place(f, spriteId, { x: 0, y: 0 }); - stack.execute(new MoveSceneObjectCommand(sceneId, place.objectId, { x: 40, y: 50, z: 3 })); - expect(object().position).toEqual({ x: 40, y: 50, z: 3 }); + stack.execute(new MoveSceneObjectCommand(sceneId, box, { x: 40, y: 50, z: 3 })); + expect(object(box).position).toEqual({ x: 40, y: 50, z: 3 }); stack.undo(); - expect(object().position).toEqual({ x: 0, y: 0, z: 0 }); + expect(object(box).position).toEqual({ x: 0, y: 0, z: 0 }); stack.redo(); - stack.execute(new SetOverrideCommand(sceneId, place.objectId, 'color', 0x123456)); - expect(object().overrides).toEqual({ color: 0x123456 }); - expect(() => stack.execute(new SetOverrideCommand(sceneId, place.objectId, 'nope', 1))).toThrow(); + stack.execute(new SetOverrideCommand(sceneId, box, 'color', 0x123456)); + expect(object(box).overrides).toEqual({ color: 0x123456 }); + expect(() => stack.execute(new SetOverrideCommand(sceneId, box, 'nope', 1))).toThrow(); - stack.execute(new ClearOverrideCommand(sceneId, place.objectId, 'color')); - expect(object().overrides).toEqual({}); + stack.execute(new ClearOverrideCommand(sceneId, box, 'color')); + expect(object(box).overrides).toEqual({}); stack.undo(); - expect(object().overrides).toEqual({ color: 0x123456 }); + expect(object(box).overrides).toEqual({ color: 0x123456 }); - stack.execute(new RemoveSceneObjectCommand(sceneId, place.objectId)); - expect(findScene(project, sceneId)!.objects).toHaveLength(0); + stack.execute(new RemoveSceneObjectCommand(sceneId, box)); + expect(sceneOf().objects).toHaveLength(0); stack.undo(); - expect(object().position).toEqual({ x: 40, y: 50, z: 3 }); + expect(object(box).position).toEqual({ x: 40, y: 50, z: 3 }); }); it('clears the redo stack on a new command', () => { - const { stack, sceneId, spriteId } = setup(); - stack.execute(new PlaceAssetCommand(sceneId, spriteId, { x: 0, y: 0 })); - stack.undo(); - expect(stack.canRedo).toBe(true); - stack.execute(new PlaceAssetCommand(sceneId, spriteId, { x: 1, y: 1 })); - expect(stack.canRedo).toBe(false); + const f = makeSpriteScene(); + place(f, f.spriteId, { x: 0, y: 0 }); + f.stack.undo(); + expect(f.stack.canRedo).toBe(true); + place(f, f.spriteId, { x: 1, y: 1 }); + expect(f.stack.canRedo).toBe(false); }); }); diff --git a/tests/core/container.test.ts b/tests/core/container.test.ts index deeb5352..7cd5f3cf 100644 --- a/tests/core/container.test.ts +++ b/tests/core/container.test.ts @@ -1,35 +1,22 @@ import { describe, expect, it } from 'vitest'; -import { CreateAssetCommand, DeleteAssetCommand } from '../../src/core/commands/assetCommands'; -import { CommandStack } from '../../src/core/commands/CommandStack'; +import { DeleteAssetCommand } from '../../src/core/commands/assetCommands'; import { CompoundCommand } from '../../src/core/commands/CompoundCommand'; import { CreateContainerCommand } from '../../src/core/commands/containerCommands'; -import { MoveSceneObjectCommand, PlaceAssetCommand, RemoveSceneObjectsCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands'; +import { MoveSceneObjectCommand, RemoveSceneObjectsCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands'; +import { worldPositionOf } from '../../src/core/graph'; import type { ContainerAsset } from '../../src/core/model/Asset'; -import { subtreeRoots, treeOrder, worldPosition } from '../../src/core/model/hierarchy'; -import { createEmptyProject, findScene } from '../../src/core/model/Project'; -import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; -import { World } from '../../src/runtime/World'; +import { subtreeRoots, treeOrder } from '../../src/core/model/hierarchy'; +import { findAsset, findScene } from '../../src/core/model/Project'; +import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers'; -/** - * Scene with a sprite asset and three boxes: a at (100,100,z1), b at (10,0) and c at (0,5) - * parented under b (local (-10,5)). - */ +/** Three boxes: a at (100,100,z1), b at (10,0) and c at (0,5) parented under b (local (-10,5)). */ function setup() { - const project = createEmptyProject('p', 'Containers'); - const stack = new CommandStack(project); - const scene = new CreateAssetCommand('scene', 'Level'); - const sprite = new CreateAssetCommand('sprite', 'Box'); - stack.execute(scene); - stack.execute(sprite); - const a = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 100, y: 100, z: 1 }); - const b = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 10, y: 0 }); - const c = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 5 }); - for (const cmd of [a, b, c]) stack.execute(cmd); - stack.execute(new SetParentCommand(scene.asset.id, c.objectId, b.objectId)); - const sceneOf = () => findScene(project, scene.asset.id)!; - const object = (id: string) => sceneOf().objects.find((o) => o.id === id)!; - const world = (id: string) => worldPosition(sceneOf(), object(id)); - return { project, stack, sceneId: scene.asset.id, spriteId: sprite.asset.id, a: a.objectId, b: b.objectId, c: c.objectId, sceneOf, object, world }; + const f = makeSpriteScene(); + const a = place(f, f.spriteId, { x: 100, y: 100, z: 1 }); + const b = place(f, f.spriteId, { x: 10, y: 0 }); + const c = place(f, f.spriteId, { x: 0, y: 5 }); + f.stack.execute(new SetParentCommand(f.sceneId, c, b)); + return { ...f, a, b, c }; } describe('subtree roots', () => { @@ -60,7 +47,7 @@ describe('CreateContainerCommand', () => { expect({ a: world(a), b: world(b), c: world(c) }).toEqual(before); // The asset holds the same structure with private ids; the members point at it. - const asset = project.assets.find((x) => x.id === command.assetId) as ContainerAsset; + const asset = findAsset(project, command.assetId) as ContainerAsset; expect(asset.type).toBe('container'); expect(asset.objects).toHaveLength(3); const template = (id: string) => asset.objects.find((t) => t.id === object(id).templateId)!; @@ -83,17 +70,17 @@ describe('CreateContainerCommand', () => { }); it('places the container below the parent all roots share', () => { - const { stack, sceneId, spriteId, b, c, object, world } = setup(); - const d = new PlaceAssetCommand(sceneId, spriteId, { x: 30, y: 25 }, b); - stack.execute(d); - const command = new CreateContainerCommand(sceneId, [c, d.objectId]); + const f = setup(); + const { stack, sceneId, spriteId, b, c, object, world } = f; + const d = place(f, spriteId, { x: 30, y: 25 }, b); + const command = new CreateContainerCommand(sceneId, [c, d]); stack.execute(command); const container = object(command.objectId); expect(container.parentId).toBe(b); // World centre of c (0,5) and d (30,25) is (15,15); b is at (10,0) → local (5,15). expect(container.position).toEqual({ x: 5, y: 15, z: 0 }); expect(world(c)).toEqual({ x: 0, y: 5, z: 0 }); - expect(world(d.objectId)).toEqual({ x: 30, y: 25, z: 0 }); + expect(world(d)).toEqual({ x: 30, y: 25, z: 0 }); }); it('rejects an empty or unknown selection', () => { @@ -105,19 +92,19 @@ describe('CreateContainerCommand', () => { describe('placing a container', () => { it('instantiates the template with fresh ids below the placed object', () => { - const { project, stack, sceneId, a, b, c, sceneOf, object, world } = setup(); + const f = setup(); + const { project, stack, sceneId, a, b, c, sceneOf, object, world } = f; const create = new CreateContainerCommand(sceneId, [a, b]); stack.execute(create); - const place = new PlaceAssetCommand(sceneId, create.assetId, { x: 1000, y: 0 }); - stack.execute(place); + const placed = place(f, create.assetId, { x: 1000, y: 0 }); const copies = sceneOf().objects.filter((o) => ![a, b, c, create.objectId].includes(o.id)); expect(copies).toHaveLength(4); // container + 3 template objects - const root = object(place.objectId); + const root = object(placed); expect(root.assetId).toBe(create.assetId); - const asset = project.assets.find((x) => x.id === create.assetId) as ContainerAsset; + const asset = findAsset(project, create.assetId) as ContainerAsset; const copyOf = (id: string) => copies.find((o) => o.templateId === object(id).templateId)!; - expect(copyOf(a).parentId).toBe(place.objectId); + expect(copyOf(a).parentId).toBe(placed); expect(world(copyOf(a).id)).toEqual({ x: 1045, y: 50, z: 1 }); expect(copyOf(c).parentId).toBe(copyOf(b).id); expect(world(copyOf(c).id)).toEqual({ x: 945, y: -45, z: 0 }); @@ -126,15 +113,15 @@ describe('placing a container', () => { stack.undo(); expect(sceneOf().objects).toHaveLength(4); - expect(sceneOf().objects.some((o) => o.id === place.objectId)).toBe(false); + expect(sceneOf().objects.some((o) => o.id === placed)).toBe(false); }); it('survives a save/load round trip', () => { const { project, stack, sceneId, a, b } = setup(); const create = new CreateContainerCommand(sceneId, [a, b]); stack.execute(create); - const loaded = deserializeProject(serializeProject(project)); - const asset = loaded.assets.find((x) => x.id === create.assetId) as ContainerAsset; + const loaded = roundTrip(project); + const asset = findAsset(loaded, create.assetId) as ContainerAsset; expect(asset.type).toBe('container'); expect(asset.objects).toHaveLength(3); const scene = findScene(loaded, sceneId)!; @@ -143,43 +130,44 @@ describe('placing a container', () => { }); it('deleting an asset removes its template objects from containers too', () => { - const { project, stack, sceneId, spriteId, a, b } = setup(); + const { project, stack, sceneId, spriteId, a, b, sceneOf } = setup(); const create = new CreateContainerCommand(sceneId, [a, b]); stack.execute(create); - const asset = () => project.assets.find((x) => x.id === create.assetId) as ContainerAsset; + const asset = () => findAsset(project, create.assetId) as ContainerAsset; stack.execute(new DeleteAssetCommand(spriteId)); expect(asset().objects).toHaveLength(0); - expect(findScene(project, sceneId)!.objects.map((o) => o.id)).toEqual([create.objectId]); + expect(sceneOf().objects.map((o) => o.id)).toEqual([create.objectId]); stack.undo(); expect(asset().objects).toHaveLength(3); - expect(findScene(project, sceneId)!.objects).toHaveLength(4); + expect(sceneOf().objects).toHaveLength(4); }); it('spawns a container with its content at runtime', () => { - const { project, stack, sceneId, a, b } = setup(); + const f = setup(); + const { stack, sceneId, a, b, sceneOf } = f; const create = new CreateContainerCommand(sceneId, [a, b]); stack.execute(create); - const world = World.instantiate(project, sceneId); + const { world } = startWorld(f); const spawned = world.spawn(create.assetId, { x: 500, y: 500, z: 0 }, null)!; expect(spawned.type).toBe('container'); const children = world.entities.filter((e) => e.parent === spawned); expect(children).toHaveLength(2); const grandchild = world.entities.find((e) => e.parent && children.includes(e.parent))!; - expect(world.worldPosition(children.find((e) => e.z === 1)!)).toEqual({ x: 545, y: 550, z: 1 }); - expect(world.worldPosition(grandchild)).toEqual({ x: 445, y: 455, z: 0 }); + expect(worldPositionOf(children.find((e) => e.z === 1)!)).toEqual({ x: 545, y: 550, z: 1 }); + expect(worldPositionOf(grandchild)).toEqual({ x: 445, y: 455, z: 0 }); expect(world.syncToModel()).toBe(true); - expect(findScene(project, sceneId)!.objects).toHaveLength(8); + expect(sceneOf().objects).toHaveLength(8); }); }); describe('multi-selection commands', () => { it('removes several subtrees as one step, covering nested ids', () => { - const { stack, sceneId, a, b, c, sceneOf } = setup(); + const { stack, sceneId, a, b, c, sceneOf, object } = setup(); stack.execute(new RemoveSceneObjectsCommand(sceneId, [b, c, a])); expect(sceneOf().objects).toHaveLength(0); stack.undo(); expect(sceneOf().objects.map((o) => o.id)).toEqual([a, b, c]); - expect(sceneOf().objects.find((o) => o.id === c)?.parentId).toBe(b); + expect(object(c).parentId).toBe(b); }); it('a compound move rolls back entirely when one part fails', () => { diff --git a/tests/core/enabled.test.ts b/tests/core/enabled.test.ts index 5ee7c37e..a9d5ae70 100644 --- a/tests/core/enabled.test.ts +++ b/tests/core/enabled.test.ts @@ -1,30 +1,23 @@ import { describe, expect, it } from 'vitest'; -import { buildAsset, CreateAssetCommand } from '../../src/core/commands/assetCommands'; -import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands'; -import { CommandStack } from '../../src/core/commands/CommandStack'; import { AddColliderCommand } from '../../src/core/commands/colliderCommands'; -import { PlaceAssetCommand, SetOverrideCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands'; +import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; import { AddVariableCommand } from '../../src/core/commands/variableCommands'; -import { type EntityHandle, isHandleEnabled, requireNodeDef, type NodeContext, type Value } from '../../src/core/graph'; -import type { BehaviorAsset } from '../../src/core/model/Asset'; -import type { PropertyBag } from '../../src/core/model/Properties'; -import { createEmptyProject, findScene } from '../../src/core/model/Project'; -import { footprintOf, textSize } from '../../src/core/properties/bounds'; +import { isHandleEnabled } from '../../src/core/graph'; +import { findAsset, findScene } from '../../src/core/model/Project'; import { schemaFor } from '../../src/core/properties/PropertySchema'; import { resolveSceneObjectProps } from '../../src/core/properties/resolve'; -import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; -import { BehaviorSystem } from '../../src/runtime/systems/BehaviorSystem'; -import { CollisionSystem } from '../../src/runtime/systems/CollisionSystem'; -import { Input } from '../../src/runtime/systems/Input'; -import { World } from '../../src/runtime/World'; +import { fakeHandle, graphEditor, makeBehavior, makeProject, makeSpriteScene, place, roundTrip, runNode, startWorld } from '../helpers'; -/** Runs one node definition in isolation – enough for the small data nodes. */ -function run(type: string, inputs: Record, params: PropertyBag = {}, ctx: Partial = {}): Record { - return requireNodeDef(type).run!(ctx as NodeContext, inputs, params).outputs ?? {}; -} - -function handle(props: PropertyBag, parent: EntityHandle | null = null): EntityHandle { - return { id: 'h', assetId: 'a', name: 'h', x: 0, y: 0, z: 0, props, parent }; +/** A 100×100 wall at x = 200 and a 100×100 mover at the origin, both with box colliders, in a running world. */ +function wallAndMover() { + const f = makeSpriteScene(); + const moverId = place(f, f.spriteId, { x: 0, y: 0 }); + const wallId = place(f, f.spriteId, { x: 200, y: 0 }); + const wallCollider = new AddColliderCommand(f.sceneId, wallId); + f.stack.execute(new AddColliderCommand(f.sceneId, moverId)); + f.stack.execute(wallCollider); + const { world, collisions } = startWorld(f, { collisions: true }); + return { world, collisions, mover: world.findByObjectId(moverId)!, wall: world.findByObjectId(wallCollider.objectId)! }; } describe('the enabled switch', () => { @@ -33,47 +26,37 @@ describe('the enabled switch', () => { expect(schemaFor('behavior').map((d) => d.key)).toEqual(['enabled']); expect(schemaFor('scene').some((d) => d.key === 'enabled')).toBe(false); - const project = createEmptyProject('p', 'P'); - const stack = new CommandStack(project); - const scene = new CreateAssetCommand('scene', 'Level'); - const sprite = new CreateAssetCommand('sprite', 'Box'); - stack.execute(scene); - stack.execute(sprite); - const placed = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 0 }); - stack.execute(placed); - const object = findScene(project, scene.asset.id)!.objects[0]!; - expect(resolveSceneObjectProps(sprite.asset, object).enabled).toBe(true); + const f = makeSpriteScene(); + const { project, stack, sceneId, spriteId, object } = f; + const box = place(f, spriteId, { x: 0, y: 0 }); + expect(resolveSceneObjectProps(findAsset(project, spriteId)!, object(box)).enabled).toBe(true); // Switching an instance off is an ordinary override – and survives a round trip. - stack.execute(new SetOverrideCommand(scene.asset.id, placed.objectId, 'enabled', false)); - expect(object.overrides).toEqual({ enabled: false }); - const loaded = deserializeProject(serializeProject(project), 'p'); - expect(findScene(loaded, scene.asset.id)!.objects[0]!.overrides).toEqual({ enabled: false }); + stack.execute(new SetOverrideCommand(sceneId, box, 'enabled', false)); + expect(object(box).overrides).toEqual({ enabled: false }); + expect(findScene(roundTrip(project), sceneId)!.objects[0]!.overrides).toEqual({ enabled: false }); stack.undo(); - expect(object.overrides).toEqual({}); + expect(object(box).overrides).toEqual({}); }); it('is inherited: a child of a switched-off parent is off as well', () => { - const parent = handle({ enabled: false }); - const child = handle({ enabled: true }, parent); + const parent = fakeHandle({ enabled: false }); + const child = fakeHandle({ enabled: true }, parent); expect(isHandleEnabled(child)).toBe(false); expect(child.props.enabled).toBe(true); // its own switch is untouched parent.props.enabled = true; expect(isHandleEnabled(child)).toBe(true); - expect(run('IsEnabled', { object: child })).toEqual({ result: true, own: true }); + expect(runNode('IsEnabled', { object: child })).toEqual({ result: true, own: true }); }); it('cannot be shadowed by a behavior variable', () => { - const project = createEmptyProject('p', 'P'); - const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset; - project.assets.push(behavior); - expect(() => new AddVariableCommand('b', 'enabled', 'number').execute(project)).toThrow(); + const f = makeProject(); + const behavior = makeBehavior(f); + expect(() => new AddVariableCommand(behavior.id, 'enabled', 'number').execute(f.project)).toThrow(); }); it('takes a collider out of the collision system while it is off', () => { - const { world, wall, mover } = collisionSetup(); - const collisions = new CollisionSystem(); - world.addSystem(collisions); + const { world, collisions, wall, mover } = wallAndMover(); // The wall blocks: moving 200 to the right stops at 100 units of distance (two 100-wide boxes). collisions.moveAndSlide(world, mover, 200, 0); @@ -87,36 +70,28 @@ describe('the enabled switch', () => { }); it('stops a behavior while it is off and runs On start when it is switched on', () => { - const project = createEmptyProject('p', 'P'); - const stack = new CommandStack(project); - const scene = new CreateAssetCommand('scene', 'Level'); - const behavior = new CreateAssetCommand('behavior', 'Counter'); - stack.execute(scene); - stack.execute(behavior); - stack.execute(new AddVariableCommand(behavior.asset.id, 'ticks', 'number')); - const start = new AddNodeCommand(behavior.asset.id, 'OnStart', 0, 0); - const update = new AddNodeCommand(behavior.asset.id, 'OnUpdate', 0, 100); - const one = new AddNodeCommand(behavior.asset.id, 'Number', 0, 200, { value: 1 }); - const add = new AddNodeCommand(behavior.asset.id, 'Add', 100, 200); - const get = new AddNodeCommand(behavior.asset.id, 'GetVariable', 0, 300, { variable: 'ticks' }); - const set = new AddNodeCommand(behavior.asset.id, 'SetVariable', 200, 100, { variable: 'ticks' }); - for (const c of [start, update, one, add, get, set]) stack.execute(c); - for (const [from, fromPort, to, toPort] of [ + const f = makeSpriteScene(); + const behavior = makeBehavior(f, 'Counter', ['ticks']); + // On start and On update both add one to `ticks`. + const edit = graphEditor(f, behavior); + const start = edit.addNode('OnStart'); + const update = edit.addNode('OnUpdate'); + const one = edit.addNode('Number', { value: 1 }); + const add = edit.addNode('Add'); + const get = edit.addNode('GetVariable', { variable: 'ticks' }); + const set = edit.addNode('SetVariable', { variable: 'ticks' }); + edit.wire([ [update, 'exec', set, 'exec'], [get, 'value', add, 'a'], [one, 'value', add, 'b'], [add, 'result', set, 'value'], [start, 'exec', set, 'exec'], - ] as const) { - stack.execute(new ConnectCommand(behavior.asset.id, { node: from.node.id, port: fromPort }, { node: to.node.id, port: toPort })); - } - const placed = new PlaceAssetCommand(scene.asset.id, behavior.asset.id, { x: 0, y: 0 }); - stack.execute(placed); - stack.execute(new SetOverrideCommand(scene.asset.id, placed.objectId, 'enabled', false)); + ]); + const counter = place(f, behavior.id, { x: 0, y: 0 }); + f.stack.execute(new SetOverrideCommand(f.sceneId, counter, 'enabled', false)); - const world = World.instantiate(project, scene.asset.id); - world.addSystem(new BehaviorSystem(project, new Input(), null)); - const entity = world.findByObjectId(placed.objectId)!; + const { world } = startWorld(f, { behaviors: true }); + const entity = world.findByObjectId(counter)!; world.tick(0.1); world.tick(0.1); expect(entity.props.ticks).toBe(0); // off: no On start, no ticks @@ -132,164 +107,3 @@ describe('the enabled switch', () => { expect(entity.props.ticks).toBe(3); // frozen again, state kept }); }); - -describe('text objects and the mouse', () => { - it('measures a text footprint from its content and font size', () => { - expect(textSize({ text: 'ab', fontSize: 100 }).w).toBeCloseTo(110, 5); - expect(textSize({ text: 'ab', fontSize: 100 }).h).toBeCloseTo(125, 5); - expect(footprintOf('text', { text: 'abcd', fontSize: 100 }).w).toBeCloseTo(220, 5); - expect(run('SetText', { object: handle({ text: 'old' }), text: 42 })).toEqual({}); - }); - - it('hit-tests an object through its colliders, else through its sprites and texts', () => { - const project = createEmptyProject('p', 'P'); - const stack = new CommandStack(project); - const scene = new CreateAssetCommand('scene', 'Level'); - const sprite = new CreateAssetCommand('sprite', 'Button'); - stack.execute(scene); - stack.execute(sprite); - const button = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 0 }); - stack.execute(button); - const label = new CreateAssetCommand('text', 'Label'); - stack.execute(label); - const text = new PlaceAssetCommand(scene.asset.id, label.asset.id, { x: 0, y: 200 }); - stack.execute(text); - stack.execute(new SetParentCommand(scene.asset.id, text.objectId, button.objectId)); - - const world = World.instantiate(project, scene.asset.id); - const entity = world.findByObjectId(button.objectId)!; - expect(world.hitTest(entity, 10, 10)).toBe(true); // inside the 100×100 sprite - expect(world.hitTest(entity, 10, 200)).toBe(true); // on the label parented below it - expect(world.hitTest(entity, 400, 400)).toBe(false); - - // A collider takes over the hit test for the whole subtree. - stack.execute(new AddColliderCommand(scene.asset.id, button.objectId)); - stack.execute(new SetOverrideCommand(scene.asset.id, findScene(project, scene.asset.id)!.objects.at(-1)!.id, 'width', 20)); - world.syncFromModel(); - expect(world.hitTest(entity, 5, 5)).toBe(true); - expect(world.hitTest(entity, 40, 0)).toBe(false); // outside the 20-wide collider - expect(world.hitTest(entity, 10, 200)).toBe(false); // the label no longer answers - - // Switched off, nothing of it can be hit. - entity.props.enabled = false; - expect(world.hitTest(entity, 5, 5)).toBe(false); - }); - - it('reports pointer edges for exactly one tick', () => { - const project = createEmptyProject('p', 'P'); - const stack = new CommandStack(project); - const scene = new CreateAssetCommand('scene', 'Level'); - stack.execute(scene); - const world = World.instantiate(project, scene.asset.id); - world.movePointer(12, 34, true); - world.setPointerDown(true); - expect(world.pointer).toMatchObject({ x: 12, y: 34, inside: true, down: true, pressed: true }); - world.tick(0.1); - expect(world.pointer).toMatchObject({ down: true, pressed: false, released: false }); - world.setPointerDown(false); - expect(world.pointer).toMatchObject({ down: false, released: true }); - }); -}); - -describe('nodes added for game flow', () => { - it('formats numbers and joins text', () => { - expect(run('ToText', { value: 12.345, decimals: 1 })).toEqual({ text: '12.3' }); - expect(run('ToText', { value: 'x', decimals: 0 })).toEqual({ text: 'x' }); - expect(run('Join', { a: 'Coins: ', b: 3 })).toEqual({ text: 'Coins: 3' }); - expect(run('Modulo', { a: 125, b: 60 })).toEqual({ result: 5 }); - expect(run('Modulo', { a: 5, b: 0 })).toEqual({ result: 0 }); - expect(run('Floor', { value: 2.9 })).toEqual({ result: 2 }); - }); - - it('compares values and identifies assets', () => { - const coin = handle({}); - coin.assetId = 'coin'; - expect(run('IsAsset', { object: coin }, { asset: 'coin' })).toEqual({ result: true }); - expect(run('IsAsset', { object: coin }, { asset: 'other' })).toEqual({ result: false }); - expect(run('IsAsset', { object: null }, { asset: 'coin' })).toEqual({ result: false }); - expect(run('Equals', { a: 'a', b: 'a' })).toEqual({ result: true }); - expect(run('Equals', { a: coin, b: coin })).toEqual({ result: true }); - expect(run('Equals', { a: coin, b: null })).toEqual({ result: false }); - expect(run('NameOf', { object: coin })).toEqual({ name: 'h' }); - }); - - it('switches objects on and off and asks for a restart', () => { - const target = handle({ enabled: true }); - requireNodeDef('SetEnabled').run!({} as NodeContext, { object: target, enabled: false }, {}); - expect(target.props.enabled).toBe(false); - - let restarted = false; - requireNodeDef('RestartGame').run!({ restart: () => { restarted = true; } } as NodeContext, {}, {}); - expect(restarted).toBe(true); - }); - - it('fires On click when the mouse is pressed on the owner', () => { - const project = createEmptyProject('p', 'P'); - const stack = new CommandStack(project); - const scene = new CreateAssetCommand('scene', 'Level'); - const sprite = new CreateAssetCommand('sprite', 'Button'); - const behavior = new CreateAssetCommand('behavior', 'OnPress'); - for (const c of [scene, sprite, behavior]) stack.execute(c); - stack.execute(new AddVariableCommand(behavior.asset.id, 'clicks', 'number')); - const click = new AddNodeCommand(behavior.asset.id, 'OnClick', 0, 0); - const get = new AddNodeCommand(behavior.asset.id, 'GetVariable', 0, 100, { variable: 'clicks' }); - const one = new AddNodeCommand(behavior.asset.id, 'Number', 0, 200, { value: 1 }); - const add = new AddNodeCommand(behavior.asset.id, 'Add', 100, 150); - const set = new AddNodeCommand(behavior.asset.id, 'SetVariable', 200, 0, { variable: 'clicks' }); - for (const c of [click, get, one, add, set]) stack.execute(c); - for (const [from, fromPort, to, toPort] of [ - [click, 'exec', set, 'exec'], - [get, 'value', add, 'a'], - [one, 'value', add, 'b'], - [add, 'result', set, 'value'], - ] as const) { - stack.execute(new ConnectCommand(behavior.asset.id, { node: from.node.id, port: fromPort }, { node: to.node.id, port: toPort })); - } - const button = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 0 }); - stack.execute(button); - const logic = new PlaceAssetCommand(scene.asset.id, behavior.asset.id, { x: 0, y: 0 }); - stack.execute(logic); - stack.execute(new SetParentCommand(scene.asset.id, logic.objectId, button.objectId)); - - const world = World.instantiate(project, scene.asset.id); - world.addSystem(new BehaviorSystem(project, new Input(), null)); - const entity = world.findByObjectId(logic.objectId)!; - - world.movePointer(500, 500, true); // beside the button - world.setPointerDown(true); - world.tick(0.1); - expect(entity.props.clicks).toBe(0); - - world.setPointerDown(false); - world.movePointer(10, 10, true); // on the button - world.setPointerDown(true); - world.tick(0.1); - expect(entity.props.clicks).toBe(1); - world.tick(0.1); // still held – the press was one frame - expect(entity.props.clicks).toBe(1); - }); -}); - -/** A 100×100 wall at x = 200 and a 100×100 mover at the origin, both with box colliders. */ -function collisionSetup() { - const project = createEmptyProject('p', 'P'); - const stack = new CommandStack(project); - const scene = new CreateAssetCommand('scene', 'Level'); - const sprite = new CreateAssetCommand('sprite', 'Box'); - stack.execute(scene); - stack.execute(sprite); - const moverObject = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 0 }); - const wallObject = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 200, y: 0 }); - stack.execute(moverObject); - stack.execute(wallObject); - const moverCollider = new AddColliderCommand(scene.asset.id, moverObject.objectId); - const wallCollider = new AddColliderCommand(scene.asset.id, wallObject.objectId); - stack.execute(moverCollider); - stack.execute(wallCollider); - const world = World.instantiate(project, scene.asset.id); - return { - world, - mover: world.findByObjectId(moverObject.objectId)!, - wall: world.findByObjectId(wallCollider.objectId)!, - }; -} diff --git a/tests/core/graph.test.ts b/tests/core/graph.test.ts index 778ef401..2150843d 100644 --- a/tests/core/graph.test.ts +++ b/tests/core/graph.test.ts @@ -1,69 +1,36 @@ import { describe, expect, it } from 'vitest'; -import { buildAsset } from '../../src/core/commands/assetCommands'; -import { CommandStack } from '../../src/core/commands/CommandStack'; -import { AddNodeCommand, ConnectCommand, DisconnectCommand, RemoveNodeCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands'; -import { PlaceAssetCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands'; +import { DisconnectCommand, RemoveNodeCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands'; import { AddVariableCommand, RemoveVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands'; -import { type EntityHandle, type Graph, GraphInterpreter, type NodeContext, allNodeDefs, portsCompatible } from '../../src/core/graph'; +import { GraphInterpreter, allNodeDefs, findNode, portsCompatible } from '../../src/core/graph'; import type { BehaviorAsset } from '../../src/core/model/Asset'; -import { createEmptyProject } from '../../src/core/model/Project'; +import { findAsset } from '../../src/core/model/Project'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; +import { type ProjectFixture, fakeContext, graphEditor, makeBehavior, makeProject, makeSpriteScene, place, roundTrip } from '../helpers'; -function entity(name: string, x = 0, assetId = `asset-${name}`): EntityHandle { - return { id: name, assetId, name, x, y: 0, z: 0, props: {}, parent: null }; +/** Adds "On key held(ArrowRight) → Move(owner, dx = speed × dt)" to a project, built through commands like the editor would. */ +function withMover(fixture: F) { + const behavior = makeBehavior(fixture, 'Mover', ['speed']); + const edit = graphEditor(fixture, behavior); + const held = edit.addNode('OnKeyHeld', { key: 'ArrowRight' }); + const owner = edit.addNode('Owner'); + const speed = edit.addNode('GetVariable', { variable: 'speed' }); + const mul = edit.addNode('Multiply'); + const move = edit.addNode('Move'); + edit.wire([ + [held, 'exec', move, 'exec'], + [owner, 'object', move, 'object'], + [speed, 'value', mul, 'a'], + [held, 'dt', mul, 'b'], + [mul, 'result', move, 'dx'], + ]); + return { ...fixture, behavior, edit, nodes: { held, owner, speed, mul, move } }; } -const NO_POINTER = { x: 0, y: 0, inside: false, down: false, pressed: false, released: false }; - -function context(overrides: Partial = {}): NodeContext & { logs: string[]; stopped: boolean } { - const logs: string[] = []; - const ctx = { - self: entity('self'), - owner: entity('owner'), - variables: { speed: 100 }, - time: 0, - dt: 0.5, - logs, - stopped: false, - findByName: () => null, - isKeyDown: () => false, - pointer: { ...NO_POINTER }, - hitTest: () => false, - isTouching: () => false, - moveAndSlide: (target: EntityHandle, dx: number, dy: number) => { - target.x += dx; - target.y += dy; - return { blockedX: false, blockedY: false }; - }, - log: (m: string) => logs.push(m), - spawn: () => null, - destroy: () => {}, - stop: () => { ctx.stopped = true; }, - restart: () => {}, - ...overrides, - }; - return ctx; -} - -/** Builds "On key held → Move(owner, dx = speed × dt)" through commands, like the editor would. */ -function buildMoverProject() { - const project = createEmptyProject('p', 'P'); - const behavior = buildAsset('behavior', 'Mover', 'mover') as BehaviorAsset; - project.assets.push(behavior); - const stack = new CommandStack(project); - stack.execute(new AddVariableCommand('mover', 'speed', 'number')); - const held = new AddNodeCommand('mover', 'OnKeyHeld', 0, 0, { key: 'ArrowRight' }); - const owner = new AddNodeCommand('mover', 'Owner', 0, 100); - const speed = new AddNodeCommand('mover', 'GetVariable', 0, 200, { variable: 'speed' }); - const mul = new AddNodeCommand('mover', 'Multiply', 200, 150); - const move = new AddNodeCommand('mover', 'Move', 400, 0); - for (const c of [held, owner, speed, mul, move]) stack.execute(c); - stack.execute(new ConnectCommand('mover', { node: held.node.id, port: 'exec' }, { node: move.node.id, port: 'exec' })); - stack.execute(new ConnectCommand('mover', { node: owner.node.id, port: 'object' }, { node: move.node.id, port: 'object' })); - stack.execute(new ConnectCommand('mover', { node: speed.node.id, port: 'value' }, { node: mul.node.id, port: 'a' })); - stack.execute(new ConnectCommand('mover', { node: held.node.id, port: 'dt' }, { node: mul.node.id, port: 'b' })); - stack.execute(new ConnectCommand('mover', { node: mul.node.id, port: 'result' }, { node: move.node.id, port: 'dx' })); - return { project, behavior, stack, nodes: { held, owner, speed, mul, move } }; +/** A behavior "B" with the given number variables and an empty graph to edit. */ +function emptyBehavior(variables: string[] = []) { + const fixture = makeProject(); + const behavior = makeBehavior(fixture, 'B', variables); + return { ...fixture, behavior, edit: graphEditor(fixture, behavior) }; } describe('node registry', () => { @@ -92,9 +59,9 @@ describe('node registry', () => { describe('GraphInterpreter', () => { it('runs an event pulse and pulls data lazily (speed × dt moves the owner)', () => { - const { behavior } = buildMoverProject(); + const { behavior } = withMover(makeProject()); const interpreter = new GraphInterpreter(behavior.graph); - const ctx = context({ variables: { speed: 100 } }); + const ctx = fakeContext({ variables: { speed: 100 } }); const fired = interpreter.fire('OnKeyHeld', ctx, { dt: 0.5 }, (n) => n.params.key === 'ArrowRight'); expect(fired).toBe(1); expect(ctx.owner!.x).toBe(50); @@ -103,66 +70,50 @@ describe('GraphInterpreter', () => { }); it('uses inline values for unconnected inputs', () => { - const project = createEmptyProject('p', 'P'); - const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset; - project.assets.push(behavior); - const stack = new CommandStack(project); - const start = new AddNodeCommand('b', 'OnStart', 0, 0); - const self = new AddNodeCommand('b', 'Self', 0, 0); - const move = new AddNodeCommand('b', 'Move', 0, 0, { dx: 7, dy: -3 }); - for (const c of [start, self, move]) stack.execute(c); - stack.execute(new ConnectCommand('b', { node: start.node.id, port: 'exec' }, { node: move.node.id, port: 'exec' })); - stack.execute(new ConnectCommand('b', { node: self.node.id, port: 'object' }, { node: move.node.id, port: 'object' })); - const ctx = context(); + const { behavior, edit } = emptyBehavior(); + const start = edit.addNode('OnStart'); + const self = edit.addNode('Self'); + const move = edit.addNode('Move', { dx: 7, dy: -3 }); + edit.wire([[start, 'exec', move, 'exec'], [self, 'object', move, 'object']]); + const ctx = fakeContext(); new GraphInterpreter(behavior.graph).fire('OnStart', ctx); expect(ctx.self.x).toBe(7); expect(ctx.self.y).toBe(-3); }); it('uses port defaults for unconnected inputs (Move scale = 1, Clamp 0…1)', () => { - const project = createEmptyProject('p', 'P'); - const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset; - project.assets.push(behavior); - const stack = new CommandStack(project); - const start = new AddNodeCommand('b', 'OnStart', 0, 0); - const self = new AddNodeCommand('b', 'Self', 0, 0); - const clamp = new AddNodeCommand('b', 'Clamp', 0, 0, { value: 5 }); - const move = new AddNodeCommand('b', 'Move', 0, 0, { dx: 4 }); - for (const c of [start, self, clamp, move]) stack.execute(c); - stack.execute(new ConnectCommand('b', { node: start.node.id, port: 'exec' }, { node: move.node.id, port: 'exec' })); - stack.execute(new ConnectCommand('b', { node: self.node.id, port: 'object' }, { node: move.node.id, port: 'object' })); - stack.execute(new ConnectCommand('b', { node: clamp.node.id, port: 'result' }, { node: move.node.id, port: 'dy' })); - const ctx = context(); + const { behavior, edit } = emptyBehavior(); + const start = edit.addNode('OnStart'); + const self = edit.addNode('Self'); + const clamp = edit.addNode('Clamp', { value: 5 }); + const move = edit.addNode('Move', { dx: 4 }); + edit.wire([[start, 'exec', move, 'exec'], [self, 'object', move, 'object'], [clamp, 'result', move, 'dy']]); + const ctx = fakeContext(); new GraphInterpreter(behavior.graph).fire('OnStart', ctx); expect(ctx.self.x).toBe(4); // scale defaulted to 1, not 0 expect(ctx.self.y).toBe(1); // clamp(5, 0, 1) }); it('five-node mover: Axis input → Move(dt from On update) moves speed × dt, diagonals normalized', () => { - const project = createEmptyProject('p', 'P'); - const behavior = buildAsset('behavior', 'Mover', 'mover') as BehaviorAsset; - project.assets.push(behavior); - const stack = new CommandStack(project); - stack.execute(new AddVariableCommand('mover', 'speed', 'number')); - const update = new AddNodeCommand('mover', 'OnUpdate', 0, 0); - const owner = new AddNodeCommand('mover', 'Owner', 0, 0); - const axis = new AddNodeCommand('mover', 'AxisInput', 0, 0); - const speed = new AddNodeCommand('mover', 'GetVariable', 0, 0, { variable: 'speed' }); - const move = new AddNodeCommand('mover', 'Move', 0, 0); - for (const c of [update, owner, axis, speed, move]) stack.execute(c); - const connect = (from: AddNodeCommand, fp: string, to: AddNodeCommand, tp: string) => - stack.execute(new ConnectCommand('mover', { node: from.node.id, port: fp }, { node: to.node.id, port: tp })); - connect(update, 'exec', move, 'exec'); - connect(update, 'dt', move, 'dt'); - connect(owner, 'object', move, 'object'); - connect(axis, 'x', move, 'dx'); - connect(axis, 'y', move, 'dy'); - connect(speed, 'value', move, 'scale'); + const { behavior, edit } = emptyBehavior(['speed']); + const update = edit.addNode('OnUpdate'); + const owner = edit.addNode('Owner'); + const axis = edit.addNode('AxisInput'); + const speed = edit.addNode('GetVariable', { variable: 'speed' }); + const move = edit.addNode('Move'); + edit.wire([ + [update, 'exec', move, 'exec'], + [update, 'dt', move, 'dt'], + [owner, 'object', move, 'object'], + [axis, 'x', move, 'dx'], + [axis, 'y', move, 'dy'], + [speed, 'value', move, 'scale'], + ]); expect(behavior.graph.nodes).toHaveLength(5); const interpreter = new GraphInterpreter(behavior.graph); const run = (keys: string[]) => { - const ctx = context({ variables: { speed: 100 }, dt: 0.5, isKeyDown: (k) => keys.includes(k) }); + const ctx = fakeContext({ variables: { speed: 100 }, dt: 0.5, isKeyDown: (k) => keys.includes(k) }); interpreter.fire('OnUpdate', ctx, { dt: 0.5 }); return { x: ctx.owner!.x, y: ctx.owner!.y }; }; @@ -178,140 +129,121 @@ describe('GraphInterpreter', () => { }); it('vector and math helpers', () => { - const project = createEmptyProject('p', 'P'); - const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset; - project.assets.push(behavior); - const stack = new CommandStack(project); - const start = new AddNodeCommand('b', 'OnStart', 0, 0); - const norm = new AddNodeCommand('b', 'Normalize', 0, 0, { x: 3, y: 4 }); - const len = new AddNodeCommand('b', 'Length', 0, 0, { x: 3, y: 4 }); - const sqrt = new AddNodeCommand('b', 'Sqrt', 0, 0, { value: 16 }); - const abs = new AddNodeCommand('b', 'Abs', 0, 0, { value: -2 }); - const min = new AddNodeCommand('b', 'Min', 0, 0, { a: 3, b: -1 }); - const max = new AddNodeCommand('b', 'Max', 0, 0, { a: 3, b: -1 }); - const logs = [norm, len, sqrt, abs, min, max].map(() => new AddNodeCommand('b', 'Log', 0, 0)); - for (const c of [start, norm, len, sqrt, abs, min, max, ...logs]) stack.execute(c); - const connect = (from: AddNodeCommand, fp: string, to: AddNodeCommand, tp: string) => - stack.execute(new ConnectCommand('b', { node: from.node.id, port: fp }, { node: to.node.id, port: tp })); + const { behavior, edit } = emptyBehavior(); + const start = edit.addNode('OnStart'); + const sources = [ + edit.addNode('Normalize', { x: 3, y: 4 }), + edit.addNode('Length', { x: 3, y: 4 }), + edit.addNode('Sqrt', { value: 16 }), + edit.addNode('Abs', { value: -2 }), + edit.addNode('Min', { a: 3, b: -1 }), + edit.addNode('Max', { a: 3, b: -1 }), + ]; + const ports = ['x', 'result', 'result', 'result', 'result', 'result']; + // One Log per source, chained after On start. let previous = start; - for (const log of logs) { - connect(previous, 'exec', log, 'exec'); + sources.forEach((source, i) => { + const log = edit.addNode('Log'); + edit.wire([[previous, 'exec', log, 'exec'], [source, ports[i]!, log, 'message']]); previous = log; - } - connect(norm, 'x', logs[0]!, 'message'); - connect(len, 'result', logs[1]!, 'message'); - connect(sqrt, 'result', logs[2]!, 'message'); - connect(abs, 'result', logs[3]!, 'message'); - connect(min, 'result', logs[4]!, 'message'); - connect(max, 'result', logs[5]!, 'message'); - const ctx = context(); + }); + const ctx = fakeContext(); new GraphInterpreter(behavior.graph).fire('OnStart', ctx); expect(ctx.logs).toEqual(['0.6', '5', '4', '2', '-1', '3']); }); it('follows Branch, writes variables and logs', () => { - const project = createEmptyProject('p', 'P'); - const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset; - project.assets.push(behavior); - const stack = new CommandStack(project); - stack.execute(new AddVariableCommand('b', 'count', 'number')); - const start = new AddNodeCommand('b', 'OnStart', 0, 0); - const get = new AddNodeCommand('b', 'GetVariable', 0, 0, { variable: 'count' }); - const cmp = new AddNodeCommand('b', 'Compare', 0, 0, { op: '<', b: 3 }); - const branch = new AddNodeCommand('b', 'Branch', 0, 0); - const add = new AddNodeCommand('b', 'Add', 0, 0, { b: 1 }); - const set = new AddNodeCommand('b', 'SetVariable', 0, 0, { variable: 'count' }); - const logThen = new AddNodeCommand('b', 'Log', 0, 0, { message: 'then' }); - const logElse = new AddNodeCommand('b', 'Log', 0, 0, { message: 'else' }); - for (const c of [start, get, cmp, branch, add, set, logThen, logElse]) stack.execute(c); - const connect = (from: AddNodeCommand, fp: string, to: AddNodeCommand, tp: string) => - stack.execute(new ConnectCommand('b', { node: from.node.id, port: fp }, { node: to.node.id, port: tp })); - connect(start, 'exec', branch, 'exec'); - connect(get, 'value', cmp, 'a'); - connect(cmp, 'result', branch, 'condition'); - connect(branch, 'then', set, 'exec'); - connect(get, 'value', add, 'a'); - connect(add, 'result', set, 'value'); - connect(set, 'exec', logThen, 'exec'); - connect(branch, 'else', logElse, 'exec'); + const { behavior, edit } = emptyBehavior(['count']); + const start = edit.addNode('OnStart'); + const get = edit.addNode('GetVariable', { variable: 'count' }); + const cmp = edit.addNode('Compare', { op: '<', b: 3 }); + const branch = edit.addNode('Branch'); + const add = edit.addNode('Add', { b: 1 }); + const set = edit.addNode('SetVariable', { variable: 'count' }); + const logThen = edit.addNode('Log', { message: 'then' }); + const logElse = edit.addNode('Log', { message: 'else' }); + edit.wire([ + [start, 'exec', branch, 'exec'], + [get, 'value', cmp, 'a'], + [cmp, 'result', branch, 'condition'], + [branch, 'then', set, 'exec'], + [get, 'value', add, 'a'], + [add, 'result', set, 'value'], + [set, 'exec', logThen, 'exec'], + [branch, 'else', logElse, 'exec'], + ]); const interpreter = new GraphInterpreter(behavior.graph); - const ctx = context({ variables: { count: 0 } }); + const ctx = fakeContext({ variables: { count: 0 } }); for (let i = 0; i < 5; i++) interpreter.fire('OnStart', ctx); expect(ctx.variables.count).toBe(3); expect(ctx.logs).toEqual(['then', 'then', 'then', 'else', 'else']); }); it('reports traversed edges and nodes to the trace listener', () => { - const { behavior, nodes } = buildMoverProject(); + const { behavior, nodes } = withMover(makeProject()); const interpreter = new GraphInterpreter(behavior.graph); const edges: string[] = []; const visited: string[] = []; interpreter.trace = { onEdge: (id) => edges.push(id), onNode: (id) => visited.push(id) }; - interpreter.fire('OnKeyHeld', context(), { dt: 1 }); + interpreter.fire('OnKeyHeld', fakeContext(), { dt: 1 }); expect(edges).toHaveLength(1); - expect(visited).toContain(nodes.mul.node.id); - expect(visited).toContain(nodes.move.node.id); + expect(visited).toContain(nodes.mul.id); + expect(visited).toContain(nodes.move.id); }); it('stops a runaway flow instead of hanging', () => { - const project = createEmptyProject('p', 'P'); - const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset; - project.assets.push(behavior); - const stack = new CommandStack(project); - const start = new AddNodeCommand('b', 'OnStart', 0, 0); - const a = new AddNodeCommand('b', 'Sequence', 0, 0); - const b = new AddNodeCommand('b', 'Sequence', 0, 0); - for (const c of [start, a, b]) stack.execute(c); - stack.execute(new ConnectCommand('b', { node: start.node.id, port: 'exec' }, { node: a.node.id, port: 'exec' })); - stack.execute(new ConnectCommand('b', { node: a.node.id, port: 'a' }, { node: b.node.id, port: 'exec' })); - stack.execute(new ConnectCommand('b', { node: b.node.id, port: 'a' }, { node: a.node.id, port: 'exec' })); - expect(() => new GraphInterpreter(behavior.graph).fire('OnStart', context())).toThrow(/step limit/); + const { behavior, edit } = emptyBehavior(); + const start = edit.addNode('OnStart'); + const a = edit.addNode('Sequence'); + const b = edit.addNode('Sequence'); + edit.wire([[start, 'exec', a, 'exec'], [a, 'a', b, 'exec'], [b, 'a', a, 'exec']]); + expect(() => new GraphInterpreter(behavior.graph).fire('OnStart', fakeContext())).toThrow(/step limit/); }); }); describe('graph commands', () => { it('rejects incompatible, self and duplicate connections', () => { - const { stack, nodes } = buildMoverProject(); - expect(() => stack.execute(new ConnectCommand('mover', { node: nodes.held.node.id, port: 'exec' }, { node: nodes.mul.node.id, port: 'a' }))).toThrow(/Cannot connect/); - expect(() => stack.execute(new ConnectCommand('mover', { node: nodes.mul.node.id, port: 'result' }, { node: nodes.mul.node.id, port: 'a' }))).toThrow(/itself/); - expect(() => stack.execute(new ConnectCommand('mover', { node: nodes.held.node.id, port: 'exec' }, { node: nodes.move.node.id, port: 'exec' }))).toThrow(/Already/); - expect(() => stack.execute(new ConnectCommand('mover', { node: nodes.held.node.id, port: 'nope' }, { node: nodes.move.node.id, port: 'exec' }))).toThrow(/Port/); + const { edit, nodes: { held, mul, move } } = withMover(makeProject()); + expect(() => edit.connect(held, 'exec', mul, 'a')).toThrow(/Cannot connect/); + expect(() => edit.connect(mul, 'result', mul, 'a')).toThrow(/itself/); + expect(() => edit.connect(held, 'exec', move, 'exec')).toThrow(/Already/); + expect(() => edit.connect(held, 'nope', move, 'exec')).toThrow(/Port/); }); it('replaces the edge of a data input and restores it on undo', () => { - const { behavior, stack, nodes } = buildMoverProject(); - const literal = new AddNodeCommand('mover', 'Number', 0, 0, { value: 9 }); - stack.execute(literal); + const { behavior, stack, edit, nodes } = withMover(makeProject()); + const literal = edit.addNode('Number', { value: 9 }); const before = behavior.graph.edges.length; - stack.execute(new ConnectCommand('mover', { node: literal.node.id, port: 'value' }, { node: nodes.move.node.id, port: 'dx' })); + edit.connect(literal, 'value', nodes.move, 'dx'); expect(behavior.graph.edges.length).toBe(before); // one replaced, one added - expect(behavior.graph.edges.find((e) => e.to.node === nodes.move.node.id && e.to.port === 'dx')!.from.node).toBe(literal.node.id); + const sourceOfDx = () => behavior.graph.edges.find((e) => e.to.node === nodes.move.id && e.to.port === 'dx')!.from.node; + expect(sourceOfDx()).toBe(literal.id); stack.undo(); - expect(behavior.graph.edges.find((e) => e.to.node === nodes.move.node.id && e.to.port === 'dx')!.from.node).toBe(nodes.mul.node.id); + expect(sourceOfDx()).toBe(nodes.mul.id); }); it('removing a node drops its edges; undo restores both', () => { - const { behavior, stack, nodes } = buildMoverProject(); + const { behavior, stack, nodes } = withMover(makeProject()); const edgesBefore = behavior.graph.edges.length; - stack.execute(new RemoveNodeCommand('mover', nodes.mul.node.id)); - expect(behavior.graph.nodes.some((n) => n.id === nodes.mul.node.id)).toBe(false); + stack.execute(new RemoveNodeCommand(behavior.id, nodes.mul.id)); + expect(findNode(behavior.graph, nodes.mul.id)).toBeUndefined(); expect(behavior.graph.edges.length).toBe(edgesBefore - 3); stack.undo(); - expect(behavior.graph.nodes.some((n) => n.id === nodes.mul.node.id)).toBe(true); + expect(findNode(behavior.graph, nodes.mul.id)).toBeDefined(); expect(behavior.graph.edges.length).toBe(edgesBefore); - stack.execute(new DisconnectCommand('mover', behavior.graph.edges[0]!.id)); + stack.execute(new DisconnectCommand(behavior.id, behavior.graph.edges[0]!.id)); expect(behavior.graph.edges.length).toBe(edgesBefore - 1); stack.undo(); expect(behavior.graph.edges.length).toBe(edgesBefore); }); it('coerces declared params and keeps inline input values', () => { - const { behavior, stack, nodes } = buildMoverProject(); - stack.execute(new SetNodeParamCommand('mover', nodes.held.node.id, 'key', 'ArrowLeft')); - stack.execute(new SetNodeParamCommand('mover', nodes.move.node.id, 'dy', 4)); - const held = behavior.graph.nodes.find((n) => n.id === nodes.held.node.id)!; - const move = behavior.graph.nodes.find((n) => n.id === nodes.move.node.id)!; + const { behavior, stack, nodes } = withMover(makeProject()); + stack.execute(new SetNodeParamCommand(behavior.id, nodes.held.id, 'key', 'ArrowLeft')); + stack.execute(new SetNodeParamCommand(behavior.id, nodes.move.id, 'dy', 4)); + const held = findNode(behavior.graph, nodes.held.id)!; + const move = findNode(behavior.graph, nodes.move.id)!; expect(held.params.key).toBe('ArrowLeft'); expect(move.params.dy).toBe(4); stack.undo(); @@ -319,110 +251,48 @@ describe('graph commands', () => { }); }); -describe('variables', () => { +describe('variable commands', () => { it('adds, renames (including graph references and overrides) and removes variables', () => { - const { project, behavior, stack, nodes } = buildMoverProject(); - const scene = buildAsset('scene', 'S', 's'); - project.assets.push(scene); - const place = new PlaceAssetCommand('s', 'mover', { x: 0, y: 0 }); - stack.execute(place); - const object = (scene as { objects: { id: string; overrides: Record }[] }).objects[0]!; - object.overrides.speed = 5; + const f = withMover(makeSpriteScene()); + const { behavior, stack, nodes, object } = f; + const logic = place(f, behavior.id, { x: 0, y: 0 }); + object(logic).overrides.speed = 5; - stack.execute(new RenameVariableCommand('mover', 'speed', 'velocity')); + stack.execute(new RenameVariableCommand(behavior.id, 'speed', 'velocity')); expect(behavior.variables[0]!.key).toBe('velocity'); expect(behavior.props).toEqual({ enabled: true, velocity: 0 }); - expect(object.overrides).toEqual({ velocity: 5 }); - expect(behavior.graph.nodes.find((n) => n.id === nodes.speed.node.id)!.params.variable).toBe('velocity'); + expect(object(logic).overrides).toEqual({ velocity: 5 }); + expect(findNode(behavior.graph, nodes.speed.id)!.params.variable).toBe('velocity'); stack.undo(); expect(behavior.variables[0]!.key).toBe('speed'); - expect(object.overrides).toEqual({ speed: 5 }); + expect(object(logic).overrides).toEqual({ speed: 5 }); - expect(() => stack.execute(new AddVariableCommand('mover', 'speed', 'number'))).toThrow(/already/); - expect(() => stack.execute(new AddVariableCommand('mover', '1bad', 'number'))).toThrow(/names/); + expect(() => stack.execute(new AddVariableCommand(behavior.id, 'speed', 'number'))).toThrow(/already/); + expect(() => stack.execute(new AddVariableCommand(behavior.id, '1bad', 'number'))).toThrow(/names/); - stack.execute(new RemoveVariableCommand('mover', 'speed')); + stack.execute(new RemoveVariableCommand(behavior.id, 'speed')); expect(behavior.variables).toHaveLength(0); - expect(object.overrides).toEqual({}); + expect(object(logic).overrides).toEqual({}); stack.undo(); expect(behavior.variables).toHaveLength(1); - expect(object.overrides).toEqual({ speed: 5 }); - }); - - it('the owner is the parent: validated, kept across a round trip, dropped when dangling', () => { - const project = createEmptyProject('p', 'P'); - const scene = buildAsset('scene', 'S', 's'); - const sprite = buildAsset('sprite', 'Box', 'box'); - const behavior = buildAsset('behavior', 'B', 'b'); - project.assets.push(scene, sprite, behavior); - const stack = new CommandStack(project); - const placeBox = new PlaceAssetCommand('s', 'box', { x: 0, y: 0 }); - const placeB = new PlaceAssetCommand('s', 'b', { x: 0, y: 0 }); - stack.execute(placeBox); - stack.execute(placeB); - expect(() => stack.execute(new SetParentCommand('s', placeB.objectId, placeB.objectId))).toThrow(/own parent/); - stack.execute(new SetParentCommand('s', placeB.objectId, placeBox.objectId)); - const objectsOf = (p: unknown) => ((p as { assets: { objects: { id: string; parentId: string }[] }[] }).assets[0]!).objects; - expect(objectsOf(project)[1]!.parentId).toBe(placeBox.objectId); - // Round trip keeps the parent; a dangling parent is dropped by the loader. - expect(objectsOf(deserializeProject(serializeProject(project)))[1]!.parentId).toBe(placeBox.objectId); - const raw = serializeProject(project) as { assets: { objects?: { parentId: string }[] }[] }; - raw.assets[0]!.objects![1]!.parentId = 'ghost'; - expect(objectsOf(deserializeProject(raw))[1]!.parentId).toBe(''); + expect(object(logic).overrides).toEqual({ speed: 5 }); }); }); describe('behavior serialization', () => { it('round-trips variables and graph, dropping unknown node types and dangling edges', () => { - const { project, behavior } = buildMoverProject(); - const loaded = deserializeProject(serializeProject(project)).assets.find((a) => a.id === 'mover') as BehaviorAsset; + const { project, behavior } = withMover(makeProject()); + const loaded = findAsset(roundTrip(project), behavior.id) as BehaviorAsset; expect(loaded.variables).toEqual(behavior.variables); expect(loaded.graph).toEqual(behavior.graph); expect(loaded.props).toEqual({ enabled: true, speed: 0 }); - const raw = serializeProject(project) as { assets: { graph?: { nodes: unknown[]; edges: unknown[] } }[] }; - raw.assets[0]!.graph!.nodes.push({ id: 'zzz', type: 'DoesNotExist', x: 0, y: 0, params: {} }); - raw.assets[0]!.graph!.edges.push({ id: 'e-bad', from: { node: 'zzz', port: 'exec' }, to: { node: behavior.graph.nodes[0]!.id, port: 'exec' } }); - const cleaned = deserializeProject(raw).assets[0] as BehaviorAsset; + const raw = serializeProject(project) as { assets: { id: string; graph?: { nodes: unknown[]; edges: unknown[] } }[] }; + const rawGraph = raw.assets.find((a) => a.id === behavior.id)!.graph!; + rawGraph.nodes.push({ id: 'zzz', type: 'DoesNotExist', x: 0, y: 0, params: {} }); + rawGraph.edges.push({ id: 'e-bad', from: { node: 'zzz', port: 'exec' }, to: { node: behavior.graph.nodes[0]!.id, port: 'exec' } }); + const cleaned = findAsset(deserializeProject(raw), behavior.id) as BehaviorAsset; expect(cleaned.graph.nodes.length).toBe(behavior.graph.nodes.length); expect(cleaned.graph.edges.length).toBe(behavior.graph.edges.length); }); }); - -describe('Spawn node', () => { - const graph: Graph = { - nodes: [ - { id: 'start', type: 'OnStart', x: 0, y: 0, params: {} }, - { id: 'spawn', type: 'Spawn', x: 0, y: 0, params: { asset: 'crate', x: 5, y: 7 } }, - { id: 'log', type: 'Log', x: 0, y: 0, params: {} }, - ], - edges: [ - { id: 'e1', from: { node: 'start', port: 'exec' }, to: { node: 'spawn', port: 'exec' } }, - { id: 'e2', from: { node: 'spawn', port: 'exec' }, to: { node: 'log', port: 'exec' } }, - { id: 'e3', from: { node: 'spawn', port: 'object' }, to: { node: 'log', port: 'message' } }, - ], - }; - - it('asks the context for a new instance at the inline position and outputs it', () => { - const calls: unknown[] = []; - const ctx = context({ - spawn: (assetId, position, parent) => { - calls.push({ assetId, position, parent }); - return entity('Crate', position.x); - }, - }); - new GraphInterpreter(graph).fire('OnStart', ctx); - expect(calls).toEqual([{ assetId: 'crate', position: { x: 5, y: 7, z: 0 }, parent: null }]); - expect(ctx.logs).toEqual(['Crate']); // the spawned object flowed into Log - }); - - it('logs a hint and continues when no asset is selected or the asset is unknown', () => { - const ctx = context({ spawn: () => null }); - const noAsset: Graph = { ...graph, nodes: graph.nodes.map((n) => (n.id === 'spawn' ? { ...n, params: {} } : n)) }; - new GraphInterpreter(noAsset).fire('OnStart', ctx); - expect(ctx.logs).toEqual(['Spawn: no asset selected', '']); - const ctx2 = context({ spawn: () => null }); - new GraphInterpreter(graph).fire('OnStart', ctx2); - expect(ctx2.logs[0]).toBe('Spawn: asset crate not found'); - }); -}); diff --git a/tests/core/hierarchy.test.ts b/tests/core/hierarchy.test.ts index 5c230d4e..89a5f6cf 100644 --- a/tests/core/hierarchy.test.ts +++ b/tests/core/hierarchy.test.ts @@ -1,46 +1,37 @@ import { describe, expect, it } from 'vitest'; -import { CreateAssetCommand, DeleteAssetCommand } from '../../src/core/commands/assetCommands'; -import { CommandStack } from '../../src/core/commands/CommandStack'; -import { MoveSceneObjectCommand, PlaceAssetCommand, RemoveSceneObjectCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands'; -import { GraphInterpreter, type NodeContext, worldPositionOf } from '../../src/core/graph'; -import { ancestorIdsOf, descendantsOf, localPositionFor, treeOrder, visibleTreeOrder, worldPosition } from '../../src/core/model/hierarchy'; -import { createEmptyProject, findScene } from '../../src/core/model/Project'; +import { DeleteAssetCommand } from '../../src/core/commands/assetCommands'; +import { MoveSceneObjectCommand, RemoveSceneObjectCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands'; +import { type Graph, GraphInterpreter, worldPositionOf } from '../../src/core/graph'; +import { ancestorIdsOf, descendantsOf, findObject, localPositionFor, treeOrder, visibleTreeOrder } from '../../src/core/model/hierarchy'; +import { findScene } from '../../src/core/model/Project'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; -import { World } from '../../src/runtime/World'; +import { fakeContext, fakeEntity, makeBehavior, makeSpriteScene, place, roundTrip, startWorld } from '../helpers'; -/** Scene with a sprite asset and three placed boxes: a at (100,100), b at (10,0), c at (0,5). */ -function setup() { - const project = createEmptyProject('p', 'Hierarchy'); - const stack = new CommandStack(project); - const scene = new CreateAssetCommand('scene', 'Level'); - const sprite = new CreateAssetCommand('sprite', 'Box'); - stack.execute(scene); - stack.execute(sprite); - const a = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 100, y: 100, z: 1 }); - const b = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 10, y: 0 }); - const c = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 5 }); - for (const cmd of [a, b, c]) stack.execute(cmd); - const sceneOf = () => findScene(project, scene.asset.id)!; - const object = (id: string) => sceneOf().objects.find((o) => o.id === id)!; - return { project, stack, sceneId: scene.asset.id, spriteId: sprite.asset.id, a: a.objectId, b: b.objectId, c: c.objectId, sceneOf, object }; +/** Three boxes, all top level: a at (100,100,z1), b at (10,0), c at (0,5). */ +function threeBoxes() { + const f = makeSpriteScene(); + const a = place(f, f.spriteId, { x: 100, y: 100, z: 1 }); + const b = place(f, f.spriteId, { x: 10, y: 0 }); + const c = place(f, f.spriteId, { x: 0, y: 5 }); + return { ...f, a, b, c }; } describe('scene hierarchy', () => { it('re-parenting keeps the world position and moves children along with the parent', () => { - const { stack, sceneId, a, b, c, sceneOf, object } = setup(); + const { stack, sceneId, a, b, c, sceneOf, object, world } = threeBoxes(); stack.execute(new SetParentCommand(sceneId, b, a)); // b stays at world (10,0): local becomes (-90,-100,-1). expect(object(b).parentId).toBe(a); expect(object(b).position).toEqual({ x: -90, y: -100, z: -1 }); - expect(worldPosition(sceneOf(), object(b))).toEqual({ x: 10, y: 0, z: 0 }); + expect(world(b)).toEqual({ x: 10, y: 0, z: 0 }); stack.execute(new SetParentCommand(sceneId, c, b)); - expect(worldPosition(sceneOf(), object(c))).toEqual({ x: 0, y: 5, z: 0 }); + expect(world(c)).toEqual({ x: 0, y: 5, z: 0 }); // Moving the root drags the whole subtree. stack.execute(new MoveSceneObjectCommand(sceneId, a, { x: 200, y: 100, z: 1 })); - expect(worldPosition(sceneOf(), object(b))).toEqual({ x: 110, y: 0, z: 0 }); - expect(worldPosition(sceneOf(), object(c))).toEqual({ x: 100, y: 5, z: 0 }); + expect(world(b)).toEqual({ x: 110, y: 0, z: 0 }); + expect(world(c)).toEqual({ x: 100, y: 5, z: 0 }); // Undo restores the previous parent and local position exactly. stack.undo(); @@ -55,7 +46,7 @@ describe('scene hierarchy', () => { }); it('refuses cycles and self-parenting', () => { - const { stack, sceneId, a, b, c } = setup(); + const { stack, sceneId, a, b, c } = threeBoxes(); stack.execute(new SetParentCommand(sceneId, b, a)); stack.execute(new SetParentCommand(sceneId, c, b)); expect(() => stack.execute(new SetParentCommand(sceneId, a, a))).toThrow(/own parent/); @@ -64,7 +55,7 @@ describe('scene hierarchy', () => { }); it('lists objects in tree order and descendants depth-first', () => { - const { stack, sceneId, a, b, c, sceneOf } = setup(); + const { stack, sceneId, a, b, c, sceneOf } = threeBoxes(); // Storage order is a, b, c; parent c under a → c is shown right after a, before b. stack.execute(new SetParentCommand(sceneId, c, a)); expect(treeOrder(sceneOf()).map((e) => [e.object.id, e.depth])).toEqual([[a, 0], [c, 1], [b, 0]]); @@ -73,7 +64,7 @@ describe('scene hierarchy', () => { }); it('hides the subtrees of collapsed objects and marks the rows that can fold', () => { - const { stack, sceneId, a, b, c, sceneOf } = setup(); + const { stack, sceneId, a, b, c, sceneOf } = threeBoxes(); // a > c > b: two levels, so folding a hides both descendants. stack.execute(new SetParentCommand(sceneId, c, a)); stack.execute(new SetParentCommand(sceneId, b, c)); @@ -88,7 +79,7 @@ describe('scene hierarchy', () => { }); it('lists the ancestors of an object nearest first, so a selection can be unfolded', () => { - const { stack, sceneId, a, b, c, sceneOf } = setup(); + const { stack, sceneId, a, b, c, sceneOf } = threeBoxes(); stack.execute(new SetParentCommand(sceneId, c, a)); stack.execute(new SetParentCommand(sceneId, b, c)); expect(ancestorIdsOf(sceneOf(), b)).toEqual([c, a]); @@ -96,32 +87,46 @@ describe('scene hierarchy', () => { }); it('removing an object or deleting an asset takes the subtree along, undo restores it', () => { - const { project, stack, sceneId, spriteId, a, b, c, sceneOf } = setup(); - const behavior = new CreateAssetCommand('behavior', 'Logic'); - stack.execute(behavior); - const placeLogic = new PlaceAssetCommand(sceneId, behavior.asset.id, { x: 0, y: 0 }, a); - stack.execute(placeLogic); - expect(sceneOf().objects.find((o) => o.id === placeLogic.objectId)!.position).toEqual({ x: -100, y: -100, z: -1 }); + const f = threeBoxes(); + const { project, stack, sceneId, spriteId, a, b, c, sceneOf, object } = f; + const logic = place(f, makeBehavior(f, 'Logic').id, { x: 0, y: 0 }, a); + expect(object(logic).position).toEqual({ x: -100, y: -100, z: -1 }); stack.execute(new SetParentCommand(sceneId, b, a)); stack.execute(new RemoveSceneObjectCommand(sceneId, a)); expect(sceneOf().objects.map((o) => o.id)).toEqual([c]); stack.undo(); - expect(sceneOf().objects.map((o) => o.id)).toEqual([a, b, c, placeLogic.objectId]); - expect(sceneOf().objects.find((o) => o.id === b)!.parentId).toBe(a); + expect(sceneOf().objects.map((o) => o.id)).toEqual([a, b, c, logic]); + expect(object(b).parentId).toBe(a); // Deleting the sprite asset removes a, b, c – and the behavior parented below a. stack.execute(new DeleteAssetCommand(spriteId)); expect(sceneOf().objects).toHaveLength(0); stack.undo(); - expect(sceneOf().objects.map((o) => o.id)).toEqual([a, b, c, placeLogic.objectId]); + expect(sceneOf().objects.map((o) => o.id)).toEqual([a, b, c, logic]); expect(project.assets.map((x) => x.id)).toContain(spriteId); }); + it('a behavior is parented like any object: validated, kept across a round trip, dropped when dangling', () => { + const f = makeSpriteScene(); + const { project, stack, sceneId, object } = f; + const box = place(f, f.spriteId, { x: 0, y: 0 }); + const logic = place(f, makeBehavior(f).id, { x: 0, y: 0 }); + expect(() => stack.execute(new SetParentCommand(sceneId, logic, logic))).toThrow(/own parent/); + stack.execute(new SetParentCommand(sceneId, logic, box)); + expect(object(logic).parentId).toBe(box); + expect(findObject(findScene(roundTrip(project), sceneId)!, logic)!.parentId).toBe(box); + + // A dangling parent is dropped by the loader. + const raw = serializeProject(project) as { assets: { objects?: { id: string; parentId: string }[] }[] }; + raw.assets[0]!.objects!.find((o) => o.id === logic)!.parentId = 'ghost'; + expect(findObject(findScene(deserializeProject(raw), sceneId)!, logic)!.parentId).toBe(''); + }); + it('the loader accepts legacy ownerId, drops dangling parents and breaks cycles', () => { - const { project, stack, sceneId, a, b, c } = setup(); + const { project, stack, sceneId, a, b, c } = threeBoxes(); stack.execute(new SetParentCommand(sceneId, b, a)); - const raw = JSON.parse(JSON.stringify(serializeProject(project))) as { assets: { objects?: Record[] }[] }; + const raw = serializeProject(project) as { assets: { objects?: Record[] }[] }; const objects = raw.assets[0]!.objects!; // Legacy documents stored the link as `ownerId`. delete objects[1]!.parentId; @@ -130,7 +135,7 @@ describe('scene hierarchy', () => { objects[0]!.parentId = c; objects[2]!.parentId = a; const loaded = findScene(deserializeProject(raw), sceneId)!; - const parentOf = (id: string) => loaded.objects.find((o) => o.id === id)!.parentId; + const parentOf = (id: string) => findObject(loaded, id)!.parentId; expect(parentOf(b)).toBe(a); expect([parentOf(a), parentOf(c)].filter(Boolean)).toHaveLength(1); // one link of the cycle survives objects[1]!.parentId = 'ghost'; @@ -141,24 +146,26 @@ describe('scene hierarchy', () => { describe('runtime hierarchy', () => { it('entities mirror parents: world positions follow the parent, Destroy takes children along', () => { - const { project, stack, sceneId, a, b, c } = setup(); + const f = threeBoxes(); + const { stack, sceneId, a, b, c } = f; stack.execute(new SetParentCommand(sceneId, b, a)); stack.execute(new SetParentCommand(sceneId, c, b)); - const world = World.instantiate(project, sceneId); + const { world } = startWorld(f); const entity = (id: string) => world.findByObjectId(id)!; expect(entity(b).parent).toBe(entity(a)); - expect(world.worldPosition(entity(c))).toEqual({ x: 0, y: 5, z: 0 }); + expect(worldPositionOf(entity(c))).toEqual({ x: 0, y: 5, z: 0 }); entity(a).x += 50; - expect(world.worldPosition(entity(c))).toEqual({ x: 50, y: 5, z: 0 }); + expect(worldPositionOf(entity(c))).toEqual({ x: 50, y: 5, z: 0 }); expect(worldPositionOf(entity(b))).toEqual({ x: 60, y: 0, z: 0 }); world.remove(entity(a)); expect(world.entities).toHaveLength(0); }); it('Get position exposes local and world coordinates, Parent walks up', () => { - const parent = { id: 'p', assetId: 'a-p', name: 'p', x: 100, y: 20, z: 0, props: {}, parent: null }; - const child = { id: 'c', assetId: 'a-c', name: 'c', x: 5, y: 5, z: 0, props: {}, parent }; - const graph = { nodes: [ + const parent = { ...fakeEntity('p', 100), y: 20 }; + const child = { ...fakeEntity('c', 5), y: 5, parent }; + // On start → Log(world x of self) → Log(local x of self) → Log(world x of self's parent) + const graph: Graph = { nodes: [ { id: 'start', type: 'OnStart', x: 0, y: 0, params: {} }, { id: 'self', type: 'Self', x: 0, y: 0, params: {} }, { id: 'pos', type: 'GetPosition', x: 0, y: 0, params: {} }, @@ -178,9 +185,8 @@ describe('runtime hierarchy', () => { { id: 'e8', from: { node: 'pos', port: 'x' }, to: { node: 'log2', port: 'message' } }, { id: 'e9', from: { node: 'ppos', port: 'worldX' }, to: { node: 'log3', port: 'message' } }, ] }; - const logs: string[] = []; - const ctx: NodeContext = { self: child, owner: parent, variables: {}, time: 0, dt: 0, findByName: () => null, isKeyDown: () => false, pointer: { x: 0, y: 0, inside: false, down: false, pressed: false, released: false }, hitTest: () => false, isTouching: () => false, moveAndSlide: () => ({ blockedX: false, blockedY: false }), log: (m) => logs.push(m), spawn: () => null, destroy: () => {}, stop: () => {}, restart: () => {} }; + const ctx = fakeContext({ self: child, owner: parent, variables: {} }); new GraphInterpreter(graph).fire('OnStart', ctx); - expect(logs).toEqual(['105', '5', '100']); + expect(ctx.logs).toEqual(['105', '5', '100']); }); }); diff --git a/tests/core/nodes.test.ts b/tests/core/nodes.test.ts new file mode 100644 index 00000000..f5273677 --- /dev/null +++ b/tests/core/nodes.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { type Graph, GraphInterpreter } from '../../src/core/graph'; +import { fakeContext, fakeEntity, fakeHandle, graphEditor, makeBehavior, makeSpriteScene, place, runNode, startWorld } from '../helpers'; + +describe('game-flow nodes', () => { + it('formats numbers and joins text', () => { + expect(runNode('ToText', { value: 12.345, decimals: 1 })).toEqual({ text: '12.3' }); + expect(runNode('ToText', { value: 'x', decimals: 0 })).toEqual({ text: 'x' }); + expect(runNode('Join', { a: 'Coins: ', b: 3 })).toEqual({ text: 'Coins: 3' }); + expect(runNode('Modulo', { a: 125, b: 60 })).toEqual({ result: 5 }); + expect(runNode('Modulo', { a: 5, b: 0 })).toEqual({ result: 0 }); + expect(runNode('Floor', { value: 2.9 })).toEqual({ result: 2 }); + }); + + it('compares values and identifies assets', () => { + const coin = fakeHandle({}); + coin.assetId = 'coin'; + expect(runNode('IsAsset', { object: coin }, { asset: 'coin' })).toEqual({ result: true }); + expect(runNode('IsAsset', { object: coin }, { asset: 'other' })).toEqual({ result: false }); + expect(runNode('IsAsset', { object: null }, { asset: 'coin' })).toEqual({ result: false }); + expect(runNode('Equals', { a: 'a', b: 'a' })).toEqual({ result: true }); + expect(runNode('Equals', { a: coin, b: coin })).toEqual({ result: true }); + expect(runNode('Equals', { a: coin, b: null })).toEqual({ result: false }); + expect(runNode('NameOf', { object: coin })).toEqual({ name: 'h' }); + }); + + it('switches objects on and off and asks for a restart', () => { + const target = fakeHandle({ enabled: true }); + runNode('SetEnabled', { object: target, enabled: false }); + expect(target.props.enabled).toBe(false); + + let restarted = false; + runNode('RestartGame', {}, {}, fakeContext({ restart: () => { restarted = true; } })); + expect(restarted).toBe(true); + }); + + it('fires On click when the mouse is pressed on the owner', () => { + const f = makeSpriteScene('Button'); + const behavior = makeBehavior(f, 'OnPress', ['clicks']); + // On click → clicks = clicks + 1 + const edit = graphEditor(f, behavior); + const click = edit.addNode('OnClick'); + const get = edit.addNode('GetVariable', { variable: 'clicks' }); + const one = edit.addNode('Number', { value: 1 }); + const add = edit.addNode('Add'); + const set = edit.addNode('SetVariable', { variable: 'clicks' }); + edit.wire([ + [click, 'exec', set, 'exec'], + [get, 'value', add, 'a'], + [one, 'value', add, 'b'], + [add, 'result', set, 'value'], + ]); + const button = place(f, f.spriteId, { x: 0, y: 0 }); + const logic = place(f, behavior.id, { x: 0, y: 0 }, button); + + const { world } = startWorld(f, { behaviors: true }); + const entity = world.findByObjectId(logic)!; + + world.movePointer(500, 500, true); // beside the button + world.setPointerDown(true); + world.tick(0.1); + expect(entity.props.clicks).toBe(0); + + world.setPointerDown(false); + world.movePointer(10, 10, true); // on the button + world.setPointerDown(true); + world.tick(0.1); + expect(entity.props.clicks).toBe(1); + world.tick(0.1); // still held – the press was one frame + expect(entity.props.clicks).toBe(1); + }); +}); + +describe('Spawn node', () => { + // On start → Spawn(crate at (5,7)) → Log(the spawned object) + const graph: Graph = { + nodes: [ + { id: 'start', type: 'OnStart', x: 0, y: 0, params: {} }, + { id: 'spawn', type: 'Spawn', x: 0, y: 0, params: { asset: 'crate', x: 5, y: 7 } }, + { id: 'log', type: 'Log', x: 0, y: 0, params: {} }, + ], + edges: [ + { id: 'e1', from: { node: 'start', port: 'exec' }, to: { node: 'spawn', port: 'exec' } }, + { id: 'e2', from: { node: 'spawn', port: 'exec' }, to: { node: 'log', port: 'exec' } }, + { id: 'e3', from: { node: 'spawn', port: 'object' }, to: { node: 'log', port: 'message' } }, + ], + }; + + it('asks the context for a new instance at the inline position and outputs it', () => { + const calls: unknown[] = []; + const ctx = fakeContext({ + spawn: (assetId, position, parent) => { + calls.push({ assetId, position, parent }); + return fakeEntity('Crate', position.x); + }, + }); + new GraphInterpreter(graph).fire('OnStart', ctx); + expect(calls).toEqual([{ assetId: 'crate', position: { x: 5, y: 7, z: 0 }, parent: null }]); + expect(ctx.logs).toEqual(['Crate']); // the spawned object flowed into Log + }); + + it('logs a hint and continues when no asset is selected or the asset is unknown', () => { + const ctx = fakeContext({ spawn: () => null }); + const noAsset: Graph = { ...graph, nodes: graph.nodes.map((n) => (n.id === 'spawn' ? { ...n, params: {} } : n)) }; + new GraphInterpreter(noAsset).fire('OnStart', ctx); + expect(ctx.logs).toEqual(['Spawn: no asset selected', '']); + const ctx2 = fakeContext({ spawn: () => null }); + new GraphInterpreter(graph).fire('OnStart', ctx2); + expect(ctx2.logs[0]).toBe('Spawn: asset crate not found'); + }); +}); diff --git a/tests/core/serialize.test.ts b/tests/core/serialize.test.ts index 79ea9bf8..58593247 100644 --- a/tests/core/serialize.test.ts +++ b/tests/core/serialize.test.ts @@ -1,24 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; -import { CommandStack } from '../../src/core/commands/CommandStack'; -import { PlaceAssetCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; -import { createEmptyProject } from '../../src/core/model/Project'; -import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; +import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; +import { deserializeProject } from '../../src/core/store/serialize'; +import { makeSpriteScene, place, roundTrip } from '../helpers'; describe('serialize', () => { it('round-trips a project', () => { - const project = createEmptyProject('p1', 'Round trip'); - const stack = new CommandStack(project); - const scene = new CreateAssetCommand('scene', 'Level'); - const sprite = new CreateAssetCommand('sprite', 'Box'); - stack.execute(scene); - stack.execute(sprite); - const place = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 7, y: 8 }); - stack.execute(place); - stack.execute(new SetOverrideCommand(scene.asset.id, place.objectId, 'width', 33)); - - const copy = deserializeProject(JSON.parse(JSON.stringify(serializeProject(project)))); - expect(copy).toEqual(project); + const f = makeSpriteScene(); + const box = place(f, f.spriteId, { x: 7, y: 8 }); + f.stack.execute(new SetOverrideCommand(f.sceneId, box, 'width', 33)); + expect(roundTrip(f.project)).toEqual(f.project); }); it('repairs broken documents', () => { diff --git a/tests/core/text-and-pointer.test.ts b/tests/core/text-and-pointer.test.ts new file mode 100644 index 00000000..0e59ca6e --- /dev/null +++ b/tests/core/text-and-pointer.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import { AddColliderCommand } from '../../src/core/commands/colliderCommands'; +import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; +import { footprintOf, textSize } from '../../src/core/properties/bounds'; +import { fakeHandle, makeSpriteScene, place, runNode, startWorld } from '../helpers'; + +describe('text objects', () => { + it('measures a text footprint from its content and font size', () => { + expect(textSize({ text: 'ab', fontSize: 100 }).w).toBeCloseTo(110, 5); + expect(textSize({ text: 'ab', fontSize: 100 }).h).toBeCloseTo(125, 5); + expect(footprintOf('text', { text: 'abcd', fontSize: 100 }).w).toBeCloseTo(220, 5); + expect(runNode('SetText', { object: fakeHandle({ text: 'old' }), text: 42 })).toEqual({}); + }); +}); + +describe('the mouse', () => { + it('hit-tests an object through its colliders, else through its sprites and texts', () => { + const f = makeSpriteScene('Button'); + const { stack, sceneId } = f; + const button = place(f, f.spriteId, { x: 0, y: 0 }); + const label = new CreateAssetCommand('text', 'Label'); + stack.execute(label); + place(f, label.asset.id, { x: 0, y: 200 }, button); + + const { world } = startWorld(f); + const entity = world.findByObjectId(button)!; + expect(world.hitTest(entity, 10, 10)).toBe(true); // inside the 100×100 sprite + expect(world.hitTest(entity, 10, 200)).toBe(true); // on the label parented below it + expect(world.hitTest(entity, 400, 400)).toBe(false); + + // A collider takes over the hit test for the whole subtree. + const collider = new AddColliderCommand(sceneId, button); + stack.execute(collider); + stack.execute(new SetOverrideCommand(sceneId, collider.objectId, 'width', 20)); + world.syncFromModel(); + expect(world.hitTest(entity, 5, 5)).toBe(true); + expect(world.hitTest(entity, 40, 0)).toBe(false); // outside the 20-wide collider + expect(world.hitTest(entity, 10, 200)).toBe(false); // the label no longer answers + + // Switched off, nothing of it can be hit. + entity.props.enabled = false; + expect(world.hitTest(entity, 5, 5)).toBe(false); + }); + + it('reports pointer edges for exactly one tick', () => { + const { world } = startWorld(makeSpriteScene()); + world.movePointer(12, 34, true); + world.setPointerDown(true); + expect(world.pointer).toMatchObject({ x: 12, y: 34, inside: true, down: true, pressed: true }); + world.tick(0.1); + expect(world.pointer).toMatchObject({ down: true, pressed: false, released: false }); + world.setPointerDown(false); + expect(world.pointer).toMatchObject({ down: false, released: true }); + }); +}); diff --git a/tests/core/world-sync.test.ts b/tests/core/world-sync.test.ts index 4a867c24..8c36e934 100644 --- a/tests/core/world-sync.test.ts +++ b/tests/core/world-sync.test.ts @@ -1,38 +1,28 @@ import { describe, expect, it } from 'vitest'; -import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; -import { CommandStack } from '../../src/core/commands/CommandStack'; -import { ClearOverrideCommand, PlaceAssetCommand, RemoveSceneObjectCommand, SetOverrideCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands'; -import { createEmptyProject, findScene } from '../../src/core/model/Project'; -import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; +import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; +import { ClearOverrideCommand, RemoveSceneObjectCommand, SetOverrideCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands'; +import { findScene } from '../../src/core/model/Project'; import { World } from '../../src/runtime/World'; +import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers'; -/** A scene with a 100×100 box asset and two placed boxes. */ +/** Two boxes: a at the origin and b at (300,0). */ function setup() { - const project = createEmptyProject('p', 'Sync'); - const stack = new CommandStack(project); - const scene = new CreateAssetCommand('scene', 'Level'); - const sprite = new CreateAssetCommand('sprite', 'Box'); - stack.execute(scene); - stack.execute(sprite); - const a = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 0 }); - const b = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 300, y: 0 }); - stack.execute(a); - stack.execute(b); - const sceneOf = () => findScene(project, scene.asset.id)!; - const object = (id: string) => sceneOf().objects.find((o) => o.id === id)!; - return { project, stack, sceneId: scene.asset.id, spriteId: sprite.asset.id, a: a.objectId, b: b.objectId, sceneOf, object }; + const f = makeSpriteScene(); + const a = place(f, f.spriteId, { x: 0, y: 0 }); + const b = place(f, f.spriteId, { x: 300, y: 0 }); + return { ...f, a, b }; } describe('world ↔ model synchronisation (editing while playing)', () => { it('syncFromModel picks up placed, moved, edited, re-parented and removed objects', () => { - const { project, stack, sceneId, spriteId, a, b, object } = setup(); - const world = World.instantiate(project, sceneId); + const f = setup(); + const { stack, sceneId, spriteId, a, b, object } = f; + const { world } = startWorld(f); expect(world.entities.map((e) => e.objectId)).toEqual([a, b]); // Placed while playing → new entity, existing ones keep their identity. const entityA = world.findByObjectId(a)!; - const c = new PlaceAssetCommand(sceneId, spriteId, { x: 50, y: 50 }); - stack.execute(c); + const c = place(f, spriteId, { x: 50, y: 50 }); world.syncFromModel(); expect(world.entities).toHaveLength(3); expect(world.findByObjectId(a)).toBe(entityA); @@ -50,14 +40,15 @@ describe('world ↔ model synchronisation (editing while playing)', () => { expect(world.findByObjectId(b)!.x).toBe(object(b).position.x); // Removed while playing → entity gone. - stack.execute(new RemoveSceneObjectCommand(sceneId, c.objectId)); + stack.execute(new RemoveSceneObjectCommand(sceneId, c)); world.syncFromModel(); expect(world.entities.map((e) => e.objectId)).toEqual([a, b]); }); it('syncToModel writes positions, changed values as overrides and destroyed entities back', () => { - const { project, sceneId, a, b, object, sceneOf } = setup(); - const world = World.instantiate(project, sceneId); + const f = setup(); + const { project, sceneId, a, b, object, sceneOf } = f; + const { world } = startWorld(f); const entityA = world.findByObjectId(a)!; entityA.x = 123; entityA.props.width = 999; // what a Set property node would do @@ -83,8 +74,9 @@ describe('world ↔ model synchronisation (editing while playing)', () => { }); it('spawn adds an object to the runtime scene and an entity at once, without resetting others', () => { - const { project, sceneId, spriteId, a, sceneOf } = setup(); - const world = World.instantiate(project, sceneId); + const f = setup(); + const { sceneId, spriteId, a, sceneOf } = f; + const { world } = startWorld(f); const entityA = world.findByObjectId(a)!; entityA.x = 77; // moved earlier in this tick – a spawn must not pull the model value back @@ -118,14 +110,14 @@ describe('world ↔ model synchronisation (editing while playing)', () => { }); it('a play-mode copy leaves the original model untouched', () => { - const { project, sceneId, a, object } = setup(); - const copy = deserializeProject(serializeProject(project), project.id); + const { project, sceneId, a, object, sceneOf } = setup(); + const copy = roundTrip(project); const world = World.instantiate(copy, sceneId); world.findByObjectId(a)!.x = 500; world.remove(world.findByObjectId(a)!); world.syncToModel(); expect(findScene(copy, sceneId)!.objects).toHaveLength(1); expect(object(a).position.x).toBe(0); - expect(findScene(project, sceneId)!.objects).toHaveLength(2); + expect(sceneOf().objects).toHaveLength(2); }); }); diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 00000000..b9d2c656 --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,165 @@ +import { CreateAssetCommand } from '../src/core/commands/assetCommands'; +import { CommandStack } from '../src/core/commands/CommandStack'; +import { AddNodeCommand, ConnectCommand } from '../src/core/commands/graphCommands'; +import { PlaceAssetCommand } from '../src/core/commands/sceneObjectCommands'; +import { AddVariableCommand } from '../src/core/commands/variableCommands'; +import { type EntityHandle, type GraphNode, type NodeContext, type PointerState, type Value, requireNodeDef } from '../src/core/graph'; +import type { BehaviorAsset, SceneAsset } from '../src/core/model/Asset'; +import { findObject, worldPosition } from '../src/core/model/hierarchy'; +import type { Position, PropertyBag, Vec2 } from '../src/core/model/Properties'; +import { createEmptyProject, findScene, type Project } from '../src/core/model/Project'; +import type { SceneObject } from '../src/core/model/SceneObject'; +import { deserializeProject, serializeProject } from '../src/core/store/serialize'; +import { BehaviorSystem } from '../src/runtime/systems/BehaviorSystem'; +import { CollisionSystem } from '../src/runtime/systems/CollisionSystem'; +import { Input } from '../src/runtime/systems/Input'; +import { World } from '../src/runtime/World'; + +export interface ProjectFixture { + project: Project; + stack: CommandStack; +} + +export interface SceneFixture extends ProjectFixture { + sceneId: string; + spriteId: string; + /** The scene asset, looked up fresh (undo may replace it). */ + sceneOf(): SceneAsset; + object(id: string): SceneObject; + /** World position of a scene object, ancestors included. */ + world(id: string): Position; +} + +/** An empty project with an undo stack. */ +export function makeProject(): ProjectFixture { + const project = createEmptyProject('p', 'P'); + return { project, stack: new CommandStack(project) }; +} + +/** A project with a scene "Level" and a 100×100 sprite (named "Box" unless told otherwise), plus lookups into the scene. */ +export function makeSpriteScene(spriteName = 'Box'): SceneFixture { + const fixture = makeProject(); + const scene = new CreateAssetCommand('scene', 'Level'); + const sprite = new CreateAssetCommand('sprite', spriteName); + fixture.stack.execute(scene); + fixture.stack.execute(sprite); + const sceneOf = () => findScene(fixture.project, scene.asset.id)!; + const object = (id: string) => findObject(sceneOf(), id)!; + return { ...fixture, sceneId: scene.asset.id, spriteId: sprite.asset.id, sceneOf, object, world: (id) => worldPosition(sceneOf(), object(id)) }; +} + +/** Places an asset into the fixture's scene at a world position (optionally below a parent) and returns the new object's id. */ +export function place(fixture: SceneFixture, assetId: string, position: Vec2 | Position, parentId = ''): string { + const command = new PlaceAssetCommand(fixture.sceneId, assetId, position, parentId); + fixture.stack.execute(command); + return command.objectId; +} + +/** Adds a behavior asset declaring the given number variables. */ +export function makeBehavior(fixture: ProjectFixture, name = 'B', variables: readonly string[] = []): BehaviorAsset { + const command = new CreateAssetCommand('behavior', name); + fixture.stack.execute(command); + for (const key of variables) fixture.stack.execute(new AddVariableCommand(command.asset.id, key, 'number')); + return command.asset as BehaviorAsset; +} + +export type Edge = readonly [from: GraphNode, fromPort: string, to: GraphNode, toPort: string]; + +export interface GraphEditor { + /** Adds a node with inline params (graph coordinates do not matter to tests). */ + addNode(type: string, params?: PropertyBag): GraphNode; + connect(from: GraphNode, fromPort: string, to: GraphNode, toPort: string): void; + wire(edges: readonly Edge[]): void; +} + +/** Command-driven editing of one behavior's graph, the way the editor does it. */ +export function graphEditor(fixture: ProjectFixture, behavior: BehaviorAsset): GraphEditor { + const { stack } = fixture; + const connect: GraphEditor['connect'] = (from, fromPort, to, toPort) => { + stack.execute(new ConnectCommand(behavior.id, { node: from.id, port: fromPort }, { node: to.id, port: toPort })); + }; + return { + addNode(type, params = {}) { + const command = new AddNodeCommand(behavior.id, type, 0, 0, params); + stack.execute(command); + return command.node; + }, + connect, + wire(edges) { + for (const [from, fromPort, to, toPort] of edges) connect(from, fromPort, to, toPort); + }, + }; +} + +export const NO_POINTER: PointerState = { x: 0, y: 0, inside: false, down: false, pressed: false, released: false }; + +/** A top-level entity handle named after itself, with empty props. */ +export function fakeEntity(name: string, x = 0, assetId = `asset-${name}`): EntityHandle { + return { id: name, assetId, name, x, y: 0, z: 0, props: {}, parent: null }; +} + +/** An entity handle "h" carrying the given props, optionally below a parent. */ +export function fakeHandle(props: PropertyBag, parent: EntityHandle | null = null): EntityHandle { + return { id: 'h', assetId: 'a', name: 'h', x: 0, y: 0, z: 0, props, parent }; +} + +export type FakeContext = NodeContext & { logs: string[]; stopped: boolean }; + +/** A NodeContext double: self/owner handles, `speed = 100`, dt 0.5, moves applied directly, `log` and `stop` recorded. */ +export function fakeContext(overrides: Partial = {}): FakeContext { + const logs: string[] = []; + const ctx: FakeContext = { + self: fakeEntity('self'), + owner: fakeEntity('owner'), + variables: { speed: 100 }, + time: 0, + dt: 0.5, + logs, + stopped: false, + findByName: () => null, + isKeyDown: () => false, + pointer: { ...NO_POINTER }, + hitTest: () => false, + isTouching: () => false, + moveAndSlide: (target, dx, dy) => { + target.x += dx; + target.y += dy; + return { blockedX: false, blockedY: false }; + }, + log: (message) => logs.push(message), + spawn: () => null, + destroy: () => {}, + stop: () => { + ctx.stopped = true; + }, + restart: () => {}, + ...overrides, + }; + return ctx; +} + +/** Runs one node definition in isolation and returns its data outputs. */ +export function runNode(type: string, inputs: Record, params: PropertyBag = {}, ctx: NodeContext = fakeContext()): Record { + return requireNodeDef(type).run!(ctx, inputs, params).outputs ?? {}; +} + +export interface WorldOptions { + collisions?: boolean; + behaviors?: boolean; +} + +/** Instantiates the fixture's scene, optionally with a CollisionSystem and a BehaviorSystem (fed by those collisions). */ +export function startWorld(fixture: SceneFixture, options: WorldOptions & { collisions: true }): { world: World; collisions: CollisionSystem }; +export function startWorld(fixture: SceneFixture, options?: WorldOptions): { world: World; collisions: CollisionSystem | null }; +export function startWorld(fixture: SceneFixture, options: WorldOptions = {}): { world: World; collisions: CollisionSystem | null } { + const world = World.instantiate(fixture.project, fixture.sceneId); + const collisions = options.collisions ? new CollisionSystem() : null; + if (collisions) world.addSystem(collisions); + if (options.behaviors) world.addSystem(new BehaviorSystem(fixture.project, new Input(), collisions)); + return { world, collisions }; +} + +/** The project as it comes back after being saved and loaded again. */ +export function roundTrip(project: Project): Project { + return deserializeProject(serializeProject(project)); +} diff --git a/tools/e2e.mjs b/tools/e2e.mjs index 2f766582..2839aea2 100644 --- a/tools/e2e.mjs +++ b/tools/e2e.mjs @@ -1,825 +1,20 @@ #!/usr/bin/env node /** - * Headless end-to-end test driven over the Chrome DevTools Protocol. - * Usage: PATH=/opt/node22/bin:$PATH node tools/e2e.mjs [projectId] [baseUrl] - * Writes screenshots to /tmp/gm-e2e-*.png and prints PASS/FAIL checks. No dependencies. + * Headless end-to-end checks of the editor, driven over the Chrome DevTools Protocol. + * Usage: PATH=/opt/node22/bin:$PATH node tools/seed-e2e.mjs && node tools/e2e.mjs [projectId] [baseUrl] + * Prints PASS/FAIL per check, writes screenshots to /tmp/gm-e2e-*.png. No dependencies. * - * Runs against the dedicated fixture project (the demo project is refused) and mutates it: - * node tools/seed-e2e.mjs && node tools/e2e.mjs - * The scene it expects is produced by tools/seed-e2e.mjs. + * Runs against the fixture project written by tools/seed-e2e.mjs and mutates it heavily; the + * steps in tools/e2e/ run in order and build on each other's scene state. */ -import { runChecks } from './harness.mjs'; -import { PROJECTS, cliArgs } from './projects.mjs'; +import { STEPS } from './e2e/index.mjs'; +import { runChecks } from './lib/harness.mjs'; +import { PROJECTS, cliArgs } from './lib/projects.mjs'; const { projectId, baseUrl } = cliArgs('e2e.mjs', PROJECTS.fixture); await runChecks({ projectId, baseUrl, port: 9333, shotPrefix: 'gm-e2e' }, async (t) => { - const { - cdp, consoleLog, check, sleep, - evaluate, locate, shot, mouse, click, clickText, drag, keyPress, typeText, api, sceneOf, stateJson, - } = t; - - await cdp.send('Page.navigate', { url: `${baseUrl}?project=${projectId}` }); - await sleep(4000); - await shot('01-loaded'); - check('page loaded without exceptions', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).join(' | ')); - check('debug hook present', Boolean(await evaluate('Boolean(window.__gm)'))); - - // 0a. Hierarchy folding. A freshly opened scene is fully collapsed, so only its top-level - // objects are listed (o4/oc1 hang under o1, oc2 under o2). The chevron folds a row open - // and shut without touching the selection, and selecting a nested object unfolds the way - // down to it. - const rowIds = () => evaluate('JSON.stringify(__gm.hierarchyOrder())').then(JSON.parse); - const selectionBefore = JSON.parse(await stateJson()).selection; - check('hierarchy starts collapsed – only top-level objects are listed', JSON.stringify(await rowIds()) === JSON.stringify(['o1', 'o2', 'o3', 'o5']), JSON.stringify(await rowIds())); - check('a leaf row offers no chevron', (await evaluate(`__gm.hierarchyChevron('o3')`)) === null); - const chevron = JSON.parse(await evaluate(`JSON.stringify(__gm.hierarchyChevron('o2'))`)); - await click(chevron.x, chevron.y); - await sleep(150); - check( - 'clicking the chevron unfolds the row, leaving the selection alone', - JSON.stringify(await rowIds()) === JSON.stringify(['o1', 'o2', 'oc2', 'o3', 'o5']) && JSON.stringify(JSON.parse(await stateJson()).selection) === JSON.stringify(selectionBefore), - JSON.stringify(await rowIds()), - ); - await shot('01a-hierarchy-unfolded'); - await click(chevron.x, chevron.y); - await sleep(150); - check('clicking it again folds the subtree away', JSON.stringify(await rowIds()) === JSON.stringify(['o1', 'o2', 'o3', 'o5']), JSON.stringify(await rowIds())); - await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'oc1' })`); - await sleep(150); - check('selecting a nested object unfolds its ancestors', JSON.stringify(await rowIds()) === JSON.stringify(['o1', 'o4', 'oc1', 'o2', 'o3', 'o5']), JSON.stringify(await rowIds())); - await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()'); - await sleep(150); - check('Collapse all folds everything back', JSON.stringify(await rowIds()) === JSON.stringify(['o1', 'o2', 'o3', 'o5']), JSON.stringify(await rowIds())); - - // 1. Select the blue crate in the hierarchy. - await clickText('Blue crate'); - let s = JSON.parse(await stateJson()); - check('hierarchy click selects object', s.selection?.kind === 'object' && s.selection.objectId === 'o2', JSON.stringify(s.selection)); - await shot('02-object-selected'); - - // 2. Rename via inspector: the name field sits right of the "Name" label. - const nameLabel = await locate('Name'); - await click(nameLabel.x + 160, nameLabel.y); - await keyPress('a', 'KeyA', { modifiers: 2, windowsVirtualKeyCode: 65 }); // Ctrl+A in the bridge input - await typeText('Renamed crate'); - await shot('03-typing'); - await keyPress('Enter', 'Enter', { windowsVirtualKeyCode: 13 }); - await sleep(200); - s = JSON.parse(await stateJson()); - const nameNow = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').name`); - check('typing + Enter renamed the object', nameNow === 'Renamed crate', JSON.stringify(nameNow)); - await shot('04-renamed'); - - // 3. Scrub the X field (right of the "X" label) by 100 px. - const xLabel = await locate('X'); - await drag(xLabel.x + 160, xLabel.y, xLabel.x + 260, xLabel.y); - const xNow = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`); - check('scrubbing X moved the object', xNow > 120, `x=${xNow}`); - await shot('05-scrubbed'); - - // 3b. Type into X, then click straight into the empty viewport: the typed value must be - // committed (not reverted) even though the click deselects and rebuilds the inspector. - const center = await evaluate('__gm.viewportCenter()'); - await click(xLabel.x + 160, xLabel.y); // plain click → text editing with everything selected - await typeText('300'); - await click(center.x, center.y + 250); - const xTyped = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`); - s = JSON.parse(await stateJson()); - check('typed X survives clicking into the viewport', xTyped === 300 && s.selection === null, `x=${xTyped} selection=${JSON.stringify(s.selection)}`); - - // 4. Drag the red crate in the viewport. It is at world (-150,0); the view is centred - // on the origin, so compute its screen position from the viewport widget bounds. - await drag(center.x - 150, center.y, center.x - 50, center.y - 70); - const o1Now = await evaluate(`JSON.stringify(__gm.state.activeScene.objects.find(o => o.id === 'o1').position)`); - check('viewport drag moved the red crate', JSON.parse(o1Now).x > -150, 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'); - check('rename persisted', renamed.name === 'Renamed crate', renamed.name); - check('scrub persisted', renamed.position.x > 120, JSON.stringify(renamed.position)); - check('override kept', renamed.overrides.color === 4491519, JSON.stringify(renamed.overrides)); - check('save status is saved', (JSON.parse(await stateJson())).save === 'saved'); - - // 6. Undo four times (drag, typed X, scrub, rename) with Ctrl+Z, then redo once. - await click(center.x, center.y + 200); // click empty viewport → deselect, drop focus - for (let i = 0; i < 4; i++) { - await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); - await sleep(80); - } - const afterUndo = JSON.parse(await evaluate(`JSON.stringify(__gm.state.activeScene.objects.map(o => ({ id: o.id, name: o.name, x: o.position.x })))`)); - check('undo reverted drag, typed X, scrub and rename', afterUndo[0].x === -150 && afterUndo[1].x === 120 && afterUndo[1].name === 'Blue crate', JSON.stringify(afterUndo)); - await keyPress('z', 'KeyZ', { modifiers: 10, windowsVirtualKeyCode: 90 }); // Ctrl+Shift+Z - const afterRedo = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').name`); - check('redo restored the rename', afterRedo === 'Renamed crate', afterRedo); - await shot('07-undo-redo'); - - // 7. Play: the camera frame is rendered (both crates lie inside the 640×360 frame at the - // origin; the behavior symbol is editor-only). The "Mover" behavior owns the red crate: - // holding ArrowRight moves it in the world (Axis input × speed × dt), Esc stops without - // touching the model. - await clickText('Renamed crate'); - const xBeforePlay = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`); - await clickText('▶ Play'); - await sleep(300); - s = JSON.parse(await stateJson()); - check('play mode entered', s.mode === 'play', JSON.stringify(s)); - let info = await evaluate('JSON.stringify(__gm.gameInfo())'); - check('play renders the camera frame with both crates', JSON.parse(info).rendering && JSON.parse(info).visibleItems === 2, info); - await shot('08-playing'); - await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 }); - await sleep(400); - await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 }); - const worldAfterRight = JSON.parse(await evaluate('JSON.stringify(__gm.world())')); - const movedCrate = worldAfterRight.entities.find((e) => e.objectId === 'o1'); - check('behavior graph moved its owner while ArrowRight was held', movedCrate.x > -150 + 30, `world x=${movedCrate.x}`); - await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 }); - await sleep(250); - await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 }); - const afterLeft = JSON.parse(await evaluate('JSON.stringify(__gm.world())')).entities.find((e) => e.objectId === 'o1'); - check('ArrowLeft (axis x = −1) moved the owner back left', afterLeft.x < movedCrate.x, `world x=${afterLeft.x}`); - await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 }); - await sleep(300); - s = JSON.parse(await stateJson()); - check('escape stopped play', s.mode === 'edit', JSON.stringify(s)); - await shot('09-stopped'); - const o2AfterPlay = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`); - const o1AfterPlay = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').position.x`); - check('play did not write back to the model', o2AfterPlay === xBeforePlay && o1AfterPlay === -150, `${o2AfterPlay} vs ${xBeforePlay}, o1=${o1AfterPlay}`); - - // 7a. Editing while playing: Play opens the Game tab, the panels stay open and edit a runtime - // copy. Moving the blue crate (model hook + preview, as a scrub does) and deleting it with - // the Delete key change the world at once, undo brings it back, Esc restores the saved model. - await clickText('▶ Play'); - await sleep(300); - check('play opens the Game tab', (await evaluate('__gm.tab()')) === 'game'); - check('panels stay visible while playing', Boolean(await evaluate(`__gm.locate('INSPECTOR')`)) && (await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 7); - await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'o2' })`); - await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 400; __gm.state.preview()`); - await sleep(100); - let liveO2 = JSON.parse(await evaluate('JSON.stringify(__gm.world())')).entities.find((e) => e.objectId === 'o2'); - check('an edit while playing moves the entity in the world', liveO2 && liveO2.x === 400, JSON.stringify(liveO2)); - await shot('08b-editing-while-playing'); - await keyPress('Delete', 'Delete', { windowsVirtualKeyCode: 46 }); - await sleep(150); - const liveIds = JSON.parse(await evaluate('JSON.stringify(__gm.world())')).entities.map((e) => e.objectId); - check('Delete while playing removes the entity and its collider child from the world', !liveIds.includes('o2') && !liveIds.includes('oc2') && liveIds.length === 5, JSON.stringify(liveIds)); - await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); - await sleep(150); - liveO2 = JSON.parse(await evaluate('JSON.stringify(__gm.world())')).entities.find((e) => e.objectId === 'o2'); - check('undo while playing brings the entity back into the world', liveO2 && liveO2.x === 400, JSON.stringify(liveO2)); - // 7a-ii. Spawning: the "Spawner" behavior places a Crate at (0, 150) on Space. The new object - // shows up in the runtime scene (hierarchy), in the world and in the Game view at once; - // Stop discards it with the rest of the runtime copy. - const visibleBeforeSpawn = JSON.parse(await evaluate('JSON.stringify(__gm.gameInfo())')).visibleItems; - await keyPress(' ', 'Space', { windowsVirtualKeyCode: 32 }); - await sleep(200); - const spawnedObjects = JSON.parse(await evaluate('JSON.stringify(__gm.state.activeScene.objects)')); - const spawned = spawnedObjects.find((o) => !['o1', 'o2', 'o3', 'o4', 'o5', 'oc1', 'oc2'].includes(o.id)); - check('Spawn node adds a crate object to the runtime scene at (0, 150)', spawned && spawned.assetId === 'crate' && spawned.parentId === '' && spawned.position.x === 0 && spawned.position.y === 150 && spawnedObjects.length === 8, JSON.stringify(spawned)); - const spawnWorld = JSON.parse(await evaluate('JSON.stringify(__gm.world())')); - check('the spawned entity lives in the world and its name reached the Log node', spawnWorld.entities.length === 8 && spawnWorld.log.includes('Crate'), JSON.stringify({ n: spawnWorld.entities.length, log: spawnWorld.log })); - check('hierarchy lists the spawned object', (await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 8); - info = await evaluate('JSON.stringify(__gm.gameInfo())'); - check('the Game view renders the spawned crate', JSON.parse(info).visibleItems === visibleBeforeSpawn + 1, `${info} before=${visibleBeforeSpawn}`); - await shot('08c-spawned'); - await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 }); - await sleep(300); - const o2AfterEdit = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`); - check('stop restores the saved model untouched by play-mode edits (spawned object gone)', o2AfterEdit === xBeforePlay && (await evaluate('__gm.state.activeScene.objects.length')) === 7, `x=${o2AfterEdit}`); - check('stop returns to the Scene tab', (await evaluate('__gm.tab()')) === 'scene'); - - // 7b. Culling: move the blue crate far outside the camera frame → only one item visible. - await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 5000; __gm.state.preview()`); - await clickText('▶ Play'); - await sleep(300); - info = await evaluate('JSON.stringify(__gm.gameInfo())'); - check('objects outside the camera frame are culled', JSON.parse(info).visibleItems === 1, info); - await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 }); - await sleep(200); - await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = ${xBeforePlay}; __gm.state.preview()`); - - // 7c. Without a camera nothing is rendered: delete the camera object, play, then undo. - await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'o3' })`); - await keyPress('Delete', 'Delete', { windowsVirtualKeyCode: 46 }); - await sleep(200); - check('camera object deleted', (await evaluate('__gm.state.activeScene.objects.length')) === 6); - await clickText('▶ Play'); - await sleep(300); - info = await evaluate('JSON.stringify(__gm.gameInfo())'); - s = JSON.parse(await stateJson()); - check('play without camera renders nothing', s.mode === 'play' && !JSON.parse(info).rendering && JSON.parse(info).visibleItems === 0, info); - check('play without camera shows a notice', s.notice.includes('no camera'), s.notice); - await shot('10-play-no-camera'); - await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 }); - await sleep(300); - await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); - await sleep(200); - check('undo restored the camera object', (await evaluate('__gm.state.activeScene.objects.length')) === 7); - - // 8. Context menu on the Crate asset → menu shows "Place in scene". - await clickText('Crate', 'right'); - await sleep(200); - const menuVisible = await evaluate(`Boolean(__gm.locate('Place in scene'))`); - check('asset context menu opens', menuVisible); - await shot('11-context-menu'); - await clickText('Place in scene'); - await sleep(200); - const count = await evaluate('__gm.state.activeScene.objects.length'); - check('placing from the menu adds an object', count === 8, `objects=${count}`); - - // 9. Create a new asset through the + menu. - await clickText('+'); - await sleep(150); - await clickText('Sprite'); - await sleep(200); - const assets = await evaluate(`JSON.stringify(__gm.state.project.assets.map(a => a.name))`); - check('+ menu creates a sprite asset', JSON.parse(assets).includes('Sprite'), assets); - await shot('12-new-asset'); - - // 10. Symbols stay on top: put the camera first in the hierarchy (drawn first) and a crate - // right on top of its icon → the icon must still be drawn above the sprite. - await evaluate(`(() => { - const objs = __gm.state.activeScene.objects; - const cam = objs.splice(objs.findIndex(o => o.id === 'o3'), 1)[0]; - objs.unshift(cam); - const crate = objs.find(o => o.id === 'o1'); - crate.position.x = 0; crate.position.y = 0; - __gm.state.select(null); - __gm.state.preview(); - })()`); - await sleep(200); - const layers = await evaluate(`JSON.stringify([__gm.layerOf('o3'), __gm.layerOf('o1'), __gm.layerOf('o2')])`); - check('camera symbol is drawn in the overlay layer above sprites', layers === '["overlay","objects","objects"]', layers); - await shot('13-symbol-on-top'); - - // 11. Z-order: put both crates on the origin. With equal z the hierarchy order wins - // (o1 before o2 → o2 on top); raising o1's z draws it above o2; undo restores it. - await evaluate(`(() => { - const o2 = __gm.state.activeScene.objects.find(o => o.id === 'o2'); - o2.position.x = 0; o2.position.y = 0; - __gm.state.preview(); - })()`); - await sleep(200); - const orderBefore = await evaluate(`JSON.stringify(__gm.drawOrder().filter(id => id === 'o1' || id === 'o2'))`); - check('equal z keeps hierarchy order', orderBefore === '["o1","o2"]', orderBefore); - await evaluate(`__gm.state.select({ kind: 'object', sceneId: __gm.state.activeScene.id, objectId: 'o1' })`); - await sleep(200); - const zLabel = await locate('Z'); - await click(zLabel.x + 160, zLabel.y); // plain click → text editing with everything selected - await typeText('5'); - await keyPress('Enter', 'Enter', { windowsVirtualKeyCode: 13 }); - await sleep(200); - const zNow = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').position.z`); - check('Z field sets position.z', zNow === 5, `z=${zNow}`); - const orderAfter = await evaluate(`JSON.stringify(__gm.drawOrder().filter(id => id === 'o1' || id === 'o2'))`); - check('higher z is drawn on top', orderAfter === '["o2","o1"]', orderAfter); - await shot('14-z-order'); - await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); - await sleep(200); - const zUndone = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').position.z`); - check('undo restores z', zUndone === 0, `z=${zUndone}`); - - // 12. Asset defaults: the Crate asset has height 60 (schema default 100), width and - // color at their defaults → exactly one reset button is visible. Clicking it puts - // height back to 100 and hides the button; undo brings 60 back. - await evaluate(`__gm.state.select({ kind: 'asset', assetId: 'crate' })`); - await sleep(200); - const resetButton = await evaluate(`__gm.locate('↺')`); - check('asset inspector shows a reset for the non-default height', Boolean(resetButton)); - await click(resetButton.x, resetButton.y); - await sleep(200); - const heightReset = await evaluate(`__gm.state.project.assets.find(a => a.id === 'crate').props.height`); - check('reset puts the asset property back to its default', heightReset === 100, `height=${heightReset}`); - check('reset button hidden once the value is at default', !(await evaluate(`__gm.locate('↺')`))); - await shot('15-asset-reset'); - await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); - await sleep(200); - const heightUndone = await evaluate(`__gm.state.project.assets.find(a => a.id === 'crate').props.height`); - check('undo restores the asset value', heightUndone === 60, `height=${heightUndone}`); - check('reset button visible again after undo', Boolean(await evaluate(`__gm.locate('↺')`))); - - // 13. Behavior graph editor. Selecting the Mover asset binds the graph; the Graph tab shows it. - await clickText('Mover'); - await evaluate(`__gm.showTab('graph')`); - await sleep(250); - check('graph editor shows the selected behavior', (await evaluate(`__gm.graph.current()`)) === 'mover' && (await evaluate('__gm.tab()')) === 'graph'); - await shot('16-graph'); - const nodeOf = (id) => evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === ${JSON.stringify(id)}))`).then(JSON.parse); - const graphCounts = () => evaluate(`(() => { const g = __gm.state.project.assets.find(a => a.id === 'mover').graph; return JSON.stringify({ nodes: g.nodes.length, edges: g.edges.length }); })()`).then(JSON.parse); - - // 13a. Drag a node: clicking its title selects it, dragging moves it (undoable). - const mulBefore = await nodeOf('n-axis'); - const mulPos = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.nodeAt('n-axis'))`)); - await drag(mulPos.x, mulPos.y, mulPos.x + 60, mulPos.y + 40); - s = JSON.parse(await stateJson()); - const mulAfter = await nodeOf('n-axis'); - check('dragging a node selects and moves it', s.selection?.kind === 'node' && s.selection.nodeId === 'n-axis' && mulAfter.x === mulBefore.x + 60 && mulAfter.y === mulBefore.y + 40, `${JSON.stringify(s.selection)} ${mulBefore.x},${mulBefore.y} → ${mulAfter.x},${mulAfter.y}`); - check('node inspector shows the node', Boolean(await evaluate(`__gm.locate('Axis input')`)) && Boolean(await evaluate(`__gm.locate('DATA NODE')`))); - // The Move node's unconnected `scale` shows its port default (1) once its edge is removed; - // with the edge present the inspector hides it. Port defaults are covered by unit tests. - await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); - await sleep(150); - const mulUndone = await nodeOf('n-axis'); - check('undo moves the node back', mulUndone.x === mulBefore.x && mulUndone.y === mulBefore.y, `${mulUndone.x},${mulUndone.y}`); - - // 13a. Regression: typing a query and then *clicking* a row must add exactly one node. - // (Previously the search field's blur-commit picked the first match and the row click - // picked it again → two nodes.) - const gcenter = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.center())`)); - const beforeClickPick = await graphCounts(); - await click(gcenter.x + 250, gcenter.y + 250, 'right'); - await sleep(250); - check('node palette opens (row-click case)', Boolean(await evaluate(`__gm.locate('Search nodes…')`))); - await typeText('stop'); - await sleep(150); - const stopRow = await locate('Stop game'); // unique on screen (nothing else on screen shows this label) - await click(stopRow.x, stopRow.y); - await sleep(250); - const afterClickPick = await graphCounts(); - check('clicking a palette row adds exactly one node', afterClickPick.nodes === beforeClickPick.nodes + 1, JSON.stringify({ beforeClickPick, afterClickPick })); - check('palette closed after row click', !(await evaluate(`__gm.locate('Search nodes…')`))); - // Undo so 13b finds the same empty spot and the expected graph. - await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); - await sleep(150); - check('undo removes the clicked node again', (await graphCounts()).nodes === beforeClickPick.nodes); - - // 13b. Right-click the empty canvas → palette → search "log" → Enter adds a Log node. - const before13 = await graphCounts(); - await click(gcenter.x + 250, gcenter.y + 250, 'right'); - await sleep(250); - check('node palette opens', Boolean(await evaluate(`__gm.locate('Search nodes…')`))); - await typeText('log'); - await sleep(100); - await keyPress('Enter', 'Enter', { windowsVirtualKeyCode: 13 }); - await sleep(250); - const after13 = await graphCounts(); - s = JSON.parse(await stateJson()); - const newNode = s.selection?.kind === 'node' ? await nodeOf(s.selection.nodeId) : null; - check('palette adds a Log node and selects it', after13.nodes === before13.nodes + 1 && newNode?.type === 'Log', JSON.stringify({ after13, newNode })); - await shot('17-node-added'); - - // 13c. Connect: drag from the upper Move node's exec output onto the Log node's exec input. - const fromPort = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.portAt('n-move', 'exec', true))`)); - const toPort = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.portAt(${JSON.stringify(newNode.id)}, 'exec', false))`)); - await drag(fromPort.x, fromPort.y, toPort.x, toPort.y, 16); - const connected = await graphCounts(); - check('dragging port to port connects the nodes', connected.edges === before13.edges + 1, JSON.stringify(connected)); - // An exec output must not connect to a data input: dragging onto "message" does nothing. - const dataPort = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.portAt(${JSON.stringify(newNode.id)}, 'message', false))`)); - await drag(fromPort.x, fromPort.y, dataPort.x, dataPort.y, 16); - check('incompatible ports are rejected', (await graphCounts()).edges === before13.edges + 1 && s.mode === 'edit'); - await shot('18-connected'); - - // 13d. Delete removes the selected node together with its edge; undo brings both back. - await evaluate(`__gm.state.select({ kind: 'node', assetId: 'mover', nodeId: ${JSON.stringify(newNode.id)} })`); - await keyPress('Delete', 'Delete', { windowsVirtualKeyCode: 46 }); - await sleep(150); - const deleted = await graphCounts(); - check('Delete removes the node and its edge', deleted.nodes === before13.nodes && deleted.edges === before13.edges, JSON.stringify(deleted)); - await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); - await sleep(150); - const restored = await graphCounts(); - check('undo restores node and edge', restored.nodes === before13.nodes + 1 && restored.edges === before13.edges + 1, JSON.stringify(restored)); - - // 13e. Play always starts on the Game tab; switching to Graph while playing shows - // the live highlight: hold ArrowRight → the exec edge is hot. - const fpsEdit = await evaluate('__gm.fps()'); - check('fps readout is measured in edit mode', typeof fpsEdit === 'number' && fpsEdit > 0, `fps=${fpsEdit}`); - // Single evaluate: the readout refreshes twice a second, so read value and label together. - const fpsLabel = await evaluate(`!!__gm.locate(__gm.fps() + ' fps')`); - check('fps label is visible in the toolbar', fpsLabel === true); - await clickText('▶ Play'); - await sleep(200); - check('play switches to the game tab', (await evaluate('__gm.tab()')) === 'game'); - await sleep(600); - const fpsPlay = await evaluate('__gm.fps()'); - check('fps readout keeps running in play mode', typeof fpsPlay === 'number' && fpsPlay > 0, `fps=${fpsPlay}`); - await evaluate(`__gm.showTab('graph')`); - await sleep(100); - await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 }); - await sleep(300); - await shot('19-graph-live'); - await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 }); - await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 }); - await sleep(200); - - // 13f. Variables: "+ Variable ▾" → Number adds "var1" with default 0; undo removes it. - await evaluate(`__gm.state.select({ kind: 'asset', assetId: 'mover' })`); - await sleep(150); - await clickText('+ Variable ▾'); - await sleep(150); - await clickText('Number'); - await sleep(200); - const vars = JSON.parse(await evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === 'mover').variables.map(v => v.key))`)); - check('adding a variable declares it on the behavior', vars.join(',') === 'speed,var1', vars.join(',')); - await shot('20-variable-added'); - await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); - await sleep(150); - const varsUndone = JSON.parse(await evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === 'mover').variables.map(v => v.key))`)); - check('undo removes the variable', varsUndone.join(',') === 'speed', varsUndone.join(',')); - - // 13g. The graph strictly mirrors the selection: nothing / a non-behavior → empty editor. - await evaluate(`__gm.state.select(null)`); - await sleep(100); - check('graph editor empties when nothing is selected', (await evaluate(`__gm.graph.current()`)) === null); - await evaluate(`__gm.state.select({ kind: 'asset', assetId: 'cam' })`); - await sleep(100); - check('graph editor stays empty for a non-behavior asset', (await evaluate(`__gm.graph.current()`)) === null); - await evaluate(`__gm.state.select({ kind: 'asset', assetId: 'mover' })`); - await sleep(100); - check('graph editor rebinds when a behavior is selected again', (await evaluate(`__gm.graph.current()`)) === 'mover'); - await evaluate(`__gm.showTab('scene')`); - - // 14. Hierarchy / parenting. Give both crates unique names, then drag the blue crate's row - // onto the red crate's row: it becomes a child, keeps its world position (local becomes - // the difference) and is listed right below its parent. - await evaluate(`(() => { - const objs = __gm.state.activeScene.objects; - const o1 = objs.find(o => o.id === 'o1'); const o2 = objs.find(o => o.id === 'o2'); - o1.name = 'Red crate'; o1.position = { x: -150, y: 0, z: 0 }; - o2.name = 'Blue crate'; o2.position = { x: 120, y: -80, z: 0 }; o2.parentId = ''; - __gm.state.select(null); - __gm.state.preview(); - })()`); - await sleep(200); - const redRow = await locate('Red crate'); - const blueRow = await locate('Blue crate'); - await drag(blueRow.x, blueRow.y, redRow.x, redRow.y); - await sleep(200); - const objectJson = (id) => evaluate(`JSON.stringify(__gm.state.activeScene.objects.find(o => o.id === '${id}'))`); - let o2 = JSON.parse(await objectJson('o2')); - check('dropping a row onto another parents it, keeping the world position', o2.parentId === 'o1' && o2.position.x === 270 && o2.position.y === -80, JSON.stringify(o2)); - const order = JSON.parse(await evaluate(`__gm.hierarchyExpandAll(); JSON.stringify(__gm.hierarchyOrder())`)); - // o1's subtree: o2 (list order before o4) with its collider child, then o4, then o1's collider. - const o1At = order.indexOf('o1'); - check('children are listed directly below their parent', JSON.stringify(order.slice(o1At + 1, o1At + 5)) === JSON.stringify(['o2', 'oc2', 'o4', 'oc1']), JSON.stringify(order)); - await shot('20-parented'); - - // 14a. Moving the parent (X field of the red crate) drags the child along in world space. - await clickText('Red crate'); - const xField = await locate('X'); - await click(xField.x + 160, xField.y); - await keyPress('a', 'KeyA', { modifiers: 2, windowsVirtualKeyCode: 65 }); - await typeText('-50'); - await keyPress('Enter', 'Enter', { windowsVirtualKeyCode: 13 }); - await sleep(200); - o2 = JSON.parse(await objectJson('o2')); - const worldOfO2 = await evaluate(`(() => { const s = __gm.state.activeScene; const child = s.objects.find(x => x.id === 'o2'); const parent = s.objects.find(x => x.id === child.parentId); return JSON.stringify({ x: child.position.x + parent.position.x, y: child.position.y + parent.position.y }); })()`); - check('moving the parent moves the child in world space (local unchanged)', o2.position.x === 270 && worldOfO2 === '{"x":220,"y":-80}', `${JSON.stringify(o2.position)} world=${worldOfO2}`); - - // 14b. Play: entities mirror the hierarchy; undo restores parent and position. - await evaluate(`__gm.state.select(null)`); - await keyPress('Enter', 'Enter', { modifiers: 2, windowsVirtualKeyCode: 13 }); - await sleep(300); - const playWorld = await evaluate('JSON.stringify(__gm.world())'); - const e2 = JSON.parse(playWorld).entities.find((e) => e.objectId === 'o2'); - check('play instantiates the parent link and world positions', e2 && e2.parent === 'o1' && e2.worldX === 220 && e2.x === 270, JSON.stringify(e2)); - await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 }); - await sleep(200); - await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); // undo move - await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); // undo parent - await sleep(200); - o2 = JSON.parse(await objectJson('o2')); - check('undo restores top level and the original local position', o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80, JSON.stringify(o2)); - - // 14c. Parent via the inspector menu, then "Unparent" from the hierarchy context menu: - // the world position is kept both ways. - await clickText('Blue crate'); - await clickText('None ▾'); - await sleep(150); - const menuEntry = await locate('Red crate', true); // the popup is drawn last; the hierarchy row has the same text - await click(menuEntry.x, menuEntry.y); - await sleep(200); - o2 = JSON.parse(await objectJson('o2')); - check('inspector Parent menu parents the object', o2.parentId === 'o1' && o2.position.x === 270, JSON.stringify(o2)); - const blueRow2 = await locate('Blue crate'); - await click(blueRow2.x, blueRow2.y, 'right'); - await sleep(150); - await clickText('Unparent (move to top level)'); - await sleep(200); - o2 = JSON.parse(await objectJson('o2')); - check('Unparent moves the object to the top level, keeping its world position', o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80, JSON.stringify(o2)); - - // 15. Colliders. Right-click the red crate → "Add collider": a collider asset sized like the - // crate (100×60) is placed as its child at the centre, selected and drawn in the overlay. - // The seed already gives both crates a "Crate collider" child (oc1/oc2), so the added one - // is undone again afterwards to keep exactly one collider per body for 15a/15b. - const objectsBeforeCollider = await evaluate('__gm.state.activeScene.objects.length'); - const redRow3 = await locate('Red crate'); - await click(redRow3.x, redRow3.y, 'right'); - await sleep(150); - await clickText('Add collider'); - await sleep(200); - s = JSON.parse(await stateJson()); - const colObj = s.selection?.kind === 'object' ? JSON.parse(await objectJson(s.selection.objectId)) : null; - const colAsset = colObj ? JSON.parse(await evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === '${colObj.assetId}'))`)) : null; - check('Add collider creates a collider child sized like the sprite', colObj?.parentId === 'o1' && colAsset?.type === 'collider' && colAsset.props.width === 100 && colAsset.props.height === 60, JSON.stringify({ colObj, colAsset })); - check('collider is drawn in the overlay layer', colObj && (await evaluate(`__gm.layerOf('${colObj.id}')`)) === 'overlay'); - check('collider inspector shows the shape menu', Boolean(await evaluate(`__gm.locate('Box ▾')`))); - await shot('21-collider'); - await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); - await sleep(200); - const colliderAssetGone = colAsset ? !(await evaluate(`__gm.state.project.assets.some(a => a.id === '${colAsset.id}')`)) : false; - check('undo removes the added collider object and its asset', (await evaluate('__gm.state.activeScene.objects.length')) === objectsBeforeCollider && colliderAssetGone); - - // 15a. Wire "On collision enter → Log(other)" into the Mover (owner = red crate) and play with - // the crates overlapping (each carries its seeded collider): the log names the blue crate - // exactly once (enter, not stay), and colliders are not rendered in play mode. - await evaluate(`(() => { - const scene = __gm.state.activeScene; - const mover = __gm.state.project.assets.find(a => a.id === 'mover'); - mover.graph.nodes.push({ id: 'n-hit', type: 'OnCollisionEnter', x: 0, y: 600, params: {} }, { id: 'n-hitlog', type: 'Log', x: 340, y: 600, params: {} }); - mover.graph.edges.push( - { id: 'e-hit1', from: { node: 'n-hit', port: 'exec' }, to: { node: 'n-hitlog', port: 'exec' } }, - { id: 'e-hit2', from: { node: 'n-hit', port: 'other' }, to: { node: 'n-hitlog', port: 'message' } }); - scene.objects.find(o => o.id === 'o2').position = { x: -100, y: 0, z: 0 }; // 50 from the red crate → 100-wide boxes overlap - __gm.state.select(null); - __gm.state.preview(); - })()`); - await keyPress('Enter', 'Enter', { modifiers: 2, windowsVirtualKeyCode: 13 }); - await sleep(500); - const collisionWorld = JSON.parse(await evaluate('JSON.stringify(__gm.world())')); - check('On collision enter fired once with the other body', collisionWorld && collisionWorld.log.filter((l) => l === 'Blue crate').length === 1, JSON.stringify(collisionWorld?.log)); - const spriteCount = await evaluate(`__gm.state.activeScene.objects.filter(o => __gm.state.project.assets.find(a => a.id === o.assetId)?.type === 'sprite').length`); - info = await evaluate('JSON.stringify(__gm.gameInfo())'); - check('colliders are not rendered in the game view', JSON.parse(info).visibleItems === spriteCount, `${info} sprites=${spriteCount}`); - await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 }); - await sleep(200); - - // 15b. Solid vs trigger. Put the blue crate 150 to the right of the red one (red 100 wide, the - // blue collider 140 wide → 30 of free travel) and hold ArrowRight: the Mover's Move - // (Collide with solids) parks the red crate flush against the blue one at world x = −120. - // Making the blue collider a trigger lets it pass through instead (drawn amber). - const holdRight = async (ms) => { - await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 }); - await sleep(ms); - await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 }); - }; - const playAgainstBlue = async (trigger, speed = 0) => { - await evaluate(`(() => { - const scene = __gm.state.activeScene; - scene.objects.find(o => o.id === 'o2').position = { x: 0, y: 0, z: 0 }; - scene.objects.find(o => o.id === 'oc2').overrides = ${trigger ? '{ width: 140, isTrigger: true }' : '{ width: 140 }'}; - ${speed ? `__gm.state.project.assets.find(a => a.id === 'mover').props.speed = ${speed};` : ''} - __gm.state.select(null); - __gm.state.preview(); - })()`); - await keyPress('Enter', 'Enter', { modifiers: 2, windowsVirtualKeyCode: 13 }); - await sleep(300); - await holdRight(700); - const w = JSON.parse(await evaluate('JSON.stringify(__gm.world())')); - await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 }); - await sleep(200); - return w.entities.find((e) => e.objectId === 'o1'); - }; - const blocked = await playAgainstBlue(false); - check('Move with "Collide with solids" stops the red crate flush against the blue crate\'s solid collider', blocked && Math.abs(blocked.x - -120) < 0.01, `world x=${blocked?.x}`); - const passed = await playAgainstBlue(true); - check('a trigger collider does not block (the crate passes through)', passed && passed.x > -120 + 20, `world x=${passed?.x}`); - // A frame step far larger than the collider must not jump across it (the movement is split - // into sub-steps); speed is restored afterwards so later checks see the seeded value. - const fast = await playAgainstBlue(false, 40000); - check('a very fast mover cannot tunnel through a solid collider', fast && Math.abs(fast.x - -120) < 0.5, `world x=${fast?.x}`); - await evaluate(`(() => { __gm.state.project.assets.find(a => a.id === 'mover').props.speed = 200; __gm.state.preview(); })()`); - await evaluate(`__gm.state.select({ kind: 'object', objectId: 'oc2' })`); - await sleep(200); - await shot('22-trigger-collider'); - - // 16. Multi-selection. Reset both crates to known spots, then Ctrl-click the second row in - // the hierarchy: both are selected, the inspector shows the multi view. - await evaluate(`(() => { - const objs = __gm.state.activeScene.objects; - const o1 = objs.find(o => o.id === 'o1'); const o2 = objs.find(o => o.id === 'o2'); - o1.position = { x: -150, y: 0, z: 0 }; o1.parentId = ''; - o2.position = { x: 120, y: -80, z: 0 }; o2.parentId = ''; - objs.find(o => o.id === 'oc2').overrides = { width: 140 }; - __gm.state.select(null); - __gm.state.preview(); - })()`); - await sleep(200); - const clickMod = async (x, y, modifiers) => { - await mouse('mouseMoved', x, y); - await mouse('mousePressed', x, y, 'left', { modifiers }); - await mouse('mouseReleased', x, y, 'left', { modifiers }); - await sleep(150); - }; - await clickText('Red crate'); - const blueRow3 = await locate('Blue crate'); - await clickMod(blueRow3.x, blueRow3.y, 2); // Ctrl - s = JSON.parse(await stateJson()); - check('Ctrl-click in the hierarchy adds to the selection', s.selection?.kind === 'object' && JSON.stringify(s.selection.objectIds) === '["o1","o2"]' && s.selection.objectId === 'o2', JSON.stringify(s.selection)); - check('inspector shows the multi-selection', Boolean(await evaluate(`__gm.locate('2 OBJECTS SELECTED')`))); - await shot('23-multi-selected'); - await clickMod(blueRow3.x, blueRow3.y, 2); - s = JSON.parse(await stateJson()); - check('Ctrl-click again removes the object from the selection', s.selection?.kind === 'object' && JSON.stringify(s.selection.objectIds) === '["o1"]', JSON.stringify(s.selection)); - await clickMod(blueRow3.x, blueRow3.y, 2); - - // 16a. Create container: both crates (with their colliders and the Mover) become the content - // of a new container asset; the scene gets one container object at the selection's - // centre (-15,-40), the crates hang below it with recomputed local positions, world - // positions are unchanged, and the container is selected. - const assetsBeforeContainer = await evaluate('__gm.state.project.assets.length'); - const objectsBeforeContainer = await evaluate('__gm.state.activeScene.objects.length'); - await clickText('Create container'); - await sleep(250); - s = JSON.parse(await stateJson()); - const containerObj = s.selection?.kind === 'object' ? JSON.parse(await objectJson(s.selection.objectId)) : null; - const containerAsset = containerObj ? JSON.parse(await evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === '${containerObj.assetId}'))`)) : null; - check('Create container adds one container asset and one placed container, selected', containerAsset?.type === 'container' && s.selection.objectIds.length === 1 && (await evaluate('__gm.state.project.assets.length')) === assetsBeforeContainer + 1 && (await evaluate('__gm.state.activeScene.objects.length')) === objectsBeforeContainer + 1, JSON.stringify({ containerObj, type: containerAsset?.type })); - check('the container sits at the centre of the selection', containerObj && containerObj.parentId === '' && containerObj.position.x === -15 && containerObj.position.y === -40 && containerObj.position.z === 0, JSON.stringify(containerObj?.position)); - let o1c = JSON.parse(await objectJson('o1')); - const o2c = JSON.parse(await objectJson('o2')); - check('the crates are re-parented below the container with local positions relative to its centre', o1c.parentId === containerObj?.id && o1c.position.x === -135 && o1c.position.y === 40 && o2c.parentId === containerObj?.id && o2c.position.x === 135 && o2c.position.y === -40, JSON.stringify({ o1: o1c.position, o2: o2c.position })); - check('the template holds the whole subtree (2 crates, 2 colliders, the Mover)', containerAsset?.objects.length === 5 && containerAsset.objects.filter((t) => t.parentId === '').length === 2 && o1c.templateId && containerAsset.objects.some((t) => t.id === o1c.templateId), JSON.stringify(containerAsset?.objects.map((t) => [t.assetId, t.parentId === '' ? 'root' : 'child']))); - const orderC = JSON.parse(await evaluate('__gm.hierarchyExpandAll(); JSON.stringify(__gm.hierarchyOrder())')); - const cAt = orderC.indexOf(containerObj?.id); - check('hierarchy lists the container with its content indented below', cAt >= 0 && JSON.stringify(orderC.slice(cAt)) === JSON.stringify([containerObj.id, 'o1', 'o4', 'oc1', 'o2', 'oc2']), JSON.stringify(orderC)); - check('container symbol is drawn in the overlay layer', containerObj && (await evaluate(`__gm.layerOf('${containerObj.id}')`)) === 'overlay'); - await shot('24-container-created'); - - // 16b. Dragging the container symbol in the viewport moves the whole group; undo restores it. - const cScreen = JSON.parse(await evaluate(`JSON.stringify(__gm.objectScreen('${containerObj.id}'))`)); - await drag(cScreen.x, cScreen.y, cScreen.x + 100, cScreen.y); - const containerMoved = JSON.parse(await objectJson(containerObj.id)); - o1c = JSON.parse(await objectJson('o1')); - check('dragging the container moves the group (children keep their local position)', containerMoved.position.x === 85 && o1c.position.x === -135, JSON.stringify({ container: containerMoved.position, o1: o1c.position })); - await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); - await sleep(150); - check('undo moves the container back', JSON.parse(await objectJson(containerObj.id)).position.x === -15); - - // 16c. Rubber band on empty space around the red crate (world x −230…−90, y −50…50): picks - // the objects whose position lies inside – the crate and its collider, not the Mover - // (at y = 110), not the camera (at the origin). - await drag(center.x - 230, center.y - 50, center.x - 90, center.y + 50); - s = JSON.parse(await stateJson()); - check('rubber-band selection picks the objects inside it', s.selection?.kind === 'object' && JSON.stringify([...s.selection.objectIds].sort()) === '["o1","oc1"]', JSON.stringify(s.selection)); - await shot('25-marquee'); - - // 16d. Dragging one of several selected objects moves all of them as one undo step. - const redScreen = JSON.parse(await evaluate(`JSON.stringify(__gm.objectScreen('o1'))`)); - await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] })`); - await drag(redScreen.x, redScreen.y + 20, redScreen.x + 50, redScreen.y + 20); - o1c = JSON.parse(await objectJson('o1')); - const o2d = JSON.parse(await objectJson('o2')); - check('dragging a multi-selection moves every selected object', o1c.position.x === -85 && o2d.position.x === 185, JSON.stringify({ o1: o1c.position, o2: o2d.position })); - await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); - await sleep(150); - o1c = JSON.parse(await objectJson('o1')); - check('one undo restores both', o1c.position.x === -135 && JSON.parse(await objectJson('o2')).position.x === 135); - - // 16e. Delete with a multi-selection removes all of them (with subtrees) as one step. - const objectsBeforeDelete = await evaluate('__gm.state.activeScene.objects.length'); - await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] })`); - await keyPress('Delete', 'Delete', { windowsVirtualKeyCode: 46 }); - await sleep(150); - check('Delete removes the whole multi-selection with its subtrees', (await evaluate('__gm.state.activeScene.objects.length')) === objectsBeforeDelete - 5); - await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); - await sleep(150); - check('undo restores all of them', (await evaluate('__gm.state.activeScene.objects.length')) === objectsBeforeDelete); - - // 16f. Placing the container asset again from the asset panel creates a second instance: - // the root plus 5 content objects with fresh ids, the crate copy at the same offset. - await clickText(containerAsset.name, 'right'); - await sleep(150); - await clickText('Place in scene'); - await sleep(250); - const afterPlace = JSON.parse(await evaluate('JSON.stringify(__gm.state.activeScene.objects)')); - const secondRoot = afterPlace.find((o) => o.assetId === containerAsset.id && o.id !== containerObj.id); - const secondCrate = secondRoot && afterPlace.find((o) => o.parentId === secondRoot.id && o.templateId === o1c.templateId); - check('placing the container asset instantiates its content below a new container object', afterPlace.length === objectsBeforeDelete + 6 && secondCrate && secondCrate.position.x === -135 && secondCrate.position.y === 40, JSON.stringify({ count: afterPlace.length, secondCrate })); - await shot('26-container-placed'); - - // 16g. Apply / revert on a container instance. The red crate (o1) is part of the first - // container; the second instance has an unmodified copy of it. Moving o1 on x shows - // ↑ and ↺ on the X row; ↑ writes x into the template and the copy follows; undo takes - // both back; ↺ puts o1 back to the template value. - const templateA = () => evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === '${containerAsset.id}').objects.find(t => t.id === '${o1c.templateId}'))`).then(JSON.parse); - await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'o1' })`); - await sleep(200); - check('inspector names the container the object is part of', Boolean(await evaluate(`__gm.locate(${JSON.stringify(containerAsset.name)})`))); - check('no apply/revert while the instance matches its template', !(await evaluate(`__gm.locate('↑')`)) && !(await evaluate(`__gm.locate('↺')`))); - await evaluate(`(() => { __gm.state.activeScene.objects.find(o => o.id === 'o1').position.x = -100; __gm.state.preview(); })()`); - await sleep(200); - const applyButton = await evaluate(`__gm.locate('↑')`); - check('a moved instance shows apply and revert on its X row', Boolean(applyButton) && Boolean(await evaluate(`__gm.locate('↺')`))); - await shot('27-apply-buttons'); - await click(applyButton.x, applyButton.y); - await sleep(200); - check('apply writes x into the template and the unmodified copy follows', (await templateA()).position.x === -100 && JSON.parse(await objectJson(secondCrate.id)).position.x === -100, JSON.stringify({ template: (await templateA()).position, copy: JSON.parse(await objectJson(secondCrate.id)).position })); - check('apply hides the buttons again', !(await evaluate(`__gm.locate('↑')`))); - await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); - await sleep(200); - check('undo restores template and copy', (await templateA()).position.x === -135 && JSON.parse(await objectJson(secondCrate.id)).position.x === -135); - const revertButton = await evaluate(`__gm.locate('↺')`); - await click(revertButton.x, revertButton.y); - await sleep(200); - check('revert puts the instance back to the template position', JSON.parse(await objectJson('o1')).position.x === -135); - - // 16h. Overrides on an instance offer a menu: "Apply to container" or "Apply to asset". - await evaluate(`(() => { __gm.state.activeScene.objects.find(o => o.id === 'o1').overrides.width = 77; __gm.state.preview(); })()`); - await sleep(200); - const applyMenu = await evaluate(`__gm.locate('↑ ▾')`); - check('an overridden property on an instance shows the apply menu button', Boolean(applyMenu)); - await click(applyMenu.x, applyMenu.y); - await sleep(150); - check('the menu offers container and asset', Boolean(await evaluate(`__gm.locate('Apply to container')`)) && Boolean(await evaluate(`__gm.locate('Apply to asset')`))); - await shot('28-apply-menu'); - await clickText('Apply to asset'); - await sleep(200); - const crateAfterApply = JSON.parse(await evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === 'crate').props)`)); - check('Apply to asset writes the value into the asset and drops the override', crateAfterApply.width === 77 && JSON.parse(await objectJson('o1')).overrides.width === undefined, JSON.stringify(crateAfterApply)); - await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); - await sleep(200); - check('undo restores asset and override', (await evaluate(`__gm.state.project.assets.find(a => a.id === 'crate').props.width`)) === 100 && JSON.parse(await objectJson('o1')).overrides.width === 77); - await click(applyMenu.x, applyMenu.y); - await sleep(150); - await clickText('Apply to container'); - await sleep(200); - check('Apply to container writes the override into the template and the copy', (await templateA()).overrides.width === 77 && JSON.parse(await objectJson(secondCrate.id)).overrides.width === 77); - - // 16i. o1 now matches its template (both 77) but still overrides the asset (100). Only bases - // the value really differs from are offered, so "Reset to container" is left out and the - // menu collapses to a direct ↺. Deviating from both brings the menu back. - await sleep(200); - check( - 'a reset that would change nothing is left out – a direct button instead of a menu', - Boolean(await evaluate(`__gm.locate('↺')`)) && !(await evaluate(`__gm.locate('↺ ▾')`)) && !(await evaluate(`__gm.locate('↑ ▾')`)), - ); - await evaluate(`(() => { __gm.state.activeScene.objects.find(o => o.id === 'o1').overrides.width = 55; __gm.state.preview(); })()`); - await sleep(200); - const resetMenu = await evaluate(`__gm.locate('↺ ▾')`); - check('deviating from template and asset brings the reset menu back', Boolean(resetMenu)); - await click(resetMenu.x, resetMenu.y); - await sleep(150); - check('the reset menu offers container and asset', Boolean(await evaluate(`__gm.locate('Reset to container')`)) && Boolean(await evaluate(`__gm.locate('Reset to asset')`))); - await shot('28b-reset-menu'); - await clickText('Reset to container'); - await sleep(200); - check('Reset to container puts the override back on the template value', JSON.parse(await objectJson('o1')).overrides.width === 77); - const singleReset = await evaluate(`__gm.locate('↺')`); - check('with only the asset left to differ from, the menu is a single button again', Boolean(singleReset) && !(await evaluate(`__gm.locate('↺ ▾')`))); - await click(singleReset.x, singleReset.y); - await sleep(200); - check('↺ drops the override so the object follows the asset again', JSON.parse(await objectJson('o1')).overrides.width === undefined && (await templateA()).overrides.width === 77); - // Dropping the override left the object on the asset value (100) while the template still - // says 77 – that is a real deviation, so exactly one entry remains: "Reset to container". - check('the remaining deviation from the template keeps a single button, not a menu', Boolean(await evaluate(`__gm.locate('↺')`)) && !(await evaluate(`__gm.locate('↺ ▾')`))); - await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); - await sleep(200); - check('undo restores the override', JSON.parse(await objectJson('o1')).overrides.width === 77); - - // 17. Text assets: created through the + menu, placed, and rendered as real text in the scene. - await clickText('+'); - await sleep(150); - await clickText('Text'); - await sleep(200); - const textAssetId = await evaluate(`(__gm.state.project.assets.find(a => a.type === 'text') || {}).id`); - check('+ menu creates a text asset', Boolean(textAssetId)); - await evaluate(`(() => { - const id = ${JSON.stringify(textAssetId)}; - __gm.state.project.assets.find(a => a.id === id).props.text = 'Hello world'; - // On the camera so the game view really renders it (outside the frame it would be culled). - const cam = __gm.state.activeScene.objects.find(o => o.id === 'o3').position; - __gm.state.activeScene.objects.push({ id: 'otext', assetId: id, name: '', position: { x: cam.x, y: cam.y, z: 5 }, overrides: {}, parentId: '', templateId: '' }); - __gm.state.select({ kind: 'object', sceneId: 'scene1', objectIds: ['otext'], objectId: 'otext' }); - __gm.state.preview(); - })()`); - await sleep(300); - check('a placed text object draws its content in the scene', Boolean(await evaluate(`Boolean(__gm.locate('Hello world'))`))); - await shot('29-text-object'); - - // 17a. The enabled switch: every object has it, switching it off is a normal override, and a - // switched-off object disappears from the scene view too (it stays in the hierarchy). - const enabledRow = await evaluate(`__gm.locate('Enabled')`); - check('the inspector shows the Enabled switch for a scene object', Boolean(enabledRow)); - const visibleBefore = (await evaluate('__gm.viewportInfo()')).visibleItems; - // The checkbox sits right of the 64 px label column; locate() gives the label's text centre. - await click(enabledRow.x + 55, enabledRow.y); - await sleep(250); - const textObject = JSON.parse(await objectJson('otext')); - check('clicking it writes an override instead of changing the asset', textObject.overrides.enabled === false, JSON.stringify(textObject.overrides)); - check('a switched-off object is not drawn in the scene view', (await evaluate(`JSON.stringify(__gm.drawOrder().includes('otext'))`)) === 'false'); - const visibleAfter = (await evaluate('__gm.viewportInfo()')).visibleItems; - check('the scene view renders one item less', visibleAfter === visibleBefore - 1, `${visibleBefore} → ${visibleAfter}`); - check('it stays selectable in the hierarchy, so its switch stays reachable', Boolean(await evaluate(`__gm.locate('Enabled')`))); - await shot('30-disabled'); - - // 17b. In the running game a switched-off object does not exist: it is not rendered, and - // switching it back on brings it back. - await clickText('▶ Play'); - await sleep(500); - const offItems = JSON.parse(await evaluate('JSON.stringify(__gm.gameInfo().visibleItems)')); - await evaluate(`(() => { __gm.state.activeScene.objects.find(o => o.id === 'otext').overrides.enabled = true; __gm.state.preview(); })()`); - await sleep(400); - const onItems = JSON.parse(await evaluate('JSON.stringify(__gm.gameInfo().visibleItems)')); - check('the game renders one item more once the object is switched on', onItems === offItems + 1, `${offItems} → ${onItems}`); - await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 }); - await sleep(300); - check('stop leaves the play-mode switch behind', JSON.parse(await objectJson('otext')).overrides.enabled === false); - + await t.navigate(); + await t.shot('01-loaded'); + for (const step of STEPS) await step(t); }); diff --git a/tools/e2e/01-hierarchy.mjs b/tools/e2e/01-hierarchy.mjs new file mode 100644 index 00000000..94b2225b --- /dev/null +++ b/tools/e2e/01-hierarchy.mjs @@ -0,0 +1,32 @@ +import { same } from '../lib/harness.mjs'; + +/** + * Hierarchy folding. A freshly opened scene is fully collapsed, so only its top-level objects + * are listed (o4/oc1 hang under o1, oc2 under o2). The chevron folds a row open and shut + * without touching the selection, and selecting a nested object unfolds the way down to it. + */ +export async function hierarchy(t) { + const { check, sleep, evaluate, json, click, select, state, shot } = t; + const rowIds = () => json('__gm.hierarchyOrder()'); + const rows = async () => JSON.stringify(await rowIds()); + const selectionBefore = (await state()).selection; + + check('hierarchy starts collapsed – only top-level objects are listed', same(await rowIds(), ['o1', 'o2', 'o3', 'o5']), await rows()); + check('a leaf row offers no chevron', (await evaluate(`__gm.hierarchyChevron('o3')`)) === null); + const chevron = await json(`__gm.hierarchyChevron('o2')`); + await click(chevron.x, chevron.y); + check( + 'clicking the chevron unfolds the row, leaving the selection alone', + same(await rowIds(), ['o1', 'o2', 'oc2', 'o3', 'o5']) && same((await state()).selection, selectionBefore), + await rows(), + ); + await shot('01a-hierarchy-unfolded'); + await click(chevron.x, chevron.y); + check('clicking it again folds the subtree away', same(await rowIds(), ['o1', 'o2', 'o3', 'o5']), await rows()); + await select({ kind: 'object', sceneId: 'scene1', objectId: 'oc1' }); + await sleep(150); + check('selecting a nested object unfolds its ancestors', same(await rowIds(), ['o1', 'o4', 'oc1', 'o2', 'o3', 'o5']), await rows()); + await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()'); + await sleep(150); + check('Collapse all folds everything back', same(await rowIds(), ['o1', 'o2', 'o3', 'o5']), await rows()); +} diff --git a/tools/e2e/02-inspector.mjs b/tools/e2e/02-inspector.mjs new file mode 100644 index 00000000..0102589a --- /dev/null +++ b/tools/e2e/02-inspector.mjs @@ -0,0 +1,66 @@ +/** + * Selecting, renaming and scrubbing in the inspector, dragging in the viewport, autosave to + * the server, and undo/redo of all of it. + */ +export async function inspector(t) { + const { check, sleep, json, locate, shot, click, clickText, drag, press, typeText, undo, redo, state, object, api, sceneOf, projectId } = t; + + // 1. Select the blue crate in the hierarchy. + await clickText('Blue crate'); + let s = await state(); + check('hierarchy click selects object', s.selection?.kind === 'object' && s.selection.objectId === 'o2', JSON.stringify(s.selection)); + await shot('02-object-selected'); + + // 2. Rename via inspector: the name field sits right of the "Name" label. + const nameLabel = await locate('Name'); + await click(nameLabel.x + 160, nameLabel.y); + await press('a', { ctrl: true }); + await typeText('Renamed crate'); + await shot('03-typing'); + await press('Enter'); + await sleep(200); + check('typing + Enter renamed the object', (await object('o2')).name === 'Renamed crate', JSON.stringify((await object('o2')).name)); + await shot('04-renamed'); + + // 3. Scrub the X field (right of the "X" label) by 100 px. + const xLabel = await locate('X'); + await drag(xLabel.x + 160, xLabel.y, xLabel.x + 260, xLabel.y); + const xNow = (await object('o2')).position.x; + check('scrubbing X moved the object', xNow > 120, `x=${xNow}`); + await shot('05-scrubbed'); + + // 3b. Type into X, then click straight into the empty viewport: the typed value must be + // committed (not reverted) even though the click deselects and rebuilds the inspector. + const center = await json('__gm.viewportCenter()'); + await click(xLabel.x + 160, xLabel.y); // plain click → text editing with everything selected + await typeText('300'); + await click(center.x, center.y + 250); + const xTyped = (await object('o2')).position.x; + s = await state(); + check('typed X survives clicking into the viewport', xTyped === 300 && s.selection === null, `x=${xTyped} selection=${JSON.stringify(s.selection)}`); + + // 4. Drag the red crate in the viewport. It is at world (-150,0); the view is centred + // on the origin, so compute its screen position from the viewport centre. + await drag(center.x - 150, center.y, center.x - 50, center.y - 70); + const o1Now = (await object('o1')).position; + 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'); + check('rename persisted', renamed.name === 'Renamed crate', renamed.name); + check('scrub persisted', renamed.position.x > 120, JSON.stringify(renamed.position)); + check('override kept', renamed.overrides.color === 4491519, JSON.stringify(renamed.overrides)); + check('save status is saved', (await state()).save === 'saved'); + + // 6. Undo four times (drag, typed X, scrub, rename) with Ctrl+Z, then redo once. + await click(center.x, center.y + 200); // click empty viewport → deselect, drop focus + for (let i = 0; i < 4; i++) await undo(); + const afterUndo = (await t.objects()).map((o) => ({ id: o.id, name: o.name, x: o.position.x })); + check('undo reverted drag, typed X, scrub and rename', afterUndo[0].x === -150 && afterUndo[1].x === 120 && afterUndo[1].name === 'Blue crate', JSON.stringify(afterUndo)); + await redo(); + check('redo restored the rename', (await object('o2')).name === 'Renamed crate'); + await shot('07-undo-redo'); +} diff --git a/tools/e2e/03-play.mjs b/tools/e2e/03-play.mjs new file mode 100644 index 00000000..8e784566 --- /dev/null +++ b/tools/e2e/03-play.mjs @@ -0,0 +1,104 @@ +const FIXTURE_IDS = ['o1', 'o2', 'o3', 'o4', 'o5', 'oc1', 'oc2']; + +/** + * Play mode: the camera frame is rendered, the Mover behavior moves its owner on the arrow + * keys, Esc stops without touching the model. Then editing while playing (moves, delete, undo + * and Spawn reach the world at once; Stop discards all of it), culling, and a scene without a + * camera. + */ +export async function play(t) { + const { check, sleep, evaluate, json, shot, clickText, press, holdKey, undo, state, object, objects, world, entity, select, mutate, stop } = t; + const gameInfo = () => json('__gm.gameInfo()'); + const play = t.play; + + // 7. Play: both crates lie inside the 640×360 frame at the origin; the behavior symbol is + // editor-only. The "Mover" behavior owns the red crate: holding ArrowRight moves it in the + // world (Axis input × speed × dt), Esc stops without touching the model. + await clickText('Renamed crate'); + const xBeforePlay = (await object('o2')).position.x; + await play(); + let s = await state(); + check('play mode entered', s.mode === 'play', JSON.stringify(s)); + let info = await gameInfo(); + check('play renders the camera frame with both crates', info.rendering && info.visibleItems === 2, JSON.stringify(info)); + await shot('08-playing'); + await holdKey('ArrowRight', 400); + const movedCrate = await entity('o1'); + check('behavior graph moved its owner while ArrowRight was held', movedCrate.x > -150 + 30, `world x=${movedCrate.x}`); + await holdKey('ArrowLeft', 250); + const afterLeft = await entity('o1'); + check('ArrowLeft (axis x = −1) moved the owner back left', afterLeft.x < movedCrate.x, `world x=${afterLeft.x}`); + await stop(); + s = await state(); + check('escape stopped play', s.mode === 'edit', JSON.stringify(s)); + await shot('09-stopped'); + const o2AfterPlay = (await object('o2')).position.x; + const o1AfterPlay = (await object('o1')).position.x; + check('play did not write back to the model', o2AfterPlay === xBeforePlay && o1AfterPlay === -150, `${o2AfterPlay} vs ${xBeforePlay}, o1=${o1AfterPlay}`); + + // 7a. Editing while playing: Play opens the Game tab, the panels stay open and edit a runtime + // copy. Moving the blue crate (model hook + preview, as a scrub does) and deleting it with + // the Delete key change the world at once, undo brings it back, Esc restores the saved model. + await play(); + check('play opens the Game tab', (await evaluate('__gm.tab()')) === 'game'); + check('panels stay visible while playing', Boolean(await evaluate(`__gm.locate('INSPECTOR')`)) && (await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 7); + await select({ kind: 'object', sceneId: 'scene1', objectId: 'o2' }); + await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 400`); + let liveO2 = await entity('o2'); + check('an edit while playing moves the entity in the world', liveO2 && liveO2.x === 400, JSON.stringify(liveO2)); + await shot('08b-editing-while-playing'); + await press('Delete'); + await sleep(150); + const liveIds = (await world()).entities.map((e) => e.objectId); + check('Delete while playing removes the entity and its collider child from the world', !liveIds.includes('o2') && !liveIds.includes('oc2') && liveIds.length === 5, JSON.stringify(liveIds)); + await undo(); + liveO2 = await entity('o2'); + check('undo while playing brings the entity back into the world', liveO2 && liveO2.x === 400, JSON.stringify(liveO2)); + + // 7a-ii. Spawning: the "Spawner" behavior places a Crate at (0, 150) on Space. The new object + // shows up in the runtime scene (hierarchy), in the world and in the Game view at once; + // Stop discards it with the rest of the runtime copy. + const visibleBeforeSpawn = (await gameInfo()).visibleItems; + await press('Space'); + await sleep(200); + const spawnedObjects = await objects(); + const spawned = spawnedObjects.find((o) => !FIXTURE_IDS.includes(o.id)); + check( + 'Spawn node adds a crate object to the runtime scene at (0, 150)', + spawned && spawned.assetId === 'crate' && spawned.parentId === '' && spawned.position.x === 0 && spawned.position.y === 150 && spawnedObjects.length === 8, + JSON.stringify(spawned), + ); + const spawnWorld = await world(); + check('the spawned entity lives in the world and its name reached the Log node', spawnWorld.entities.length === 8 && spawnWorld.log.includes('Crate'), JSON.stringify({ n: spawnWorld.entities.length, log: spawnWorld.log })); + check('hierarchy lists the spawned object', (await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 8); + info = await gameInfo(); + check('the Game view renders the spawned crate', info.visibleItems === visibleBeforeSpawn + 1, `${JSON.stringify(info)} before=${visibleBeforeSpawn}`); + await shot('08c-spawned'); + await stop(); + const o2AfterEdit = (await object('o2')).position.x; + check('stop restores the saved model untouched by play-mode edits (spawned object gone)', o2AfterEdit === xBeforePlay && (await objects()).length === 7, `x=${o2AfterEdit}`); + check('stop returns to the Scene tab', (await evaluate('__gm.tab()')) === 'scene'); + + // 7b. Culling: move the blue crate far outside the camera frame → only one item visible. + await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 5000`); + await play(); + info = await gameInfo(); + check('objects outside the camera frame are culled', info.visibleItems === 1, JSON.stringify(info)); + await stop(); + await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = ${xBeforePlay}`); + + // 7c. Without a camera nothing is rendered: delete the camera object, play, then undo. + await select({ kind: 'object', sceneId: 'scene1', objectId: 'o3' }); + await press('Delete'); + await sleep(200); + check('camera object deleted', (await objects()).length === 6); + await play(); + info = await gameInfo(); + s = await state(); + check('play without camera renders nothing', s.mode === 'play' && !info.rendering && info.visibleItems === 0, JSON.stringify(info)); + check('play without camera shows a notice', s.notice.includes('no camera'), s.notice); + await shot('10-play-no-camera'); + await stop(); + await undo(); + check('undo restored the camera object', (await objects()).length === 7); +} diff --git a/tools/e2e/04-assets.mjs b/tools/e2e/04-assets.mjs new file mode 100644 index 00000000..10db3062 --- /dev/null +++ b/tools/e2e/04-assets.mjs @@ -0,0 +1,37 @@ +/** The asset panel: placing from the context menu, creating through "+", and resetting an asset value. */ +export async function assets(t) { + const { check, sleep, evaluate, json, shot, click, clickText, undo, select, objects, asset } = t; + + // 8. Context menu on the Crate asset → menu shows "Place in scene". + await clickText('Crate', 'right'); + check('asset context menu opens', Boolean(await evaluate(`__gm.locate('Place in scene')`))); + await shot('11-context-menu'); + await clickText('Place in scene'); + await sleep(100); + const count = (await objects()).length; + check('placing from the menu adds an object', count === 8, `objects=${count}`); + + // 9. Create a new asset through the + menu. + await clickText('+'); + await clickText('Sprite'); + await sleep(100); + const names = await json('__gm.state.project.assets.map(a => a.name)'); + check('+ menu creates a sprite asset', names.includes('Sprite'), JSON.stringify(names)); + await shot('12-new-asset'); + + // 12. Asset defaults: the Crate asset has height 60 (schema default 100), width and color at + // their defaults → exactly one reset button is visible. Clicking it puts height back to + // 100 and hides the button; undo brings 60 back. + await select({ kind: 'asset', assetId: 'crate' }); + await sleep(200); + const resetButton = await evaluate(`__gm.locate('↺')`); + check('asset inspector shows a reset for the non-default height', Boolean(resetButton)); + await click(resetButton.x, resetButton.y); + await sleep(100); + check('reset puts the asset property back to its default', (await asset('crate')).props.height === 100, `height=${(await asset('crate')).props.height}`); + check('reset button hidden once the value is at default', !(await evaluate(`__gm.locate('↺')`))); + await shot('15-asset-reset'); + await undo(); + check('undo restores the asset value', (await asset('crate')).props.height === 60, `height=${(await asset('crate')).props.height}`); + check('reset button visible again after undo', Boolean(await evaluate(`__gm.locate('↺')`))); +} diff --git a/tools/e2e/05-drawing.mjs b/tools/e2e/05-drawing.mjs new file mode 100644 index 00000000..cdd1174a --- /dev/null +++ b/tools/e2e/05-drawing.mjs @@ -0,0 +1,35 @@ +/** Draw order: symbols always above sprites, `z` above hierarchy order. */ +export async function drawing(t) { + const { check, sleep, json, shot, click, locate, typeText, press, undo, mutate, select, object } = t; + const orderOfCrates = () => json(`__gm.drawOrder().filter(id => id === 'o1' || id === 'o2')`); + + // 10. Symbols stay on top: put the camera first in the hierarchy (drawn first) and a crate + // right on top of its icon → the icon must still be drawn above the sprite. + await mutate(` + const objs = __gm.state.activeScene.objects; + const cam = objs.splice(objs.findIndex(o => o.id === 'o3'), 1)[0]; + objs.unshift(cam); + const crate = objs.find(o => o.id === 'o1'); + crate.position.x = 0; crate.position.y = 0; + __gm.state.select(null)`); + const layers = await json(`[__gm.layerOf('o3'), __gm.layerOf('o1'), __gm.layerOf('o2')]`); + check('camera symbol is drawn in the overlay layer above sprites', JSON.stringify(layers) === '["overlay","objects","objects"]', JSON.stringify(layers)); + await shot('13-symbol-on-top'); + + // 11. Z-order: put both crates on the origin. With equal z the hierarchy order wins + // (o1 before o2 → o2 on top); raising o1's z draws it above o2; undo restores it. + await mutate(`const o2 = __gm.state.activeScene.objects.find(o => o.id === 'o2'); o2.position.x = 0; o2.position.y = 0`); + check('equal z keeps hierarchy order', JSON.stringify(await orderOfCrates()) === '["o1","o2"]', JSON.stringify(await orderOfCrates())); + await select({ kind: 'object', sceneId: 'scene1', objectId: 'o1' }); + await sleep(200); + const zLabel = await locate('Z'); + await click(zLabel.x + 160, zLabel.y); // plain click → text editing with everything selected + await typeText('5'); + await press('Enter'); + await sleep(200); + check('Z field sets position.z', (await object('o1')).position.z === 5, `z=${(await object('o1')).position.z}`); + check('higher z is drawn on top', JSON.stringify(await orderOfCrates()) === '["o2","o1"]', JSON.stringify(await orderOfCrates())); + await shot('14-z-order'); + await undo(); + check('undo restores z', (await object('o1')).position.z === 0, `z=${(await object('o1')).position.z}`); +} diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs new file mode 100644 index 00000000..b6e04990 --- /dev/null +++ b/tools/e2e/06-graph.mjs @@ -0,0 +1,134 @@ +/** + * The behavior graph editor: binding to the selection, dragging nodes, the palette (row click + * and Enter), connecting ports, deleting, the live highlight while playing, variables. + */ +export async function graph(t) { + const { check, sleep, evaluate, json, shot, click, clickText, drag, locate, typeText, press, holdKey, undo, state, select, stop, asset } = t; + const nodeOf = async (id) => (await asset('mover')).graph.nodes.find((n) => n.id === id); + const graphCounts = async () => { + const g = (await asset('mover')).graph; + return { nodes: g.nodes.length, edges: g.edges.length }; + }; + const variableKeys = async () => (await asset('mover')).variables.map((v) => v.key).join(','); + + // 13. Selecting the Mover asset binds the graph; the Graph tab shows it. + await clickText('Mover'); + await evaluate(`__gm.showTab('graph')`); + await sleep(250); + check('graph editor shows the selected behavior', (await evaluate(`__gm.graph.current()`)) === 'mover' && (await evaluate('__gm.tab()')) === 'graph'); + await shot('16-graph'); + + // 13a. Drag a node: clicking its title selects it, dragging moves it (undoable). + const axisBefore = await nodeOf('n-axis'); + const axisPos = await json(`__gm.graph.nodeAt('n-axis')`); + await drag(axisPos.x, axisPos.y, axisPos.x + 60, axisPos.y + 40); + let s = await state(); + const axisAfter = await nodeOf('n-axis'); + check( + 'dragging a node selects and moves it', + s.selection?.kind === 'node' && s.selection.nodeId === 'n-axis' && axisAfter.x === axisBefore.x + 60 && axisAfter.y === axisBefore.y + 40, + `${JSON.stringify(s.selection)} ${axisBefore.x},${axisBefore.y} → ${axisAfter.x},${axisAfter.y}`, + ); + check('node inspector shows the node', Boolean(await evaluate(`__gm.locate('Axis input')`)) && Boolean(await evaluate(`__gm.locate('DATA NODE')`))); + await undo(); + const axisUndone = await nodeOf('n-axis'); + check('undo moves the node back', axisUndone.x === axisBefore.x && axisUndone.y === axisBefore.y, `${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.) + const gcenter = await json('__gm.graph.center()'); + const beforeClickPick = await graphCounts(); + await click(gcenter.x + 250, gcenter.y + 250, 'right'); + await sleep(100); + check('node palette opens (row-click case)', Boolean(await evaluate(`__gm.locate('Search nodes…')`))); + await typeText('stop'); + await sleep(150); + const stopRow = await locate('Stop game'); // nothing else on screen shows this label + await click(stopRow.x, stopRow.y); + await sleep(100); + const afterClickPick = await graphCounts(); + check('clicking a palette row adds exactly one node', afterClickPick.nodes === beforeClickPick.nodes + 1, JSON.stringify({ beforeClickPick, afterClickPick })); + check('palette closed after row click', !(await evaluate(`__gm.locate('Search nodes…')`))); + await undo(); // so the next step finds the same empty spot and the expected graph + check('undo removes the clicked node again', (await graphCounts()).nodes === beforeClickPick.nodes); + + // 13c. Right-click the empty canvas → palette → search "log" → Enter adds a Log node. + const before13 = await graphCounts(); + await click(gcenter.x + 250, gcenter.y + 250, 'right'); + await sleep(100); + check('node palette opens', Boolean(await evaluate(`__gm.locate('Search nodes…')`))); + await typeText('log'); + await sleep(100); + await press('Enter'); + await sleep(250); + const after13 = await graphCounts(); + s = await state(); + const newNode = s.selection?.kind === 'node' ? await nodeOf(s.selection.nodeId) : null; + check('palette adds a Log node and selects it', after13.nodes === before13.nodes + 1 && newNode?.type === 'Log', JSON.stringify({ after13, newNode })); + await shot('17-node-added'); + + // 13d. Connect: drag from the Move node's exec output onto the Log node's exec input. An + // exec output must not connect to a data input: dragging onto "message" does nothing. + const fromPort = await json(`__gm.graph.portAt('n-move', 'exec', true)`); + const toPort = await json(`__gm.graph.portAt(${JSON.stringify(newNode.id)}, 'exec', false)`); + await drag(fromPort.x, fromPort.y, toPort.x, toPort.y, 16); + const connected = await graphCounts(); + check('dragging port to port connects the nodes', connected.edges === before13.edges + 1, JSON.stringify(connected)); + const dataPort = await json(`__gm.graph.portAt(${JSON.stringify(newNode.id)}, 'message', false)`); + await drag(fromPort.x, fromPort.y, dataPort.x, dataPort.y, 16); + check('incompatible ports are rejected', (await graphCounts()).edges === before13.edges + 1 && s.mode === 'edit'); + await shot('18-connected'); + + // 13e. Delete removes the selected node together with its edge; undo brings both back. + await select({ kind: 'node', assetId: 'mover', nodeId: newNode.id }); + await press('Delete'); + await sleep(150); + const deleted = await graphCounts(); + check('Delete removes the node and its edge', deleted.nodes === before13.nodes && deleted.edges === before13.edges, JSON.stringify(deleted)); + await undo(); + const restored = await graphCounts(); + check('undo restores node and edge', restored.nodes === before13.nodes + 1 && restored.edges === before13.edges + 1, JSON.stringify(restored)); + + // 13f. The fps readout runs in both modes. Play always starts on the Game tab; switching to + // Graph while playing shows the live highlight: hold ArrowRight → the exec edge is hot. + const fpsEdit = await evaluate('__gm.fps()'); + check('fps readout is measured in edit mode', typeof fpsEdit === 'number' && fpsEdit > 0, `fps=${fpsEdit}`); + // Single evaluate: the readout refreshes twice a second, so read value and label together. + check('fps label is visible in the toolbar', (await evaluate(`!!__gm.locate(__gm.fps() + ' fps')`)) === true); + await t.play(); + check('play switches to the game tab', (await evaluate('__gm.tab()')) === 'game'); + await sleep(600); + const fpsPlay = await evaluate('__gm.fps()'); + check('fps readout keeps running in play mode', typeof fpsPlay === 'number' && fpsPlay > 0, `fps=${fpsPlay}`); + await evaluate(`__gm.showTab('graph')`); + await sleep(100); + const holding = holdKey('ArrowRight', 300); + await sleep(250); + await shot('19-graph-live'); + await holding; + await stop(); + + // 13g. Variables: "+ Variable ▾" → Number adds "var1" with default 0; undo removes it. + await select({ kind: 'asset', assetId: 'mover' }); + await sleep(150); + await clickText('+ Variable ▾'); + await clickText('Number'); + await sleep(100); + check('adding a variable declares it on the behavior', (await variableKeys()) === 'speed,var1', await variableKeys()); + await shot('20-variable-added'); + await undo(); + check('undo removes the variable', (await variableKeys()) === 'speed', await variableKeys()); + + // 13h. The graph strictly mirrors the selection: nothing / a non-behavior → empty editor. + await select(null); + await sleep(100); + check('graph editor empties when nothing is selected', (await evaluate(`__gm.graph.current()`)) === null); + await select({ kind: 'asset', assetId: 'cam' }); + await sleep(100); + check('graph editor stays empty for a non-behavior asset', (await evaluate(`__gm.graph.current()`)) === null); + await select({ kind: 'asset', assetId: 'mover' }); + await sleep(100); + check('graph editor rebinds when a behavior is selected again', (await evaluate(`__gm.graph.current()`)) === 'mover'); + await evaluate(`__gm.showTab('scene')`); +} diff --git a/tools/e2e/07-parenting.mjs b/tools/e2e/07-parenting.mjs new file mode 100644 index 00000000..aa5cae87 --- /dev/null +++ b/tools/e2e/07-parenting.mjs @@ -0,0 +1,70 @@ +import { same } from '../lib/harness.mjs'; + +/** + * Hierarchy / parenting. Gives both crates unique names (later steps locate them by these), + * then parents by drag, by the inspector menu and unparents from the context menu – the world + * position is kept every time. + */ +export async function parenting(t) { + const { check, sleep, json, shot, click, clickText, drag, locate, typeText, press, undo, mutate, select, object, entity, stop } = t; + + // 14. Drag the blue crate's row onto the red crate's row: it becomes a child, keeps its world + // position (local becomes the difference) and is listed right below its parent. + await mutate(` + const objs = __gm.state.activeScene.objects; + const o1 = objs.find(o => o.id === 'o1'); const o2 = objs.find(o => o.id === 'o2'); + o1.name = 'Red crate'; o1.position = { x: -150, y: 0, z: 0 }; + o2.name = 'Blue crate'; o2.position = { x: 120, y: -80, z: 0 }; o2.parentId = ''; + __gm.state.select(null)`); + const redRow = await locate('Red crate'); + const blueRow = await locate('Blue crate'); + await drag(blueRow.x, blueRow.y, redRow.x, redRow.y); + await sleep(100); + let o2 = await object('o2'); + check('dropping a row onto another parents it, keeping the world position', o2.parentId === 'o1' && o2.position.x === 270 && o2.position.y === -80, JSON.stringify(o2)); + const order = await json('(__gm.hierarchyExpandAll(), __gm.hierarchyOrder())'); + // o1's subtree: o2 (list order before o4) with its collider child, then o4, then o1's collider. + const o1At = order.indexOf('o1'); + check('children are listed directly below their parent', same(order.slice(o1At + 1, o1At + 5), ['o2', 'oc2', 'o4', 'oc1']), JSON.stringify(order)); + await shot('20-parented'); + + // 14a. Moving the parent (X field of the red crate) drags the child along in world space. + await clickText('Red crate'); + const xField = await locate('X'); + await click(xField.x + 160, xField.y); + await press('a', { ctrl: true }); + await typeText('-50'); + await press('Enter'); + await sleep(200); + o2 = await object('o2'); + const parent = await object('o2').then((o) => object(o.parentId)); + const worldOfO2 = { x: o2.position.x + parent.position.x, y: o2.position.y + parent.position.y }; + check('moving the parent moves the child in world space (local unchanged)', o2.position.x === 270 && same(worldOfO2, { x: 220, y: -80 }), `${JSON.stringify(o2.position)} world=${JSON.stringify(worldOfO2)}`); + + // 14b. Play: entities mirror the hierarchy; undo restores parent and position. + await select(null); + await press('Enter', { ctrl: true }); + await sleep(300); + const e2 = await entity('o2'); + check('play instantiates the parent link and world positions', e2 && e2.parent === 'o1' && e2.worldX === 220 && e2.x === 270, JSON.stringify(e2)); + await stop(); + await undo(); // move + await undo(); // parent + o2 = await object('o2'); + check('undo restores top level and the original local position', o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80, JSON.stringify(o2)); + + // 14c. Parent via the inspector menu, then "Unparent" from the hierarchy context menu. + await clickText('Blue crate'); + await clickText('None ▾'); + const menuEntry = await locate('Red crate', true); // the popup is drawn last; the hierarchy row has the same text + await click(menuEntry.x, menuEntry.y); + await sleep(100); + o2 = await object('o2'); + check('inspector Parent menu parents the object', o2.parentId === 'o1' && o2.position.x === 270, JSON.stringify(o2)); + const blueRow2 = await locate('Blue crate'); + await click(blueRow2.x, blueRow2.y, 'right'); + await clickText('Unparent (move to top level)'); + await sleep(100); + o2 = await object('o2'); + check('Unparent moves the object to the top level, keeping its world position', o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80, JSON.stringify(o2)); +} diff --git a/tools/e2e/08-colliders.mjs b/tools/e2e/08-colliders.mjs new file mode 100644 index 00000000..21ea7ff6 --- /dev/null +++ b/tools/e2e/08-colliders.mjs @@ -0,0 +1,77 @@ +/** + * Colliders: "Add collider" from the context menu, collision events reaching the owner's + * behavior, and Move with "Collide with solids" – blocking, triggers, no tunnelling. + */ +export async function colliders(t) { + const { check, sleep, evaluate, json, shot, click, clickText, locate, press, holdKey, undo, mutate, select, state, object, objects, asset, world, entity, stop } = t; + + // 15. Right-click the red crate → "Add collider": a collider asset sized like the crate + // (100×60) is placed as its child at the centre, selected and drawn in the overlay. The + // seed already gives both crates a "Crate collider" child (oc1/oc2), so the added one is + // undone again to keep exactly one collider per body for the checks below. + const objectsBefore = (await objects()).length; + const redRow = await locate('Red crate'); + await click(redRow.x, redRow.y, 'right'); + await clickText('Add collider'); + await sleep(100); + const s = await state(); + const colObj = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null; + const colAsset = colObj ? await asset(colObj.assetId) : null; + check('Add collider creates a collider child sized like the sprite', colObj?.parentId === 'o1' && colAsset?.type === 'collider' && colAsset.props.width === 100 && colAsset.props.height === 60, JSON.stringify({ colObj, colAsset })); + check('collider is drawn in the overlay layer', colObj && (await evaluate(`__gm.layerOf('${colObj.id}')`)) === 'overlay'); + check('collider inspector shows the shape menu', Boolean(await evaluate(`__gm.locate('Box ▾')`))); + await shot('21-collider'); + await undo(); + const colliderAssetGone = colAsset ? !(await asset(colAsset.id)) : false; + check('undo removes the added collider object and its asset', (await objects()).length === objectsBefore && colliderAssetGone); + + // 15a. Wire "On collision enter → Log(other)" into the Mover (owner = red crate) and play with + // the crates overlapping: the log names the blue crate exactly once (enter, not stay), + // and colliders are not rendered in play mode. + await mutate(` + const mover = __gm.state.project.assets.find(a => a.id === 'mover'); + mover.graph.nodes.push({ id: 'n-hit', type: 'OnCollisionEnter', x: 0, y: 600, params: {} }, { id: 'n-hitlog', type: 'Log', x: 340, y: 600, params: {} }); + mover.graph.edges.push( + { id: 'e-hit1', from: { node: 'n-hit', port: 'exec' }, to: { node: 'n-hitlog', port: 'exec' } }, + { id: 'e-hit2', from: { node: 'n-hit', port: 'other' }, to: { node: 'n-hitlog', port: 'message' } }); + __gm.state.activeScene.objects.find(o => o.id === 'o2').position = { x: -100, y: 0, z: 0 }; // 50 from the red crate → 100-wide boxes overlap + __gm.state.select(null)`); + await press('Enter', { ctrl: true }); + await sleep(500); + const collisionWorld = await world(); + check('On collision enter fired once with the other body', collisionWorld.log.filter((l) => l === 'Blue crate').length === 1, JSON.stringify(collisionWorld.log)); + const spriteCount = await evaluate(`__gm.state.activeScene.objects.filter(o => __gm.state.project.assets.find(a => a.id === o.assetId)?.type === 'sprite').length`); + const info = await json('__gm.gameInfo()'); + check('colliders are not rendered in the game view', info.visibleItems === spriteCount, `${JSON.stringify(info)} sprites=${spriteCount}`); + await stop(); + + // 15b. Solid vs trigger. Put the blue crate 150 to the right of the red one (red 100 wide, the + // blue collider 140 wide → 30 of free travel) and hold ArrowRight: the Mover's Move + // (Collide with solids) parks the red crate flush against the blue one at world x = −120. + // Making the blue collider a trigger lets it pass through instead (drawn amber). A frame + // step far larger than the collider must not jump across it either (sub-steps). + const playAgainstBlue = async (trigger, speed = 0) => { + await mutate(` + const scene = __gm.state.activeScene; + scene.objects.find(o => o.id === 'o2').position = { x: 0, y: 0, z: 0 }; + scene.objects.find(o => o.id === 'oc2').overrides = ${trigger ? '{ width: 140, isTrigger: true }' : '{ width: 140 }'}; + ${speed ? `__gm.state.project.assets.find(a => a.id === 'mover').props.speed = ${speed};` : ''} + __gm.state.select(null)`); + await press('Enter', { ctrl: true }); + await sleep(300); + await holdKey('ArrowRight', 700); + const red = await entity('o1'); + await stop(); + return red; + }; + const blocked = await playAgainstBlue(false); + check("Move with \"Collide with solids\" stops the red crate flush against the blue crate's solid collider", blocked && Math.abs(blocked.x - -120) < 0.01, `world x=${blocked?.x}`); + const passed = await playAgainstBlue(true); + check('a trigger collider does not block (the crate passes through)', passed && passed.x > -120 + 20, `world x=${passed?.x}`); + const fast = await playAgainstBlue(false, 40000); + check('a very fast mover cannot tunnel through a solid collider', fast && Math.abs(fast.x - -120) < 0.5, `world x=${fast?.x}`); + await mutate(`__gm.state.project.assets.find(a => a.id === 'mover').props.speed = 200`); // later steps expect the seeded speed + await select({ kind: 'object', objectId: 'oc2' }); + await sleep(200); + await shot('22-trigger-collider'); +} diff --git a/tools/e2e/09-containers.mjs b/tools/e2e/09-containers.mjs new file mode 100644 index 00000000..e5bc6c16 --- /dev/null +++ b/tools/e2e/09-containers.mjs @@ -0,0 +1,205 @@ +import { same } from '../lib/harness.mjs'; + +/** + * Multi-selection and containers: Ctrl-click, Create container, dragging the group, rubber + * band, group move/delete, placing and spawning the container asset, and apply/revert of + * instance values against the template and the asset. + */ +export async function containers(t) { + const { check, sleep, evaluate, json, shot, click, clickText, drag, locate, press, undo, mutate, select, state, object, objects, asset, world, play, stop } = t; + const center = await json('__gm.viewportCenter()'); + const has = (text) => evaluate(`Boolean(__gm.locate(${JSON.stringify(text)}))`); + + // 16. Reset both crates to known spots, then Ctrl-click the second row in the hierarchy: + // both are selected, the inspector shows the multi view. + await mutate(` + const objs = __gm.state.activeScene.objects; + const o1 = objs.find(o => o.id === 'o1'); const o2 = objs.find(o => o.id === 'o2'); + o1.position = { x: -150, y: 0, z: 0 }; o1.parentId = ''; + o2.position = { x: 120, y: -80, z: 0 }; o2.parentId = ''; + objs.find(o => o.id === 'oc2').overrides = { width: 140 }; + __gm.state.select(null)`); + await clickText('Red crate'); + const blueRow = await locate('Blue crate'); + await click(blueRow.x, blueRow.y, 'left', { ctrl: true }); + let s = await state(); + check('Ctrl-click in the hierarchy adds to the selection', s.selection?.kind === 'object' && same(s.selection.objectIds, ['o1', 'o2']) && s.selection.objectId === 'o2', JSON.stringify(s.selection)); + check('inspector shows the multi-selection', await has('2 OBJECTS SELECTED')); + await shot('23-multi-selected'); + await click(blueRow.x, blueRow.y, 'left', { ctrl: true }); + s = await state(); + check('Ctrl-click again removes the object from the selection', s.selection?.kind === 'object' && same(s.selection.objectIds, ['o1']), JSON.stringify(s.selection)); + await click(blueRow.x, blueRow.y, 'left', { ctrl: true }); + + // 16a. Create container: both crates (with their colliders and the Mover) become the content + // of a new container asset; the scene gets one container object at the selection's + // centre (-15,-40), the crates hang below it with recomputed local positions, world + // positions are unchanged, and the container is selected. + const assetsBefore = (await json('__gm.state.project.assets')).length; + const objectsBefore = (await objects()).length; + await clickText('Create container'); + await sleep(100); + s = await state(); + const containerObj = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null; + const containerAsset = containerObj ? await asset(containerObj.assetId) : null; + check( + 'Create container adds one container asset and one placed container, selected', + containerAsset?.type === 'container' && s.selection.objectIds.length === 1 && (await json('__gm.state.project.assets')).length === assetsBefore + 1 && (await objects()).length === objectsBefore + 1, + JSON.stringify({ containerObj, type: containerAsset?.type }), + ); + check('the container sits at the centre of the selection', containerObj && containerObj.parentId === '' && same(containerObj.position, { x: -15, y: -40, z: 0 }), JSON.stringify(containerObj?.position)); + let o1c = await object('o1'); + const o2c = await object('o2'); + check( + 'the crates are re-parented below the container with local positions relative to its centre', + o1c.parentId === containerObj?.id && o1c.position.x === -135 && o1c.position.y === 40 && o2c.parentId === containerObj?.id && o2c.position.x === 135 && o2c.position.y === -40, + JSON.stringify({ o1: o1c.position, o2: o2c.position }), + ); + check( + 'the template holds the whole subtree (2 crates, 2 colliders, the Mover)', + containerAsset?.objects.length === 5 && containerAsset.objects.filter((o) => o.parentId === '').length === 2 && o1c.templateId && containerAsset.objects.some((o) => o.id === o1c.templateId), + JSON.stringify(containerAsset?.objects.map((o) => [o.assetId, o.parentId === '' ? 'root' : 'child'])), + ); + const orderC = await json('(__gm.hierarchyExpandAll(), __gm.hierarchyOrder())'); + const cAt = orderC.indexOf(containerObj?.id); + check('hierarchy lists the container with its content indented below', cAt >= 0 && same(orderC.slice(cAt), [containerObj.id, 'o1', 'o4', 'oc1', 'o2', 'oc2']), JSON.stringify(orderC)); + check('container symbol is drawn in the overlay layer', containerObj && (await evaluate(`__gm.layerOf('${containerObj.id}')`)) === 'overlay'); + await shot('24-container-created'); + + // 16b. Dragging the container symbol in the viewport moves the whole group; undo restores it. + const cScreen = await json(`__gm.objectScreen('${containerObj.id}')`); + await drag(cScreen.x, cScreen.y, cScreen.x + 100, cScreen.y); + const containerMoved = await object(containerObj.id); + o1c = await object('o1'); + check('dragging the container moves the group (children keep their local position)', containerMoved.position.x === 85 && o1c.position.x === -135, JSON.stringify({ container: containerMoved.position, o1: o1c.position })); + await undo(); + check('undo moves the container back', (await object(containerObj.id)).position.x === -15); + + // 16c. Rubber band on empty space around the red crate (world x −230…−90, y −50…50): picks + // the objects whose position lies inside – the crate and its collider, not the Mover + // (at y = 110), not the camera (at the origin). + await drag(center.x - 230, center.y - 50, center.x - 90, center.y + 50); + s = await state(); + check('rubber-band selection picks the objects inside it', s.selection?.kind === 'object' && same([...s.selection.objectIds].sort(), ['o1', 'oc1']), JSON.stringify(s.selection)); + await shot('25-marquee'); + + // 16d. Dragging one of several selected objects moves all of them as one undo step. + const redScreen = await json(`__gm.objectScreen('o1')`); + await select({ kind: 'object', sceneId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] }); + await drag(redScreen.x, redScreen.y + 20, redScreen.x + 50, redScreen.y + 20); + o1c = await object('o1'); + const o2d = await object('o2'); + check('dragging a multi-selection moves every selected object', o1c.position.x === -85 && o2d.position.x === 185, JSON.stringify({ o1: o1c.position, o2: o2d.position })); + await undo(); + o1c = await object('o1'); + check('one undo restores both', o1c.position.x === -135 && (await object('o2')).position.x === 135); + + // 16e. Delete with a multi-selection removes all of them (with subtrees) as one step. + const objectsBeforeDelete = (await objects()).length; + await select({ kind: 'object', sceneId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] }); + await press('Delete'); + await sleep(150); + check('Delete removes the whole multi-selection with its subtrees', (await objects()).length === objectsBeforeDelete - 5); + await undo(); + check('undo restores all of them', (await objects()).length === objectsBeforeDelete); + + // 16f. Placing the container asset again from the asset panel creates a second instance: + // the root plus 5 content objects with fresh ids, the crate copy at the same offset. + await clickText(containerAsset.name, 'right'); + await clickText('Place in scene'); + await sleep(100); + const afterPlace = await objects(); + const secondRoot = afterPlace.find((o) => o.assetId === containerAsset.id && o.id !== containerObj.id); + const secondCrate = secondRoot && afterPlace.find((o) => o.parentId === secondRoot.id && o.templateId === o1c.templateId); + check('placing the container asset instantiates its content below a new container object', afterPlace.length === objectsBeforeDelete + 6 && secondCrate && secondCrate.position.x === -135 && secondCrate.position.y === 40, JSON.stringify({ count: afterPlace.length, secondCrate })); + await shot('26-container-placed'); + + // 16f-ii. Spawning the container at runtime instantiates its content the same way: point the + // Spawner at the container asset, press Space while playing – a whole instance appears + // in the runtime copy and in the world, and Stop throws it away with the rest. + const spawnParam = (assetId) => mutate(`__gm.state.project.assets.find(a => a.id === 'spawner').graph.nodes.find(n => n.id === 's-spawn').params.asset = ${JSON.stringify(assetId)}`); + await spawnParam(containerAsset.id); + const beforeSpawn = await objects(); + await play(); + await press('Space'); + await sleep(250); + const fresh = (await objects()).filter((o) => !beforeSpawn.some((b) => b.id === o.id)); + const spawnedRoot = fresh.find((o) => o.parentId === ''); + check( + 'Spawn of a container asset creates the container object plus its content', + fresh.length === 6 && spawnedRoot?.assetId === containerAsset.id && fresh.filter((o) => o.templateId).length === 5, + JSON.stringify(fresh.map((o) => ({ id: o.id, assetId: o.assetId, templateId: o.templateId }))), + ); + const spawnWorld = await world(); + check('the spawned instance lives in the world and its name reached the Log node', spawnWorld.entities.length === beforeSpawn.length + 6 && spawnWorld.log.includes(containerAsset.name), JSON.stringify({ n: spawnWorld.entities.length, log: spawnWorld.log.slice(-1) })); + await stop(); + check('stop discards the spawned instance', (await objects()).length === beforeSpawn.length); + await spawnParam('crate'); + + // 16g. Apply / revert on a container instance. The red crate (o1) is part of the first + // container; the second instance has an unmodified copy of it. Moving o1 on x shows + // ↑ and ↺ on the X row; ↑ writes x into the template and the copy follows; undo takes + // both back; ↺ puts o1 back to the template value. + const templateA = async () => (await asset(containerAsset.id)).objects.find((o) => o.id === o1c.templateId); + await select({ kind: 'object', sceneId: 'scene1', objectId: 'o1' }); + await sleep(200); + check('inspector names the container the object is part of', await has(containerAsset.name)); + check('no apply/revert while the instance matches its template', !(await has('↑')) && !(await has('↺'))); + await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').position.x = -100`); + const applyButton = await evaluate(`__gm.locate('↑')`); + check('a moved instance shows apply and revert on its X row', Boolean(applyButton) && (await has('↺'))); + await shot('27-apply-buttons'); + await click(applyButton.x, applyButton.y); + await sleep(100); + check('apply writes x into the template and the unmodified copy follows', (await templateA()).position.x === -100 && (await object(secondCrate.id)).position.x === -100, JSON.stringify({ template: (await templateA()).position, copy: (await object(secondCrate.id)).position })); + check('apply hides the buttons again', !(await has('↑'))); + await undo(); + check('undo restores template and copy', (await templateA()).position.x === -135 && (await object(secondCrate.id)).position.x === -135); + const revertButton = await evaluate(`__gm.locate('↺')`); + await click(revertButton.x, revertButton.y); + await sleep(100); + check('revert puts the instance back to the template position', (await object('o1')).position.x === -135); + + // 16h. Overrides on an instance offer a menu: "Apply to container" or "Apply to asset". + await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').overrides.width = 77`); + const applyMenu = await evaluate(`__gm.locate('↑ ▾')`); + check('an overridden property on an instance shows the apply menu button', Boolean(applyMenu)); + await click(applyMenu.x, applyMenu.y); + check('the menu offers container and asset', (await has('Apply to container')) && (await has('Apply to asset'))); + await shot('28-apply-menu'); + await clickText('Apply to asset'); + await sleep(100); + const crateAfterApply = (await asset('crate')).props; + check('Apply to asset writes the value into the asset and drops the override', crateAfterApply.width === 77 && (await object('o1')).overrides.width === undefined, JSON.stringify(crateAfterApply)); + await undo(); + check('undo restores asset and override', (await asset('crate')).props.width === 100 && (await object('o1')).overrides.width === 77); + await click(applyMenu.x, applyMenu.y); + await clickText('Apply to container'); + await sleep(100); + check('Apply to container writes the override into the template and the copy', (await templateA()).overrides.width === 77 && (await object(secondCrate.id)).overrides.width === 77); + + // 16i. o1 now matches its template (both 77) but still overrides the asset (100). Only bases + // the value really differs from are offered, so "Reset to container" is left out and the + // menu collapses to a direct ↺. Deviating from both brings the menu back. + await sleep(200); + check('a reset that would change nothing is left out – a direct button instead of a menu', (await has('↺')) && !(await has('↺ ▾')) && !(await has('↑ ▾'))); + await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').overrides.width = 55`); + const resetMenu = await evaluate(`__gm.locate('↺ ▾')`); + check('deviating from template and asset brings the reset menu back', Boolean(resetMenu)); + await click(resetMenu.x, resetMenu.y); + check('the reset menu offers container and asset', (await has('Reset to container')) && (await has('Reset to asset'))); + await shot('28b-reset-menu'); + await clickText('Reset to container'); + await sleep(100); + check('Reset to container puts the override back on the template value', (await object('o1')).overrides.width === 77); + const singleReset = await evaluate(`__gm.locate('↺')`); + check('with only the asset left to differ from, the menu is a single button again', Boolean(singleReset) && !(await has('↺ ▾'))); + await click(singleReset.x, singleReset.y); + await sleep(100); + check('↺ drops the override so the object follows the asset again', (await object('o1')).overrides.width === undefined && (await templateA()).overrides.width === 77); + // Dropping the override left the object on the asset value (100) while the template still + // says 77 – that is a real deviation, so exactly one entry remains: "Reset to container". + check('the remaining deviation from the template keeps a single button, not a menu', (await has('↺')) && !(await has('↺ ▾'))); + await undo(); + check('undo restores the override', (await object('o1')).overrides.width === 77); +} diff --git a/tools/e2e/10-text-and-enabled.mjs b/tools/e2e/10-text-and-enabled.mjs new file mode 100644 index 00000000..c6a4de7f --- /dev/null +++ b/tools/e2e/10-text-and-enabled.mjs @@ -0,0 +1,50 @@ +/** Text assets rendered in the scene, and the enabled switch in the editor and in the running game. */ +export async function textAndEnabled(t) { + const { check, sleep, evaluate, json, shot, click, clickText, mutate, object, play, stop } = 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('+'); + await clickText('Text'); + await sleep(100); + const textAssetId = await evaluate(`(__gm.state.project.assets.find(a => a.type === 'text') || {}).id`); + check('+ menu creates a text asset', Boolean(textAssetId)); + await mutate(` + const id = ${JSON.stringify(textAssetId)}; + __gm.state.project.assets.find(a => a.id === id).props.text = 'Hello world'; + // On the camera so the game view really renders it (outside the frame it would be culled). + const cam = __gm.state.activeScene.objects.find(o => o.id === 'o3').position; + __gm.state.activeScene.objects.push({ id: 'otext', assetId: id, name: '', position: { x: cam.x, y: cam.y, z: 5 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: '', templateId: '' }); + __gm.state.select({ kind: 'object', sceneId: 'scene1', objectIds: ['otext'], objectId: 'otext' })`); + await sleep(100); + check('a placed text object draws its content in the scene', await has('Hello world')); + await shot('29-text-object'); + + // 17a. The enabled switch: every object has it, switching it off is a normal override, and a + // switched-off object disappears from the scene view too (it stays in the hierarchy). + const enabledRow = await evaluate(`__gm.locate('Enabled')`); + check('the inspector shows the Enabled switch for a scene object', Boolean(enabledRow)); + const visibleBefore = (await json('__gm.viewportInfo()')).visibleItems; + // The checkbox sits right of the 64 px label column; locate() gives the label's text centre. + await click(enabledRow.x + 55, enabledRow.y); + await sleep(100); + const textObject = await object('otext'); + check('clicking it writes an override instead of changing the asset', textObject.overrides.enabled === false, JSON.stringify(textObject.overrides)); + check('a switched-off object is not drawn in the scene view', !(await json('__gm.drawOrder()')).includes('otext')); + const visibleAfter = (await json('__gm.viewportInfo()')).visibleItems; + check('the scene view renders one item less', visibleAfter === visibleBefore - 1, `${visibleBefore} → ${visibleAfter}`); + check('it stays selectable in the hierarchy, so its switch stays reachable', await has('Enabled')); + await shot('30-disabled'); + + // 17b. In the running game a switched-off object does not exist: it is not rendered, and + // switching it back on brings it back. + await play(); + await sleep(200); + const offItems = (await json('__gm.gameInfo()')).visibleItems; + await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'otext').overrides.enabled = true`); + await sleep(200); + const onItems = (await json('__gm.gameInfo()')).visibleItems; + check('the game renders one item more once the object is switched on', onItems === offItems + 1, `${offItems} → ${onItems}`); + await stop(); + check('stop leaves the play-mode switch behind', (await object('otext')).overrides.enabled === false); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs new file mode 100644 index 00000000..25bf0683 --- /dev/null +++ b/tools/e2e/index.mjs @@ -0,0 +1,17 @@ +/** + * The e2e steps in the order they run. Each step is `async (t) => {}` over the harness + * toolbox; later steps rely on the scene state earlier ones leave behind (names given in + * the parenting step, the container created in the container step, …). + */ +import { hierarchy } from './01-hierarchy.mjs'; +import { inspector } from './02-inspector.mjs'; +import { play } from './03-play.mjs'; +import { assets } from './04-assets.mjs'; +import { drawing } from './05-drawing.mjs'; +import { graph } from './06-graph.mjs'; +import { parenting } from './07-parenting.mjs'; +import { colliders } from './08-colliders.mjs'; +import { containers } from './09-containers.mjs'; +import { textAndEnabled } from './10-text-and-enabled.mjs'; + +export const STEPS = [hierarchy, inspector, play, assets, drawing, graph, parenting, colliders, containers, textAndEnabled]; diff --git a/tools/harness.mjs b/tools/harness.mjs deleted file mode 100644 index 8ab0a7cd..00000000 --- a/tools/harness.mjs +++ /dev/null @@ -1,144 +0,0 @@ -#!/usr/bin/env node -/** - * Shared headless-Chromium harness for the check scripts (e2e.mjs, smoke-demo.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 SwiftShader GPU process kept - * rendering at full CPU once when only SIGTERM was sent) and exits with code 1 on failures. - */ -import { spawn } from 'node:child_process'; -import { writeFileSync } from 'node:fs'; - -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - -class CDP { - constructor(ws) { - this.ws = ws; - this.id = 0; - this.pending = new Map(); - this.listeners = []; - ws.addEventListener('message', (ev) => { - const msg = JSON.parse(ev.data); - if (msg.id && this.pending.has(msg.id)) { - const { resolve, reject } = this.pending.get(msg.id); - this.pending.delete(msg.id); - msg.error ? reject(new Error(msg.error.message)) : resolve(msg.result); - } else if (msg.method) { - for (const l of this.listeners) l(msg); - } - }); - } - send(method, params = {}) { - const id = ++this.id; - this.ws.send(JSON.stringify({ id, method, params })); - return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject })); - } - on(fn) { this.listeners.push(fn); } -} - -async function waitForChrome(port) { - for (let i = 0; i < 50; i++) { - try { - const res = await fetch(`http://127.0.0.1:${port}/json/version`); - if (res.ok) return res.json(); - } catch {} - await sleep(200); - } - throw new Error('chromium did not start'); -} - -export async function runChecks(options, body) { - const { projectId, baseUrl, port = 9333, shotPrefix = 'gm-e2e', width = 1280, height = 800 } = options; - const chrome = spawn('chromium', [ - '--headless', '--no-sandbox', '--disable-gpu', '--use-gl=swiftshader', '--enable-unsafe-swiftshader', - `--remote-debugging-port=${port}`, `--window-size=${width},${height}`, '--hide-scrollbars', 'about:blank', - ], { stdio: 'ignore' }); - - const consoleLog = []; - let failures = 0; - function check(name, ok, detail = '') { - console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' – ' + detail : ''}`); - if (!ok) failures++; - } - - try { - await waitForChrome(port); - const target = await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: 'PUT' })).json(); - const ws = new WebSocket(target.webSocketDebuggerUrl); - await new Promise((r) => ws.addEventListener('open', r)); - const cdp = new CDP(ws); - await cdp.send('Page.enable'); - await cdp.send('Runtime.enable'); - await cdp.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false }); - cdp.on((msg) => { - if (msg.method === 'Runtime.consoleAPICalled') consoleLog.push(msg.params.args.map((a) => a.value ?? a.description).join(' ')); - if (msg.method === 'Runtime.exceptionThrown') consoleLog.push('EXCEPTION ' + (msg.params.exceptionDetails.exception?.description ?? msg.params.exceptionDetails.text)); - }); - - const evaluate = async (expression) => (await cdp.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })).result.value; - const locate = async (text, last = false) => { - const p = await evaluate(`window.__gm && window.__gm.locate(${JSON.stringify(text)}, ${last})`); - if (!p) throw new Error(`widget with text "${text}" not found`); - return p; - }; - const shot = async (name) => { - const { data } = await cdp.send('Page.captureScreenshot', { format: 'png' }); - writeFileSync(`/tmp/${shotPrefix}-${name}.png`, Buffer.from(data, 'base64')); - }; - const mouse = (type, x, y, button = 'left', extra = {}) => - cdp.send('Input.dispatchMouseEvent', { type, x, y, button, clickCount: type === 'mousePressed' || type === 'mouseReleased' ? 1 : 0, ...extra }); - const click = async (x, y, button = 'left') => { - await mouse('mouseMoved', x, y); - await mouse('mousePressed', x, y, button); - await mouse('mouseReleased', x, y, button); - await sleep(150); - }; - const clickText = async (text, button = 'left') => { - const p = await locate(text); - await click(p.x, p.y, button); - }; - const drag = async (x1, y1, x2, y2, steps = 12) => { - await mouse('mouseMoved', x1, y1); - await mouse('mousePressed', x1, y1); - for (let i = 1; i <= steps; i++) { - await mouse('mouseMoved', x1 + ((x2 - x1) * i) / steps, y1 + ((y2 - y1) * i) / steps, 'left', { buttons: 1 }); - await sleep(16); - } - await mouse('mouseReleased', x2, y2); - await sleep(150); - }; - const keyPress = async (key, code, opts = {}) => { - await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key, code, ...opts }); - await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key, code, ...opts }); - }; - const typeText = async (text) => { - for (const ch of text) await cdp.send('Input.insertText', { text: ch }); - }; - const api = async (route) => (await fetch(`${baseUrl}api/index.php?r=${route}`)).json(); - const sceneOf = (doc) => doc.project.assets.find((a) => a.type === 'scene'); - const stateJson = () => evaluate('JSON.stringify({ selection: __gm.state.selection, mode: __gm.state.mode, save: __gm.state.saveStatus, notice: __gm.state.notice })'); - - await body({ - cdp, ws, projectId, baseUrl, consoleLog, check, sleep, - evaluate, locate, shot, mouse, click, clickText, drag, keyPress, typeText, api, sceneOf, stateJson, - }); - ws.close(); - } catch (error) { - console.error(error); - failures++; - } finally { - await new Promise((resolve) => { - if (chrome.exitCode !== null) return resolve(); - const timer = setTimeout(() => { chrome.kill('SIGKILL'); }, 5000); - chrome.once('exit', () => { clearTimeout(timer); resolve(); }); - chrome.kill('SIGTERM'); - }); - } - if (consoleLog.length) console.log('--- console ---\n' + consoleLog.slice(0, 20).join('\n')); - process.exit(failures ? 1 : 0); -} diff --git a/tools/lib/builders.mjs b/tools/lib/builders.mjs new file mode 100644 index 00000000..60903ac5 --- /dev/null +++ b/tools/lib/builders.mjs @@ -0,0 +1,50 @@ +/** + * Small factories for writing project documents by hand (the seed scripts). They produce the + * exact shape `src/core/store/serialize.ts` reads; optional fields get their defaults. + */ + +/** A scene object (also a container template entry). Position defaults to the origin. */ +export const sceneObject = (id, assetId, { name = '', x = 0, y = 0, z = 0, overrides = {}, parentId = '', templateId = '', editorOffset } = {}) => ({ + id, + assetId, + name, + position: { x, y, z }, + overrides, + parentId, + templateId, + ...(editorOffset ? { editorOffset } : {}), +}); + +/** A graph edge from `from.fromPort` to `to.toPort`. */ +export const edge = (id, from, fromPort, to, toPort) => ({ id, from: { node: from, port: fromPort }, to: { node: to, port: toPort } }); + +/** A graph node; `params` holds settings and inline input values alike. */ +export const node = (id, type, x, y, params = {}) => ({ id, type, x, y, params }); + +/** A behavior variable declaration. */ +export const variable = (key, kind, value) => ({ key, label: key, kind, default: value }); + +/** + * Instantiation of container templates the way the editor does it (`instantiateContainer`): + * `templates` maps container asset ids to their template objects. + * + * expand(assetId, hostId) copies of the template, parented below `hostId` + * instantiate(id, assetId, options) the container object plus its expanded content + * + * A template object that is itself a container is expanded in turn, unless the template already + * spells its content out. Copies remember their template id. + */ +export function containerFactory(templates) { + const expand = (assetId, hostId) => { + const template = templates[assetId]; + const objects = []; + for (const t of template) { + const id = `${hostId}-${t.id}`; + objects.push({ ...t, id, parentId: t.parentId ? `${hostId}-${t.parentId}` : hostId, templateId: t.id }); + if (templates[t.assetId] && !template.some((o) => o.parentId === t.id)) objects.push(...expand(t.assetId, id)); + } + return objects; + }; + const instantiate = (id, assetId, options = {}) => [sceneObject(id, assetId, options), ...expand(assetId, id)]; + return { expand, instantiate }; +} diff --git a/tools/lib/harness.mjs b/tools/lib/harness.mjs new file mode 100644 index 00000000..e8e18a9b --- /dev/null +++ b/tools/lib/harness.mjs @@ -0,0 +1,240 @@ +/** + * 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 SwiftShader GPU process kept + * rendering at full CPU once when only SIGTERM was sent) and exits with code 1 on failures. + */ +import { spawn } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** 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); + +/** 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 }, + Escape: { key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 }, + Space: { key: ' ', code: 'Space', windowsVirtualKeyCode: 32 }, + ArrowLeft: { key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 }, + ArrowRight: { key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 }, + Delete: { key: 'Delete', code: 'Delete', windowsVirtualKeyCode: 46 }, + a: { key: 'a', code: 'KeyA', windowsVirtualKeyCode: 65 }, + z: { key: 'z', code: 'KeyZ', windowsVirtualKeyCode: 90 }, +}; + +/** CDP modifier bitmask: 2 = Ctrl, 8 = Shift. */ +const modifierBits = ({ ctrl = false, shift = false } = {}) => (ctrl ? 2 : 0) | (shift ? 8 : 0); + +class CDP { + constructor(ws) { + this.ws = ws; + this.id = 0; + this.pending = new Map(); + this.listeners = []; + ws.addEventListener('message', (ev) => { + const msg = JSON.parse(ev.data); + if (msg.id && this.pending.has(msg.id)) { + const { resolve, reject } = this.pending.get(msg.id); + this.pending.delete(msg.id); + msg.error ? reject(new Error(msg.error.message)) : resolve(msg.result); + } else if (msg.method) { + for (const l of this.listeners) l(msg); + } + }); + } + + send(method, params = {}) { + const id = ++this.id; + this.ws.send(JSON.stringify({ id, method, params })); + return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject })); + } + + on(fn) { + this.listeners.push(fn); + } +} + +async function waitForChrome(port) { + for (let i = 0; i < 50; i++) { + try { + const res = await fetch(`http://127.0.0.1:${port}/json/version`); + if (res.ok) return res.json(); + } catch {} + await sleep(200); + } + throw new Error('chromium did not start'); +} + +/** The toolbox handed to a check body – everything a step needs to drive and inspect the editor. */ +function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { + const evaluate = async (expression) => (await cdp.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })).result.value; + /** Evaluates an expression in the page and returns it as plain JSON; `undefined` comes back as null. */ + const json = async (expression) => JSON.parse(await evaluate(`JSON.stringify((${expression}) ?? null)`)); + const locate = async (text, last = false) => { + const p = await evaluate(`window.__gm && window.__gm.locate(${JSON.stringify(text)}, ${last})`); + if (!p) throw new Error(`widget with text "${text}" not found`); + return p; + }; + const shot = async (name) => { + const { data } = await cdp.send('Page.captureScreenshot', { format: 'png' }); + writeFileSync(`/tmp/${shotPrefix}-${name}.png`, Buffer.from(data, 'base64')); + }; + + // --- mouse --------------------------------------------------------------------------------- + + const mouse = (type, x, y, button = 'left', extra = {}) => + cdp.send('Input.dispatchMouseEvent', { type, x, y, button, clickCount: type === 'mousePressed' || type === 'mouseReleased' ? 1 : 0, ...extra }); + const click = async (x, y, button = 'left', modifiers = {}) => { + const extra = { modifiers: modifierBits(modifiers) }; + await mouse('mouseMoved', x, y); + await mouse('mousePressed', x, y, button, extra); + await mouse('mouseReleased', x, y, button, extra); + await sleep(150); + }; + const clickText = async (text, button = 'left') => { + const p = await locate(text); + await click(p.x, p.y, button); + }; + const drag = async (x1, y1, x2, y2, steps = 12) => { + await mouse('mouseMoved', x1, y1); + await mouse('mousePressed', x1, y1); + for (let i = 1; i <= steps; i++) { + await mouse('mouseMoved', x1 + ((x2 - x1) * i) / steps, y1 + ((y2 - y1) * i) / steps, 'left', { buttons: 1 }); + await sleep(16); + } + await mouse('mouseReleased', x2, y2); + await sleep(150); + }; + + // --- keyboard ------------------------------------------------------------------------------ + + const keyEvent = (type, name, modifiers) => { + const key = KEYS[name]; + if (!key) throw new Error(`unknown key "${name}"`); + return cdp.send('Input.dispatchKeyEvent', { type, ...key, modifiers: modifierBits(modifiers) }); + }; + /** Press and release a key from `KEYS`, optionally with `{ ctrl, shift }`. */ + const press = async (name, modifiers = {}) => { + await keyEvent('keyDown', name, modifiers); + await keyEvent('keyUp', name, modifiers); + }; + /** Hold a key for `ms` milliseconds (arrow keys while playing). */ + const holdKey = async (name, ms) => { + await keyEvent('keyDown', name); + await sleep(ms); + await keyEvent('keyUp', name); + }; + const typeText = async (text) => { + for (const ch of text) await cdp.send('Input.insertText', { text: ch }); + }; + const undo = async () => { + await press('z', { ctrl: true }); + await sleep(200); + }; + const redo = async () => { + await press('z', { ctrl: true, shift: true }); + await sleep(200); + }; + + // --- editor state -------------------------------------------------------------------------- + + /** Selection, mode, save status and notice – the facts most checks look at. */ + const state = () => json('{ selection: __gm.state.selection, mode: __gm.state.mode, save: __gm.state.saveStatus, notice: __gm.state.notice }'); + const objects = () => json('__gm.state.activeScene.objects'); + const object = (id) => json(`__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(id)})`); + const asset = (id) => json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)})`); + const world = () => json('__gm.world()'); + const entity = async (objectId) => (await world()).entities.find((e) => e.objectId === objectId); + const select = (selection) => evaluate(`__gm.state.select(${JSON.stringify(selection)})`); + /** + * Runs `body` (JavaScript source) against the page's model and previews the result, the way a + * scrub or a command would – for putting the scene into a known state between checks. + */ + const mutate = async (body) => { + await evaluate(`(() => { ${body}; __gm.state.preview(); })()`); + await sleep(200); + }; + const play = async () => { + await clickText('▶ Play'); + await sleep(300); + }; + const stop = async () => { + await press('Escape'); + await sleep(300); + }; + + // --- server ---------------------------------------------------------------------------------- + + const api = async (route) => (await fetch(`${baseUrl}api/index.php?r=${route}`)).json(); + const sceneOf = (doc) => doc.project.assets.find((a) => a.type === 'scene'); + const exceptions = () => consoleLog.filter((l) => l.startsWith('EXCEPTION')); + /** Opens the project and asserts the page came up cleanly with the test hook installed. */ + const navigate = async () => { + await cdp.send('Page.navigate', { url: `${baseUrl}?project=${projectId}` }); + await sleep(4000); + check('page loaded without exceptions', exceptions().length === 0, exceptions().join(' | ')); + check('debug hook present', Boolean(await evaluate('Boolean(window.__gm)'))); + }; + + return { + cdp, projectId, baseUrl, consoleLog, exceptions, check, sleep, + evaluate, json, locate, shot, + mouse, click, clickText, drag, + press, holdKey, typeText, undo, redo, + state, objects, object, asset, world, entity, select, mutate, play, stop, + api, sceneOf, navigate, + }; +} + +export async function runChecks(options, body) { + const { projectId, baseUrl, port = 9333, shotPrefix = 'gm-e2e', width = 1280, height = 800 } = options; + const chrome = spawn('chromium', [ + '--headless', '--no-sandbox', '--disable-gpu', '--use-gl=swiftshader', '--enable-unsafe-swiftshader', + `--remote-debugging-port=${port}`, `--window-size=${width},${height}`, '--hide-scrollbars', 'about:blank', + ], { stdio: 'ignore' }); + + const consoleLog = []; + let failures = 0; + function check(name, ok, detail = '') { + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' – ' + detail : ''}`); + if (!ok) failures++; + } + + try { + await waitForChrome(port); + const target = await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: 'PUT' })).json(); + const ws = new WebSocket(target.webSocketDebuggerUrl); + await new Promise((r) => ws.addEventListener('open', r)); + const cdp = new CDP(ws); + await cdp.send('Page.enable'); + await cdp.send('Runtime.enable'); + await cdp.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false }); + cdp.on((msg) => { + if (msg.method === 'Runtime.consoleAPICalled') consoleLog.push(msg.params.args.map((a) => a.value ?? a.description).join(' ')); + if (msg.method === 'Runtime.exceptionThrown') consoleLog.push('EXCEPTION ' + (msg.params.exceptionDetails.exception?.description ?? msg.params.exceptionDetails.text)); + }); + await body(makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix })); + ws.close(); + } catch (error) { + console.error(error); + failures++; + } finally { + await new Promise((resolve) => { + if (chrome.exitCode !== null) return resolve(); + const timer = setTimeout(() => chrome.kill('SIGKILL'), 5000); + chrome.once('exit', () => { clearTimeout(timer); resolve(); }); + chrome.kill('SIGTERM'); + }); + } + if (consoleLog.length) console.log('--- console ---\n' + consoleLog.slice(0, 20).join('\n')); + console.log(failures ? `${failures} FAILED` : 'ALL PASSED'); + process.exit(failures ? 1 : 0); +} diff --git a/tools/projects.mjs b/tools/lib/projects.mjs similarity index 58% rename from tools/projects.mjs rename to tools/lib/projects.mjs index cb153911..a8ad69c8 100644 --- a/tools/projects.mjs +++ b/tools/lib/projects.mjs @@ -1,38 +1,30 @@ -#!/usr/bin/env node /** - * Which project each tool writes to, and the guard that keeps the checks away from the + * Which project each tool writes to, and the guard that keeps every tool away from the * playground. * - * demo the project served at ?project=… in the browser. It is the user's playground; - * only tools/seed-demo.mjs may overwrite it, and only when asked. - * fixture rewritten by every e2e run. tools/e2e.mjs mutates it heavily. - * demoCheck a throwaway copy of the demo scene for tools/smoke-demo.mjs. - * jumpRun the example game (tools/seed-jumprun.mjs, checked by tools/smoke-jumprun.mjs). + * playground the user's own project. No tool writes it – it is maintained in the editor. + * fixture rewritten by every e2e run (tools/seed-e2e.mjs + tools/e2e.mjs). + * jumpRun the example game (tools/seed-jumprun.mjs, played by tools/smoke-jumprun.mjs). * * All ids exist on the server (created via POST ?r=projects); PUT does not create projects. */ export const PROJECTS = { - demo: '649188c15414', + playground: '649188c15414', fixture: '3caa9b6a0c92', - demoCheck: 'aaf5e962ef9c', jumpRun: '3d271f3bb58b', }; export const DEFAULT_BASE_URL = 'https://nockal.com/game-maker/'; /** - * Reads `[projectId] [baseUrl]` from argv, falling back to `defaultId`. Unless `allowDemo` is - * set, targeting the demo project aborts: a destructive run must never hit the playground. + * Reads `[projectId] [baseUrl]` from argv, falling back to `defaultId`. Targeting the playground + * aborts: no tool may overwrite or mutate it. */ -export function cliArgs(script, defaultId, { allowDemo = false } = {}) { +export function cliArgs(script, defaultId) { const projectId = process.argv[2] ?? defaultId; const baseUrl = process.argv[3] ?? DEFAULT_BASE_URL; - if (!projectId) { - console.error(`usage: node tools/${script} [baseUrl]`); - process.exit(2); - } - if (!allowDemo && projectId === PROJECTS.demo) { - console.error(`refusing to run ${script} against the demo project (${PROJECTS.demo}) – it is the playground.`); + if (projectId === PROJECTS.playground) { + console.error(`refusing to run ${script} against the playground project (${PROJECTS.playground}).`); process.exit(2); } return { projectId, baseUrl }; diff --git a/tools/seed-demo.mjs b/tools/seed-demo.mjs deleted file mode 100644 index b334bae6..00000000 --- a/tools/seed-demo.mjs +++ /dev/null @@ -1,125 +0,0 @@ -#!/usr/bin/env node -/** - * Writes the demo project: a "Player" container (green character with the Mover behavior and a - * collider) facing two "Enemy" container instances, plus a Spawner that drops more enemies. - * Usage: PATH=/opt/node22/bin:$PATH node tools/seed-demo.mjs [projectId] [baseUrl] - * Defaults to the demo project – the only script allowed to overwrite the playground. Pass - * PROJECTS.demoCheck to seed the throwaway copy that tools/smoke-demo.mjs runs against. - */ -import { PROJECTS, cliArgs, writeProject } from './projects.mjs'; - -const { projectId, baseUrl } = cliArgs('seed-demo.mjs', PROJECTS.demo, { allowDemo: true }); - -/** - * "Mover" behavior in five nodes: every frame, move the owner along the normalized arrow-key - * axis, scaled by the `speed` variable per second (On update's dt feeds the dt input). - * Diagonals are not faster than straight moves because Axis input normalizes the vector, and - * Move's "Collide with solids" stops the owner's solid colliders at other solid colliders. - */ -const moverGraph = { - nodes: [ - { id: 'n-update', type: 'OnUpdate', x: 0, y: 0, params: {} }, - { id: 'n-owner', type: 'Owner', x: 0, y: 120, params: {} }, - { id: 'n-axis', type: 'AxisInput', x: 0, y: 200, params: {} }, - { id: 'n-speed', type: 'GetVariable', x: 0, y: 400, params: { variable: 'speed' } }, - { id: 'n-move', type: 'Move', x: 340, y: 0, params: { collide: true } }, - ], - edges: [ - { id: 'e1', from: { node: 'n-update', port: 'exec' }, to: { node: 'n-move', port: 'exec' } }, - { id: 'e6', from: { node: 'n-update', port: 'dt' }, to: { node: 'n-move', port: 'dt' } }, - { id: 'e2', from: { node: 'n-owner', port: 'object' }, to: { node: 'n-move', port: 'object' } }, - { id: 'e3', from: { node: 'n-axis', port: 'x' }, to: { node: 'n-move', port: 'dx' } }, - { id: 'e4', from: { node: 'n-axis', port: 'y' }, to: { node: 'n-move', port: 'dy' } }, - { id: 'e5', from: { node: 'n-speed', port: 'value' }, to: { node: 'n-move', port: 'scale' } }, - ], -}; - -/** "Spawner": pressing Space places another Enemy container at world (0, 150) and logs its name. */ -const spawnerGraph = { - nodes: [ - { id: 's-key', type: 'OnKeyDown', x: 0, y: 0, params: { key: ' ' } }, - { id: 's-spawn', type: 'Spawn', x: 260, y: 0, params: { asset: 'enemy', y: 150 } }, - { id: 's-log', type: 'Log', x: 520, y: 0, params: {} }, - ], - edges: [ - { id: 's-e1', from: { node: 's-key', port: 'exec' }, to: { node: 's-spawn', port: 'exec' } }, - { id: 's-e2', from: { node: 's-spawn', port: 'exec' }, to: { node: 's-log', port: 'exec' } }, - { id: 's-e3', from: { node: 's-spawn', port: 'object' }, to: { node: 's-log', port: 'message' } }, - ], -}; - -const GREEN = 0x45be2d; -const RED = 0xff4444; - -/** - * The "Player" template: the green character sprite, its collider and the Mover behavior that - * owns it. Positions are local to the container origin (the centre of the group). - */ -const playerTemplate = [ - { id: 't-player-body', assetId: 'crate', name: '', position: { x: 0, y: 0, z: 0 }, overrides: { color: GREEN }, parentId: '', templateId: '' }, - { id: 't-player-mover', assetId: 'mover', name: '', position: { x: 0, y: 100, z: 0 }, overrides: {}, parentId: 't-player-body', templateId: '' }, - { id: 't-player-collider', assetId: 'collider', name: '', position: { x: 0, y: 0, z: 0 }, overrides: {}, parentId: 't-player-body', templateId: '' }, -]; - -/** The "Enemy" template: a smaller red sprite with a matching 80×80 collider, no behavior. */ -const enemyTemplate = [ - { id: 't-enemy-body', assetId: 'crate', name: '', position: { x: 0, y: 0, z: 0 }, overrides: { width: 80, height: 80, color: RED }, parentId: '', templateId: '' }, - { id: 't-enemy-collider', assetId: 'collider', name: '', position: { x: 0, y: 0, z: 0 }, overrides: { width: 80, height: 80 }, parentId: 't-enemy-body', templateId: '' }, -]; - -/** One placed instance of a container: the container object plus a copy of every template object. */ -const instance = (containerId, assetId, position, template, ids) => [ - { id: containerId, assetId, name: '', position, overrides: {}, parentId: '', templateId: '' }, - ...template.map((t) => ({ - ...t, - id: ids[t.id], - parentId: t.parentId ? ids[t.parentId] : containerId, - templateId: t.id, - })), -]; - -const project = { - formatVersion: 1, - id: projectId, - name: 'Demo project', - activeSceneId: 'scene1', - assets: [ - { - id: 'scene1', - type: 'scene', - name: 'Level 1', - props: { backgroundColor: 0x22252c }, - objects: [ - // The player container on the left, the camera (zoomed out to 0.5) and the spawner. - ...instance('op', 'player', { x: -100, y: 0, z: 0 }, playerTemplate, { - 't-player-body': 'o1', 't-player-mover': 'o4', 't-player-collider': 'oc1', - }), - { id: 'o3', assetId: 'cam', name: '', position: { x: 0, y: 0, z: 0 }, overrides: { zoom: 0.5 }, parentId: '', templateId: '' }, - { id: 'o5', assetId: 'spawner', name: '', position: { x: -100, y: 200, z: 0 }, overrides: {}, parentId: '', templateId: '' }, - // Two enemies on the right, above and below the player's line. - ...instance('oe1', 'enemy', { x: 100, y: -100, z: 0 }, enemyTemplate, { - 't-enemy-body': 'o2', 't-enemy-collider': 'oc2', - }), - ...instance('oe2', 'enemy', { x: 100, y: 100, z: 0 }, enemyTemplate, { - 't-enemy-body': 'o6', 't-enemy-collider': 'oc3', - }), - ], - }, - { id: 'crate', type: 'sprite', name: 'Character Sprite', props: { width: 100, height: 100, color: 0xe0553f } }, - { id: 'cam', type: 'camera', name: 'Main Camera', props: { width: 640, height: 360, zoom: 1 } }, - { id: 'collider', type: 'collider', name: 'Character Collider', props: { shape: 'box', width: 100, height: 100, radius: 50, isTrigger: false } }, - { - id: 'mover', - type: 'behavior', - name: 'Mover', - props: { speed: 200 }, - variables: [{ key: 'speed', label: 'speed', kind: 'number', default: 0 }], - graph: moverGraph, - }, - { id: 'spawner', type: 'behavior', name: 'Spawner', props: {}, variables: [], graph: spawnerGraph }, - { id: 'player', type: 'container', name: 'Player', props: {}, objects: playerTemplate }, - { id: 'enemy', type: 'container', name: 'Enemy', props: {}, objects: enemyTemplate }, - ], -}; - -await writeProject(projectId, baseUrl, project); diff --git a/tools/seed-e2e.mjs b/tools/seed-e2e.mjs index 9a0af08c..55db6731 100644 --- a/tools/seed-e2e.mjs +++ b/tools/seed-e2e.mjs @@ -1,59 +1,57 @@ #!/usr/bin/env node /** * Writes the fixture scene tools/e2e.mjs asserts against: two top-level crates with a collider - * child each, a camera, the Mover behavior owning the red crate and a Spawner. Kept separate - * from tools/seed-demo.mjs so the shipped demo can change without rewriting 100+ checks; the - * fixture's objects are uniquely named and positioned exactly where the checks expect them. + * child each, a camera, the Mover behavior owning the red crate and a Spawner. The objects are + * uniquely named and positioned exactly where the checks expect them. * * Usage: PATH=/opt/node22/bin:$PATH node tools/seed-e2e.mjs [projectId] [baseUrl] - * Defaults to the dedicated fixture project; the demo project is refused. */ -import { PROJECTS, cliArgs, writeProject } from './projects.mjs'; +import { edge, node, sceneObject, variable } from './lib/builders.mjs'; +import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs'; const { projectId, baseUrl } = cliArgs('seed-e2e.mjs', PROJECTS.fixture); /** - * "Mover" behavior in five nodes: every frame, move the owner along the normalized arrow-key - * axis, scaled by the `speed` variable per second (On update's dt feeds the dt input). - * Diagonals are not faster than straight moves because Axis input normalizes the vector, and - * Move's "Collide with solids" stops the owner's solid colliders at other solid colliders. + * "Mover" in five nodes: every frame, move the owner along the normalized arrow-key axis, + * scaled by the `speed` variable per second (On update's dt feeds the dt input). "Collide with + * solids" stops the owner's solid colliders at other solid colliders. */ const moverGraph = { nodes: [ - { id: 'n-update', type: 'OnUpdate', x: 0, y: 0, params: {} }, - { id: 'n-owner', type: 'Owner', x: 0, y: 120, params: {} }, - { id: 'n-axis', type: 'AxisInput', x: 0, y: 200, params: {} }, - { id: 'n-speed', type: 'GetVariable', x: 0, y: 400, params: { variable: 'speed' } }, - { id: 'n-move', type: 'Move', x: 340, y: 0, params: { collide: true } }, + node('n-update', 'OnUpdate', 0, 0), + node('n-owner', 'Owner', 0, 120), + node('n-axis', 'AxisInput', 0, 200), + node('n-speed', 'GetVariable', 0, 400, { variable: 'speed' }), + node('n-move', 'Move', 340, 0, { collide: true }), ], edges: [ - { id: 'e1', from: { node: 'n-update', port: 'exec' }, to: { node: 'n-move', port: 'exec' } }, - { id: 'e6', from: { node: 'n-update', port: 'dt' }, to: { node: 'n-move', port: 'dt' } }, - { id: 'e2', from: { node: 'n-owner', port: 'object' }, to: { node: 'n-move', port: 'object' } }, - { id: 'e3', from: { node: 'n-axis', port: 'x' }, to: { node: 'n-move', port: 'dx' } }, - { id: 'e4', from: { node: 'n-axis', port: 'y' }, to: { node: 'n-move', port: 'dy' } }, - { id: 'e5', from: { node: 'n-speed', port: 'value' }, to: { node: 'n-move', port: 'scale' } }, + edge('e1', 'n-update', 'exec', 'n-move', 'exec'), + edge('e6', 'n-update', 'dt', 'n-move', 'dt'), + edge('e2', 'n-owner', 'object', 'n-move', 'object'), + edge('e3', 'n-axis', 'x', 'n-move', 'dx'), + edge('e4', 'n-axis', 'y', 'n-move', 'dy'), + edge('e5', 'n-speed', 'value', 'n-move', 'scale'), ], }; /** "Spawner": pressing Space places a new Crate at world (0, 150) and logs its name. */ const spawnerGraph = { nodes: [ - { id: 's-key', type: 'OnKeyDown', x: 0, y: 0, params: { key: ' ' } }, - { id: 's-spawn', type: 'Spawn', x: 260, y: 0, params: { asset: 'crate', y: 150 } }, - { id: 's-log', type: 'Log', x: 520, y: 0, params: {} }, + node('s-key', 'OnKeyDown', 0, 0, { key: ' ' }), + node('s-spawn', 'Spawn', 260, 0, { asset: 'crate', y: 150 }), + node('s-log', 'Log', 520, 0), ], edges: [ - { id: 's-e1', from: { node: 's-key', port: 'exec' }, to: { node: 's-spawn', port: 'exec' } }, - { id: 's-e2', from: { node: 's-spawn', port: 'exec' }, to: { node: 's-log', port: 'exec' } }, - { id: 's-e3', from: { node: 's-spawn', port: 'object' }, to: { node: 's-log', port: 'message' } }, + edge('s-e1', 's-key', 'exec', 's-spawn', 'exec'), + edge('s-e2', 's-spawn', 'exec', 's-log', 'exec'), + edge('s-e3', 's-spawn', 'object', 's-log', 'message'), ], }; const project = { formatVersion: 1, id: projectId, - name: 'Demo project', + name: 'E2E fixture', activeSceneId: 'scene1', assets: [ { @@ -62,30 +60,23 @@ const project = { name: 'Level 1', props: { backgroundColor: 0x22252c }, objects: [ - { id: 'o1', assetId: 'crate', name: '', position: { x: -150, y: 0, z: 0 }, overrides: {}, parentId: '' }, - { id: 'o2', assetId: 'crate', name: 'Blue crate', position: { x: 120, y: -80, z: 0 }, overrides: { width: 140, color: 0x4488ff }, parentId: '' }, - { id: 'o3', assetId: 'cam', name: '', position: { x: 0, y: 0, z: 0 }, overrides: {}, parentId: '' }, - // The mover is parented to the red crate (its owner); its position is local to it. - { id: 'o4', assetId: 'mover', name: '', position: { x: 0, y: 110, z: 0 }, overrides: {}, parentId: 'o1' }, - // Both crates carry the shared "Crate collider" as a centred child; the blue crate's - // instance is widened to match its 140-wide sprite override. - { id: 'oc1', assetId: 'collider', name: '', position: { x: 0, y: 0, z: 0 }, overrides: {}, parentId: 'o1' }, - { id: 'oc2', assetId: 'collider', name: '', position: { x: 0, y: 0, z: 0 }, overrides: { width: 140 }, parentId: 'o2' }, + sceneObject('o1', 'crate', { x: -150 }), + sceneObject('o2', 'crate', { name: 'Blue crate', x: 120, y: -80, overrides: { width: 140, color: 0x4488ff } }), + sceneObject('o3', 'cam'), + // The Mover is parented to the red crate (its owner); its position is local to it. + sceneObject('o4', 'mover', { y: 110, parentId: 'o1' }), + // Both crates carry the shared collider as a centred child; the blue one is widened to + // match its 140-wide sprite override. + sceneObject('oc1', 'collider', { parentId: 'o1' }), + sceneObject('oc2', 'collider', { parentId: 'o2', overrides: { width: 140 } }), // Top-level spawner (no owner) left of the red crate: Space spawns a crate. - { id: 'o5', assetId: 'spawner', name: '', position: { x: -300, y: 0, z: 0 }, overrides: {}, parentId: '' }, + sceneObject('o5', 'spawner', { x: -300 }), ], }, { id: 'crate', type: 'sprite', name: 'Crate', props: { width: 100, height: 60, color: 0xe0553f } }, { id: 'cam', type: 'camera', name: 'Main camera', props: { width: 640, height: 360, zoom: 1 } }, { id: 'collider', type: 'collider', name: 'Crate collider', props: { shape: 'box', width: 100, height: 60, radius: 50, isTrigger: false } }, - { - id: 'mover', - type: 'behavior', - name: 'Mover', - props: { speed: 200 }, - variables: [{ key: 'speed', label: 'speed', kind: 'number', default: 0 }], - graph: moverGraph, - }, + { id: 'mover', type: 'behavior', name: 'Mover', props: { speed: 200 }, variables: [variable('speed', 'number', 0)], graph: moverGraph }, { id: 'spawner', type: 'behavior', name: 'Spawner', props: {}, variables: [], graph: spawnerGraph }, ], }; diff --git a/tools/seed-jumprun.mjs b/tools/seed-jumprun.mjs index d1e00a38..c6517740 100644 --- a/tools/seed-jumprun.mjs +++ b/tools/seed-jumprun.mjs @@ -6,7 +6,7 @@ * Title screen (a container with a panel, texts and a clickable Start button) * Level (ground, platforms, walls, ten coins, the player and the HUD labels) * Coin (sprite plus trigger collider – placed ten times inside the level container) - * Win / Lose (containers shown when the coins are collected or the 30 seconds are up) + * Win / Lose (containers shown when the coins are collected or the time is up) * * Screens are containers that are switched on and off with the `enabled` property – the same * switch the inspector shows for every object. Everything else is nodes: gravity and jumping @@ -14,7 +14,8 @@ * * Usage: PATH=/opt/node22/bin:$PATH node tools/seed-jumprun.mjs [projectId] [baseUrl] */ -import { PROJECTS, cliArgs, writeProject } from './projects.mjs'; +import { containerFactory, edge, node, sceneObject, variable } from './lib/builders.mjs'; +import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs'; const { projectId, baseUrl } = cliArgs('seed-jumprun.mjs', PROJECTS.jumpRun); @@ -28,38 +29,29 @@ const COIN = 0xf5c542; const PANEL = 0x11141c; const BUTTON = 0x63c74d; const WHITE = 0xf2f5f7; +const HINT = 0x9aa3b2; +const BUTTON_TEXT = 0x10240a; // --- level geometry (the camera shows x −320…320, y −180…180; +y is down) --------------------- /** A solid box: the sprite plus a collider child of the same size. */ -const solid = (id, x, y, w, h, color, name = '') => [ - { id, assetId: 'box', name, position: { x, y, z: 0 }, overrides: { width: w, height: h, color }, parentId: '', templateId: '' }, - { id: `${id}-c`, assetId: 'solid', name: '', position: { x: 0, y: 0, z: 0 }, overrides: { width: w, height: h }, parentId: id, templateId: '' }, +const solid = (id, x, y, w, h, color) => [ + sceneObject(id, 'box', { x, y, overrides: { width: w, height: h, color } }), + sceneObject(`${id}-c`, 'solid', { parentId: id, overrides: { width: w, height: h } }), ]; -/** - * The coin is a container asset of its own: a sprite with a trigger collider child, so it reports - * the touch without blocking. The ten coins in the level are instances of it – changing the - * template changes all of them. - */ +const text = (id, x, y, content, { size = 28, color = WHITE, align = 'center', name = '', parentId = '', z = 1 } = {}) => + sceneObject(id, 'label', { name, x, y, z, parentId, overrides: { text: content, fontSize: size, color, align } }); + +/** One placed coin: the container object; its content comes from the coin template. */ +const coin = (id, x, y) => sceneObject(id, 'coin', { name: 'Coin', x, y }); + +/** The coin: a sprite with a trigger collider child, so it reports the touch without blocking. */ const coinTemplate = [ - { id: 'coin-body', assetId: 'coin-sprite', name: '', position: { x: 0, y: 0, z: 1 }, overrides: {}, parentId: '', templateId: '' }, - { id: 'coin-body-c', assetId: 'trigger', name: '', position: { x: 0, y: 0, z: 0 }, overrides: {}, parentId: 'coin-body', templateId: '' }, + sceneObject('coin-body', 'coin-sprite', { z: 1 }), + sceneObject('coin-body-c', 'trigger', { parentId: 'coin-body' }), ]; -/** One placed coin: the container object, its content comes from the template above. */ -const coin = (id, x, y) => ({ id, assetId: 'coin', name: 'Coin', position: { x, y, z: 0 }, overrides: {}, parentId: '', templateId: '' }); - -const text = (id, x, y, content, { size = 28, color = WHITE, align = 'center', name = '', parentId = '', z = 1 } = {}) => ({ - id, - assetId: 'label', - name, - position: { x, y, z }, - overrides: { text: content, fontSize: size, color, align }, - parentId, - templateId: '', -}); - const levelTemplate = [ // Ground, side walls and six platforms. ...solid('ground', 0, 165, 660, 30, GRASS), @@ -83,65 +75,37 @@ const levelTemplate = [ coin('c9', 255, -90), coin('c10', 290, 128), // The player: sprite, solid collider and the behavior that owns it. - { id: 'player', assetId: 'player-sprite', name: 'Player', position: { x: -270, y: 125, z: 2 }, overrides: {}, parentId: '', templateId: '' }, - { id: 'player-c', assetId: 'solid', name: '', position: { x: 0, y: 0, z: 0 }, overrides: { width: 30, height: 40 }, parentId: 'player', templateId: '' }, - { id: 'player-logic', assetId: 'player', name: '', position: { x: 0, y: -60, z: 0 }, overrides: {}, parentId: 'player', templateId: '' }, + sceneObject('player', 'player-sprite', { name: 'Player', x: -270, y: 125, z: 2 }), + sceneObject('player-c', 'solid', { parentId: 'player', overrides: { width: 30, height: 40 } }), + sceneObject('player-logic', 'player', { parentId: 'player', y: -60 }), // HUD, top left and top right of the camera frame. text('time-label', -300, -160, 'Time 30', { size: 22, align: 'left', name: 'TimeLabel', z: 3 }), text('coin-label', 300, -160, 'Coins 0/10', { size: 22, align: 'right', name: 'CoinLabel', z: 3 }), ]; /** A full-frame panel with a headline, a line of help text and a button carrying a behavior. */ -const screenTemplate = (prefix, headline, hint, buttonLabel, behaviorId, headlineSize = 52) => [ - { id: `${prefix}-panel`, assetId: 'box', name: '', position: { x: 0, y: 0, z: 0 }, overrides: { width: 640, height: 360, color: PANEL }, parentId: '', templateId: '' }, - text(`${prefix}-title`, 0, -70, headline, { size: headlineSize }), - text(`${prefix}-hint`, 0, -10, hint, { size: 20, color: 0x9aa3b2 }), - { id: `${prefix}-button`, assetId: 'box', name: '', position: { x: 0, y: 80, z: 1 }, overrides: { width: 240, height: 60, color: BUTTON }, parentId: '', templateId: '' }, - text(`${prefix}-button-label`, 0, 0, buttonLabel, { size: 26, color: 0x10240a, parentId: `${prefix}-button`, z: 1 }), - { id: `${prefix}-button-logic`, assetId: behaviorId, name: '', position: { x: 0, y: -60, z: 0 }, overrides: {}, parentId: `${prefix}-button`, templateId: '' }, +const screenTemplate = (prefix, headline, hint, buttonLabel, behaviorId) => [ + sceneObject(`${prefix}-panel`, 'box', { overrides: { width: 640, height: 360, color: PANEL } }), + text(`${prefix}-title`, 0, -70, headline, { size: 52 }), + text(`${prefix}-hint`, 0, -10, hint, { size: 20, color: HINT }), + sceneObject(`${prefix}-button`, 'box', { y: 80, z: 1, overrides: { width: 240, height: 60, color: BUTTON } }), + text(`${prefix}-button-label`, 0, 0, buttonLabel, { size: 26, color: BUTTON_TEXT, parentId: `${prefix}-button` }), + sceneObject(`${prefix}-button-logic`, behaviorId, { parentId: `${prefix}-button`, y: -60 }), ]; -const titleTemplate = screenTemplate('title', 'JUMP & RUN', 'Arrow keys to move, Space to jump.', 'START', 'start-button'); -const winTemplate = screenTemplate('win', 'YOU WIN!', 'All ten coins collected in time.', 'PLAY AGAIN', 'restart-button'); -const loseTemplate = screenTemplate('lose', 'TIME UP', 'The time is over - try again.', 'PLAY AGAIN', 'restart-button'); - -/** Which template belongs to which container asset – the lookup `expand` needs for nesting. */ -const containerTemplates = { +const templates = { level: levelTemplate, coin: coinTemplate, - title: titleTemplate, - win: winTemplate, - lose: loseTemplate, + title: screenTemplate('title', 'JUMP & RUN', 'Arrow keys to move, Space to jump.', 'START', 'start-button'), + win: screenTemplate('win', 'YOU WIN!', 'All ten coins collected in time.', 'PLAY AGAIN', 'restart-button'), + lose: screenTemplate('lose', 'TIME UP', 'The time is over - try again.', 'PLAY AGAIN', 'restart-button'), }; - -/** - * A copy of every object of `template`, parented below `hostId` and remembering its template id. - * A template object that is itself a container is expanded in turn (unless the template already - * spells its content out) – exactly what `instantiateContainer` does in the editor. - */ -const expand = (template, hostId) => { - const objects = []; - for (const t of template) { - const id = `${hostId}-${t.id}`; - objects.push({ ...t, id, parentId: t.parentId ? `${hostId}-${t.parentId}` : hostId, templateId: t.id }); - const nested = containerTemplates[t.assetId]; - if (nested && !template.some((o) => o.parentId === t.id)) objects.push(...expand(nested, id)); - } - return objects; -}; - -/** One placed container: the container object plus a copy of every template object. */ -const instantiate = (containerId, assetId, name, position, overrides = {}, extra = {}) => [ - { id: containerId, assetId, name, position, overrides, parentId: '', templateId: '', ...extra }, - ...expand(containerTemplates[assetId], containerId), -]; +const { instantiate } = containerFactory(templates); // --- graphs ---------------------------------------------------------------------------------- -const edge = (id, from, fromPort, to, toPort) => ({ id, from: { node: from, port: fromPort }, to: { node: to, port: toPort } }); - /** - * "Player": gravity, walking, jumping and picking coins up – 35 nodes in three strands. + * "Player": gravity, walking, jumping and picking coins up – three strands. * * On update vy += gravity·dt, then Move(owner) by (axis·speed, vy)·dt with collisions on. * Blocked vertically while falling means standing: vy back to 0, onGround true. @@ -151,45 +115,43 @@ const edge = (id, from, fromPort, to, toPort) => ({ id, from: { node: from, port */ const playerGraph = { nodes: [ - { id: 'p-upd', type: 'OnUpdate', x: 0, y: 0, params: {} }, - { id: 'p-owner', type: 'Owner', x: 0, y: 140, params: {} }, - { id: 'p-axis', type: 'AxisInput', x: 0, y: 220, params: { left: 'ArrowLeft', right: 'ArrowRight', up: '', down: '' } }, - { id: 'p-speed', type: 'GetVariable', x: 0, y: 360, params: { variable: 'speed' } }, - { id: 'p-mulx', type: 'Multiply', x: 220, y: 300, params: {} }, - { id: 'p-vy', type: 'GetVariable', x: 0, y: 440, params: { variable: 'vy' } }, - { id: 'p-grav', type: 'GetVariable', x: 0, y: 520, params: { variable: 'gravity' } }, - { id: 'p-mulg', type: 'Multiply', x: 220, y: 500, params: {} }, - { id: 'p-addvy', type: 'Add', x: 420, y: 460, params: {} }, - { id: 'p-setvy', type: 'SetVariable', x: 620, y: 0, params: { variable: 'vy' } }, - { id: 'p-vy2', type: 'GetVariable', x: 620, y: 340, params: { variable: 'vy' } }, - { id: 'p-move', type: 'Move', x: 840, y: 0, params: { collide: true } }, - { id: 'p-br', type: 'Branch', x: 1080, y: 0, params: {} }, - { id: 'p-seq', type: 'Sequence', x: 1260, y: 0, params: {} }, - { id: 'p-vy3', type: 'GetVariable', x: 1080, y: 260, params: { variable: 'vy' } }, - { id: 'p-cmp', type: 'Compare', x: 1260, y: 260, params: { op: '>=', b: 0 } }, - { id: 'p-ground', type: 'SetVariable', x: 1460, y: 0, params: { variable: 'onGround' } }, - { id: 'p-zero', type: 'SetVariable', x: 1460, y: 140, params: { variable: 'vy', value: 0 } }, - { id: 'p-air', type: 'SetVariable', x: 1260, y: 400, params: { variable: 'onGround', value: false } }, + node('p-upd', 'OnUpdate', 0, 0), + node('p-owner', 'Owner', 0, 140), + node('p-axis', 'AxisInput', 0, 220, { left: 'ArrowLeft', right: 'ArrowRight', up: '', down: '' }), + node('p-speed', 'GetVariable', 0, 360, { variable: 'speed' }), + node('p-mulx', 'Multiply', 220, 300), + node('p-vy', 'GetVariable', 0, 440, { variable: 'vy' }), + node('p-grav', 'GetVariable', 0, 520, { variable: 'gravity' }), + node('p-mulg', 'Multiply', 220, 500), + node('p-addvy', 'Add', 420, 460), + node('p-setvy', 'SetVariable', 620, 0, { variable: 'vy' }), + node('p-vy2', 'GetVariable', 620, 340, { variable: 'vy' }), + node('p-move', 'Move', 840, 0, { collide: true }), + node('p-br', 'Branch', 1080, 0), + node('p-seq', 'Sequence', 1260, 0), + node('p-vy3', 'GetVariable', 1080, 260, { variable: 'vy' }), + node('p-cmp', 'Compare', 1260, 260, { op: '>=', b: 0 }), + node('p-ground', 'SetVariable', 1460, 0, { variable: 'onGround' }), + node('p-zero', 'SetVariable', 1460, 140, { variable: 'vy', value: 0 }), + node('p-air', 'SetVariable', 1260, 400, { variable: 'onGround', value: false }), - { id: 'p-key', type: 'OnKeyDown', x: 0, y: 700, params: { key: ' ' } }, - { id: 'p-og', type: 'GetVariable', x: 0, y: 820, params: { variable: 'onGround' } }, - { id: 'p-brj', type: 'Branch', x: 240, y: 700, params: {} }, - { id: 'p-jump', type: 'GetVariable', x: 240, y: 880, params: { variable: 'jumpSpeed' } }, - { id: 'p-neg', type: 'Multiply', x: 440, y: 880, params: { b: -1 } }, - { id: 'p-setjump', type: 'SetVariable', x: 660, y: 700, params: { variable: 'vy' } }, + node('p-key', 'OnKeyDown', 0, 700, { key: ' ' }), + node('p-og', 'GetVariable', 0, 820, { variable: 'onGround' }), + node('p-brj', 'Branch', 240, 700), + node('p-jump', 'GetVariable', 240, 880, { variable: 'jumpSpeed' }), + node('p-neg', 'Multiply', 440, 880, { b: -1 }), + node('p-setjump', 'SetVariable', 660, 700, { variable: 'vy' }), - { id: 'p-col', type: 'OnCollisionEnter', x: 0, y: 1060, params: {} }, - { id: 'p-iscoin', type: 'IsAsset', x: 260, y: 1180, params: { asset: 'coin-sprite' } }, - { id: 'p-brc', type: 'Branch', x: 480, y: 1060, params: {} }, - { id: 'p-seqc', type: 'Sequence', x: 680, y: 1060, params: {} }, - { id: 'p-game', type: 'ObjectByName', x: 680, y: 1280, params: { name: 'Game' } }, - { id: 'p-coins', type: 'GetProperty', x: 880, y: 1280, params: { property: 'coins' } }, - { id: 'p-add1', type: 'Add', x: 1080, y: 1280, params: { b: 1 } }, - { id: 'p-setcoins', type: 'SetProperty', x: 1280, y: 1060, params: { property: 'coins' } }, - // The collider reports its body, which is the coin sprite; the whole coin is the container - // above it, so that is what gets destroyed. - { id: 'p-coinroot', type: 'Parent', x: 1080, y: 1420, params: {} }, - { id: 'p-destroy', type: 'Destroy', x: 1280, y: 1200, params: {} }, + node('p-col', 'OnCollisionEnter', 0, 1060), + node('p-iscoin', 'IsAsset', 260, 1180, { asset: 'coin-sprite' }), + node('p-brc', 'Branch', 480, 1060), + node('p-seqc', 'Sequence', 680, 1060), + node('p-game', 'ObjectByName', 680, 1280, { name: 'Game' }), + node('p-coins', 'GetProperty', 880, 1280, { property: 'coins' }), + node('p-add1', 'Add', 1080, 1280, { b: 1 }), + node('p-setcoins', 'SetProperty', 1280, 1060, { property: 'coins' }), + node('p-coinroot', 'Parent', 1080, 1420), + node('p-destroy', 'Destroy', 1280, 1200), ], edges: [ edge('pe1', 'p-upd', 'exec', 'p-setvy', 'exec'), @@ -236,55 +198,55 @@ const playerGraph = { }; /** - * "Game": owns the run. On start it shows the title and hides everything else; while - * `running` it counts the clock down, writes both HUD labels and decides win (coins reached) - * or loss (time gone) by switching the screens. + * "Game": owns the run. On start it shows the title and hides everything else; while `running` + * it counts the clock down, writes both HUD labels and decides win (coins reached) or loss (time + * gone) by switching the screens. */ const gameGraph = { nodes: [ - { id: 'g-start', type: 'OnStart', x: 0, y: 0, params: {} }, - { id: 'g-title', type: 'ObjectByName', x: 0, y: 140, params: { name: 'Title' } }, - { id: 'g-level', type: 'ObjectByName', x: 0, y: 220, params: { name: 'Level' } }, - { id: 'g-win', type: 'ObjectByName', x: 0, y: 300, params: { name: 'Win' } }, - { id: 'g-lose', type: 'ObjectByName', x: 0, y: 380, params: { name: 'Lose' } }, - { id: 'g-on1', type: 'SetEnabled', x: 260, y: 0, params: { enabled: true } }, - { id: 'g-off1', type: 'SetEnabled', x: 480, y: 0, params: { enabled: false } }, - { id: 'g-off2', type: 'SetEnabled', x: 700, y: 0, params: { enabled: false } }, - { id: 'g-off3', type: 'SetEnabled', x: 920, y: 0, params: { enabled: false } }, + node('g-start', 'OnStart', 0, 0), + node('g-title', 'ObjectByName', 0, 140, { name: 'Title' }), + node('g-level', 'ObjectByName', 0, 220, { name: 'Level' }), + node('g-win', 'ObjectByName', 0, 300, { name: 'Win' }), + node('g-lose', 'ObjectByName', 0, 380, { name: 'Lose' }), + node('g-on1', 'SetEnabled', 260, 0, { enabled: true }), + node('g-off1', 'SetEnabled', 480, 0, { enabled: false }), + node('g-off2', 'SetEnabled', 700, 0, { enabled: false }), + node('g-off3', 'SetEnabled', 920, 0, { enabled: false }), - { id: 'g-upd', type: 'OnUpdate', x: 0, y: 520, params: {} }, - { id: 'g-running', type: 'GetVariable', x: 0, y: 640, params: { variable: 'running' } }, - { id: 'g-brrun', type: 'Branch', x: 220, y: 520, params: {} }, - { id: 'g-time', type: 'GetVariable', x: 220, y: 700, params: { variable: 'timeLeft' } }, - { id: 'g-sub', type: 'Subtract', x: 420, y: 700, params: {} }, - { id: 'g-max', type: 'Max', x: 600, y: 700, params: { b: 0 } }, - { id: 'g-settime', type: 'SetVariable', x: 440, y: 520, params: { variable: 'timeLeft' } }, + node('g-upd', 'OnUpdate', 0, 520), + node('g-running', 'GetVariable', 0, 640, { variable: 'running' }), + node('g-brrun', 'Branch', 220, 520), + node('g-time', 'GetVariable', 220, 700, { variable: 'timeLeft' }), + node('g-sub', 'Subtract', 420, 700), + node('g-max', 'Max', 600, 700, { b: 0 }), + node('g-settime', 'SetVariable', 440, 520, { variable: 'timeLeft' }), - { id: 'g-timelabel', type: 'ObjectByName', x: 440, y: 860, params: { name: 'TimeLabel' } }, - { id: 'g-time2', type: 'GetVariable', x: 440, y: 940, params: { variable: 'timeLeft' } }, - { id: 'g-ceil', type: 'Ceil', x: 640, y: 940, params: {} }, - { id: 'g-join', type: 'Join', x: 840, y: 940, params: { a: 'Time ' } }, - { id: 'g-settext', type: 'SetText', x: 700, y: 520, params: {} }, + node('g-timelabel', 'ObjectByName', 440, 860, { name: 'TimeLabel' }), + node('g-time2', 'GetVariable', 440, 940, { variable: 'timeLeft' }), + node('g-ceil', 'Ceil', 640, 940), + node('g-join', 'Join', 840, 940, { a: 'Time ' }), + node('g-settext', 'SetText', 700, 520), - { id: 'g-coinlabel', type: 'ObjectByName', x: 700, y: 1080, params: { name: 'CoinLabel' } }, - { id: 'g-coins', type: 'GetVariable', x: 700, y: 1160, params: { variable: 'coins' } }, - { id: 'g-totext2', type: 'ToText', x: 900, y: 1160, params: { decimals: 0 } }, - { id: 'g-join2', type: 'Join', x: 1080, y: 1160, params: { a: 'Coins ' } }, - { id: 'g-join3', type: 'Join', x: 1260, y: 1160, params: { b: '/10' } }, - { id: 'g-settext2', type: 'SetText', x: 960, y: 520, params: {} }, + node('g-coinlabel', 'ObjectByName', 700, 1080, { name: 'CoinLabel' }), + node('g-coins', 'GetVariable', 700, 1160, { variable: 'coins' }), + node('g-totext2', 'ToText', 900, 1160, { decimals: 0 }), + node('g-join2', 'Join', 1080, 1160, { a: 'Coins ' }), + node('g-join3', 'Join', 1260, 1160, { b: '/10' }), + node('g-settext2', 'SetText', 960, 520), - { id: 'g-needed', type: 'GetVariable', x: 960, y: 700, params: { variable: 'needed' } }, - { id: 'g-cmpwin', type: 'Compare', x: 1160, y: 700, params: { op: '>=' } }, - { id: 'g-brwin', type: 'Branch', x: 1220, y: 520, params: {} }, - { id: 'g-stop1', type: 'SetVariable', x: 1420, y: 440, params: { variable: 'running', value: false } }, - { id: 'g-leveloff1', type: 'SetEnabled', x: 1620, y: 440, params: { enabled: false } }, - { id: 'g-winon', type: 'SetEnabled', x: 1820, y: 440, params: { enabled: true } }, + node('g-needed', 'GetVariable', 960, 700, { variable: 'needed' }), + node('g-cmpwin', 'Compare', 1160, 700, { op: '>=' }), + node('g-brwin', 'Branch', 1220, 520), + node('g-stop1', 'SetVariable', 1420, 440, { variable: 'running', value: false }), + node('g-leveloff1', 'SetEnabled', 1620, 440, { enabled: false }), + node('g-winon', 'SetEnabled', 1820, 440, { enabled: true }), - { id: 'g-cmplose', type: 'Compare', x: 1160, y: 860, params: { op: '<=', b: 0 } }, - { id: 'g-brlose', type: 'Branch', x: 1420, y: 640, params: {} }, - { id: 'g-stop2', type: 'SetVariable', x: 1620, y: 640, params: { variable: 'running', value: false } }, - { id: 'g-leveloff2', type: 'SetEnabled', x: 1820, y: 640, params: { enabled: false } }, - { id: 'g-loseon', type: 'SetEnabled', x: 2020, y: 640, params: { enabled: true } }, + node('g-cmplose', 'Compare', 1160, 860, { op: '<=', b: 0 }), + node('g-brlose', 'Branch', 1420, 640), + node('g-stop2', 'SetVariable', 1620, 640, { variable: 'running', value: false }), + node('g-leveloff2', 'SetEnabled', 1820, 640, { enabled: false }), + node('g-loseon', 'SetEnabled', 2020, 640, { enabled: true }), ], edges: [ edge('ge1', 'g-start', 'exec', 'g-on1', 'exec'), @@ -341,15 +303,15 @@ const gameGraph = { /** "Start button": a click resets the counters on the Game behavior and swaps title for level. */ const startButtonGraph = { nodes: [ - { id: 's-click', type: 'OnClick', x: 0, y: 0, params: {} }, - { id: 's-game', type: 'ObjectByName', x: 0, y: 160, params: { name: 'Game' } }, - { id: 's-title', type: 'ObjectByName', x: 0, y: 240, params: { name: 'Title' } }, - { id: 's-level', type: 'ObjectByName', x: 0, y: 320, params: { name: 'Level' } }, - { id: 's-coins', type: 'SetProperty', x: 240, y: 0, params: { property: 'coins', value: 0 } }, - { id: 's-time', type: 'SetProperty', x: 460, y: 0, params: { property: 'timeLeft', value: 30 } }, - { id: 's-run', type: 'SetProperty', x: 680, y: 0, params: { property: 'running', value: true } }, - { id: 's-hide', type: 'SetEnabled', x: 900, y: 0, params: { enabled: false } }, - { id: 's-show', type: 'SetEnabled', x: 1120, y: 0, params: { enabled: true } }, + node('s-click', 'OnClick', 0, 0), + node('s-game', 'ObjectByName', 0, 160, { name: 'Game' }), + node('s-title', 'ObjectByName', 0, 240, { name: 'Title' }), + node('s-level', 'ObjectByName', 0, 320, { name: 'Level' }), + node('s-coins', 'SetProperty', 240, 0, { property: 'coins', value: 0 }), + node('s-time', 'SetProperty', 460, 0, { property: 'timeLeft', value: 30 }), + node('s-run', 'SetProperty', 680, 0, { property: 'running', value: true }), + node('s-hide', 'SetEnabled', 900, 0, { enabled: false }), + node('s-show', 'SetEnabled', 1120, 0, { enabled: true }), ], edges: [ edge('se1', 's-click', 'exec', 's-coins', 'exec'), @@ -367,15 +329,10 @@ const startButtonGraph = { /** "Restart button": one click, one node – the scene starts over from its saved state. */ const restartButtonGraph = { - nodes: [ - { id: 'r-click', type: 'OnClick', x: 0, y: 0, params: {} }, - { id: 'r-restart', type: 'RestartGame', x: 260, y: 0, params: {} }, - ], + nodes: [node('r-click', 'OnClick', 0, 0), node('r-restart', 'RestartGame', 260, 0)], edges: [edge('re1', 'r-click', 'exec', 'r-restart', 'exec')], }; -const variable = (key, kind, value) => ({ key, label: key, kind, default: value }); - // --- project --------------------------------------------------------------------------------- const project = { @@ -390,15 +347,14 @@ const project = { name: 'Level 1', props: { backgroundColor: SKY }, objects: [ - ...instantiate('L', 'level', 'Level', { x: 0, y: 0, z: 0 }), - // All three screens sit on the camera frame and start switched off, so the level stays - // visible in the editor; the Game behavior decides on `On start` which one is up. - // The title screen carries an editor offset so it does not cover the level in the scene view. - ...instantiate('T', 'title', 'Title', { x: 0, y: 0, z: 10 }, { enabled: true }, { editorOffset: { x: -750, y: -500 } }), - ...instantiate('W', 'win', 'Win', { x: 0, y: 0, z: 10 }, { enabled: true }, { editorOffset: { x: 0, y: -500 } }), - ...instantiate('B', 'lose', 'Lose', { x: 0, y: 0, z: 10 }, { enabled: true }, { editorOffset: { x: 750, y: -500 } }), - { id: 'ocam', assetId: 'cam', name: 'Main Camera', position: { x: 0, y: 0, z: 0 }, overrides: {}, parentId: '', templateId: '' }, - { id: 'ogame', assetId: 'game', name: 'Game', position: { x: -200, y: -220, z: 0 }, overrides: {}, parentId: '', templateId: '' }, + ...instantiate('L', 'level', { name: 'Level' }), + // All three screens sit on the camera frame; the Game behavior decides on `On start` which + // one is up. Editor offsets pull them apart in the scene view so they do not cover the level. + ...instantiate('T', 'title', { name: 'Title', z: 10, overrides: { enabled: true }, editorOffset: { x: -750, y: -500 } }), + ...instantiate('W', 'win', { name: 'Win', z: 10, overrides: { enabled: true }, editorOffset: { x: 0, y: -500 } }), + ...instantiate('B', 'lose', { name: 'Lose', z: 10, overrides: { enabled: true }, editorOffset: { x: 750, y: -500 } }), + sceneObject('ocam', 'cam', { name: 'Main Camera' }), + sceneObject('ogame', 'game', { name: 'Game', x: -200, y: -220 }), ], }, { id: 'cam', type: 'camera', name: 'Main Camera', props: { width: 640, height: 360, zoom: 1 } }, @@ -439,9 +395,9 @@ const project = { { id: 'restart-button', type: 'behavior', name: 'Restart Button', props: {}, variables: [], graph: restartButtonGraph }, { id: 'coin', type: 'container', name: 'Coin', props: {}, objects: coinTemplate }, { id: 'level', type: 'container', name: 'Level', props: {}, objects: levelTemplate }, - { id: 'title', type: 'container', name: 'Title screen', props: {}, objects: titleTemplate }, - { id: 'win', type: 'container', name: 'Win screen', props: {}, objects: winTemplate }, - { id: 'lose', type: 'container', name: 'Lose screen', props: {}, objects: loseTemplate }, + { id: 'title', type: 'container', name: 'Title screen', props: {}, objects: templates.title }, + { id: 'win', type: 'container', name: 'Win screen', props: {}, objects: templates.win }, + { id: 'lose', type: 'container', name: 'Lose screen', props: {}, objects: templates.lose }, ], }; diff --git a/tools/smoke-demo.mjs b/tools/smoke-demo.mjs deleted file mode 100644 index f6745908..00000000 --- a/tools/smoke-demo.mjs +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env node -/** - * Smoke test for the *shipped demo scene* (containers, template instances, spawning, collision). - * tools/e2e.mjs covers the editor in depth against a stable fixture; this one guards the other - * side: that the scene we actually hand out still loads and plays after a change. - * - * Usage: PATH=/opt/node22/bin:$PATH node tools/smoke-demo.mjs [projectId] [baseUrl] - * Defaults to the throwaway demo-check project (the playground itself is refused): - * node tools/seed-demo.mjs aaf5e962ef9c && node tools/smoke-demo.mjs - * Screenshots land in /tmp/gm-smoke-*.png. - */ -import { runChecks } from './harness.mjs'; -import { PROJECTS, cliArgs } from './projects.mjs'; - -const { projectId, baseUrl } = cliArgs('smoke-demo.mjs', PROJECTS.demoCheck); - -/** Seeded ids: the three container instances and everything the demo places. */ -const ROOTS = ['op', 'o3', 'o5', 'oe1', 'oe2']; -const OBJECT_COUNT = 12; - -await runChecks({ projectId, baseUrl, port: 9335, shotPrefix: 'gm-smoke' }, async (t) => { - const { cdp, consoleLog, check, sleep, evaluate, shot, clickText, keyPress } = t; - - const objects = async () => JSON.parse(await evaluate('JSON.stringify(__gm.state.activeScene.objects)')); - const world = async () => JSON.parse(await evaluate('JSON.stringify(__gm.world())')); - const holdKey = async (key, code, keyCode, ms) => { - await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key, code, windowsVirtualKeyCode: keyCode }); - await sleep(ms); - await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key, code, windowsVirtualKeyCode: keyCode }); - }; - - await cdp.send('Page.navigate', { url: `${baseUrl}?project=${projectId}` }); - await sleep(4000); - await shot('01-loaded'); - check('demo scene loaded without exceptions', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).join(' | ')); - check('debug hook present', Boolean(await evaluate('Boolean(window.__gm)'))); - - // 1. Structure: three container instances (Player + two Enemies), a camera and the spawner. - const objs = await objects(); - const roots = objs.filter((o) => o.parentId === '').map((o) => o.id); - check('demo scene has the seeded objects and roots', objs.length === OBJECT_COUNT && ROOTS.every((id) => roots.includes(id)), `${objs.length} objects, roots=${JSON.stringify(roots)}`); - check('hierarchy opens collapsed – one row per root', (await evaluate('__gm.hierarchyOrder().length')) === ROOTS.length, JSON.stringify(await evaluate('JSON.stringify(__gm.hierarchyOrder())'))); - check('hierarchy lists every object once expanded', (await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === OBJECT_COUNT); - - // 2. Every child of a container instance points at an object of that container's template. - const templateLinks = JSON.parse(await evaluate(`JSON.stringify((() => { - const scene = __gm.state.activeScene, assets = __gm.state.project.assets; - const containerOf = (id) => { const o = scene.objects.find((x) => x.id === id); return o ? assets.find((a) => a.id === o.assetId && a.type === 'container') ?? containerOf(o.parentId) : null; }; - return scene.objects.filter((o) => o.templateId).map((o) => { - const c = containerOf(o.parentId); - return { id: o.id, container: c ? c.id : '', ok: Boolean(c && c.objects.some((t) => t.id === o.templateId)) }; - }); - })())`)); - check('container instances are linked to their template', templateLinks.length === 7 && templateLinks.every((l) => l.ok), JSON.stringify(templateLinks)); - - // 3. Play: the demo runs, the Mover moves the player and colliders keep it out of an enemy. - await clickText('▶ Play'); - await sleep(400); - check('play opens the Game tab with the demo running', (await evaluate('__gm.tab()')) === 'game' && (await world()).entities.length === OBJECT_COUNT); - const startX = (await world()).entities.find((e) => e.objectId === 'o1').worldX; - await holdKey('ArrowRight', 'ArrowRight', 39, 400); - const movedX = (await world()).entities.find((e) => e.objectId === 'o1').worldX; - check('the Mover behavior moves the player while ArrowRight is held', movedX > startX + 30, `world x ${startX} → ${movedX}`); - await shot('02-playing'); - - // 3b. Line the player up with the upper enemy (100, −100) – editing while playing only touches - // the runtime copy – and walk right into it. Collider halves are 50 and 40, so a blocked - // player comes to rest with its centre exactly 90 away; without collisions it would run past. - await evaluate(`(() => { const o = __gm.state.activeScene.objects.find((x) => x.id === 'op'); o.position.x = -100; o.position.y = -100; __gm.state.preview(); })()`); - await sleep(150); - await holdKey('ArrowRight', 'ArrowRight', 39, 1500); - const w = await world(); - const player = w.entities.find((e) => e.objectId === 'o1'); - const enemy = w.entities.find((e) => e.objectId === 'o2'); - const dx = player.worldX - enemy.worldX; - const dy = Math.abs(player.worldY - enemy.worldY); - check('solid colliders stop the player at the enemy instead of letting it pass', dy < 1 && dx > -91 && dx < -89, `dx=${dx.toFixed(2)} dy=${dy.toFixed(2)}`); - await shot('03-collided'); - - // 4. Spawning a container: the Spawner drops a whole Enemy instance (container + two children). - await keyPress(' ', 'Space', { windowsVirtualKeyCode: 32 }); - await sleep(250); - const after = await objects(); - const fresh = after.filter((o) => !objs.some((old) => old.id === o.id)); - const container = fresh.find((o) => o.parentId === ''); - const linked = fresh.filter((o) => o.templateId && o.id !== container?.id); - check('Space spawns a full Enemy container instance', fresh.length === 3 && container?.assetId === 'enemy' && container.position.y === 150 && linked.length === 2, JSON.stringify(fresh.map((o) => ({ id: o.id, assetId: o.assetId, templateId: o.templateId })))); - const spawnWorld = await world(); - check('the spawned instance lives in the world and reached the Log node', spawnWorld.entities.length === OBJECT_COUNT + 3 && spawnWorld.log.includes('Enemy'), JSON.stringify({ n: spawnWorld.entities.length, log: spawnWorld.log.slice(-1) })); - await shot('04-spawned'); - - // 5. Stop discards the runtime copy: the saved demo is exactly as it was. - await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 }); - await sleep(400); - const restored = await objects(); - check('stop restores the demo scene untouched', restored.length === OBJECT_COUNT && (await evaluate('__gm.tab()')) === 'scene', `${restored.length} objects`); - const op = restored.find((o) => o.id === 'op'); - const o1 = restored.find((o) => o.id === 'o1'); - check('the player container is back at its seeded position (the play-mode move is gone)', op.position.x === -100 && op.position.y === 0 && o1.position.x === 0 && o1.position.y === 0, JSON.stringify({ op: op.position, o1: o1.position })); - - check('no exceptions during run', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).slice(0, 3).join(' | ')); -}); diff --git a/tools/smoke-jumprun.mjs b/tools/smoke-jumprun.mjs index 0424fd98..284c4682 100644 --- a/tools/smoke-jumprun.mjs +++ b/tools/smoke-jumprun.mjs @@ -7,53 +7,51 @@ * Usage: PATH=/opt/node22/bin:$PATH node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs * Screenshots land in /tmp/gm-jr-*.png. */ -import { runChecks } from './harness.mjs'; -import { PROJECTS, cliArgs } from './projects.mjs'; +import { runChecks } from './lib/harness.mjs'; +import { PROJECTS, cliArgs } from './lib/projects.mjs'; const { projectId, baseUrl } = cliArgs('smoke-jumprun.mjs', PROJECTS.jumpRun); +/** What the seeder writes; a mismatch means the loader dropped something. */ +const OBJECT_COUNT = 77; +const GRAPHS = { player: [35, 38], game: [38, 43], 'start-button': [9, 10], 'restart-button': [2, 1] }; +const GROUND_Y = 130; + await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (t) => { - const { cdp, consoleLog, check, sleep, evaluate, shot, click, clickText, keyPress } = t; + const { check, sleep, evaluate, json, shot, click, holdKey, press, entity, world, mutate, play, stop, navigate, exceptions } = t; - const world = async () => JSON.parse(await evaluate('JSON.stringify(__gm.world())')); - const entity = async (objectId) => (await world()).entities.find((e) => e.objectId === objectId); /** Variables of the "Game" behavior instance, live from the running world. */ - const gameVars = async () => (await world()).entities.find((e) => e.objectId === 'ogame').props; - const holdKey = async (key, code, keyCode, ms) => { - await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key, code, windowsVirtualKeyCode: keyCode }); - await sleep(ms); - await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key, code, windowsVirtualKeyCode: keyCode }); - }; + 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 = JSON.parse(await evaluate(`JSON.stringify(__gm.gameScreen(${JSON.stringify(objectId)}))`)); + const p = await json(`__gm.gameScreen(${JSON.stringify(objectId)})`); if (!p) throw new Error(`object ${objectId} is not on screen in the game view`); await click(p.x, p.y); }; /** Edits the play copy (never the saved project) and pushes it into the running world. */ - const setVariable = async (objectId, key, value) => { - await evaluate(`(() => { const o = __gm.state.activeScene.objects.find((x) => x.id === ${JSON.stringify(objectId)}); o.overrides[${JSON.stringify(key)}] = ${JSON.stringify(value)}; __gm.state.preview(); })()`); - await sleep(150); - }; + const setVariable = (objectId, key, value) => + mutate(`__gm.state.activeScene.objects.find((x) => x.id === ${JSON.stringify(objectId)}).overrides[${JSON.stringify(key)}] = ${JSON.stringify(value)}`); - await cdp.send('Page.navigate', { url: `${baseUrl}?project=${projectId}` }); - await sleep(4000); - check('the game project loads without exceptions', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).join(' | ')); + await navigate(); // 1. Nothing was dropped by the loader: every node and edge of the four behaviors survived. - const graphs = JSON.parse(await evaluate(`JSON.stringify(__gm.state.project.assets.filter((a) => a.type === 'behavior').map((a) => ({ id: a.id, n: a.graph.nodes.length, e: a.graph.edges.length })))`)); - const expected = { player: [35, 38], game: [38, 43], 'start-button': [9, 10], 'restart-button': [2, 1] }; - check('all behavior graphs load complete (no unknown node types, no dropped edges)', - graphs.length === 4 && graphs.every((g) => expected[g.id] && g.n === expected[g.id][0] && g.e === expected[g.id][1]), JSON.stringify(graphs)); - const objectCount = JSON.parse(await evaluate('JSON.stringify(__gm.state.activeScene.objects.length)')); - check('the scene holds the seeded objects', objectCount === 77, `${objectCount} objects`); + const graphs = await json(`__gm.state.project.assets.filter((a) => a.type === 'behavior').map((a) => ({ id: a.id, n: a.graph.nodes.length, e: a.graph.edges.length }))`); + check( + 'all behavior graphs load complete (no unknown node types, no dropped edges)', + graphs.length === 4 && graphs.every((g) => GRAPHS[g.id] && g.n === GRAPHS[g.id][0] && g.e === GRAPHS[g.id][1]), + JSON.stringify(graphs), + ); + const objectCount = await evaluate('__gm.state.activeScene.objects.length'); + check('the scene holds the seeded objects', objectCount === OBJECT_COUNT, `${objectCount} objects`); await shot('01-editor'); // 2. Play: the Game behavior shows the title screen and switches the level off. - await clickText('▶ Play'); - await sleep(600); + await play(); + await sleep(300); check('play opens the Game tab', (await evaluate('__gm.tab()')) === 'game'); - check('the title screen is up', Boolean(await evaluate('Boolean(__gm.locate("JUMP & RUN"))'))); + check('the title screen is up', await visible('JUMP & RUN')); const level = await entity('L'); const title = await entity('T'); check('the Game behavior switched the level off and the title on', level.props.enabled === false && title.props.enabled === true, JSON.stringify({ level: level.props.enabled, title: title.props.enabled })); @@ -64,62 +62,59 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async ( await sleep(400); check('clicking Start hides the title and shows the level', (await entity('L')).props.enabled === true && (await entity('T')).props.enabled === false); const vars = await gameVars(); - check('the run starts with a full clock and no coins', vars.running === true && vars.coins === 0 && vars.timeLeft > 118, JSON.stringify(vars)); + check('the run starts with a full clock and no coins', vars.running === true && vars.coins === 0 && vars.timeLeft > 28, JSON.stringify(vars)); await shot('03-running'); // 4. Gravity: the player rests on the ground (its 40-high box on the ground's top edge, y = 150). const grounded = await entity('L-player'); - check('gravity dropped the player onto the ground (top edge at y = 150)', Math.abs(grounded.worldY - 130) < 1.5, `world y ${grounded.worldY.toFixed(2)}`); + check('gravity dropped the player onto the ground (top edge at y = 150)', Math.abs(grounded.worldY - GROUND_Y) < 1.5, `world y ${grounded.worldY.toFixed(2)}`); // 5. Jumping: Space lifts the player (y gets smaller) and gravity brings it back down. - await keyPress(' ', 'Space', { windowsVirtualKeyCode: 32 }); + await press('Space'); await sleep(200); const airborne = await entity('L-player'); check('Space makes the player jump', airborne.worldY < grounded.worldY - 25, `y ${grounded.worldY.toFixed(1)} → ${airborne.worldY.toFixed(1)}`); await sleep(1200); const landed = await entity('L-player'); - check('the player falls back onto the ground', Math.abs(landed.worldY - 130) < 1.5, `y ${landed.worldY.toFixed(2)}`); + check('the player falls back onto the ground', Math.abs(landed.worldY - GROUND_Y) < 1.5, `y ${landed.worldY.toFixed(2)}`); await shot('04-jumped'); // 6. Walking and collecting: running into a coin removes it and counts up. - const coins = async () => (await world()).entities.filter((e) => e.name === 'Coin').length; const coinsBefore = await coins(); - await holdKey('ArrowRight', 'ArrowRight', 39, 1500); + await holdKey('ArrowRight', 1500); const walked = await entity('L-player'); check('arrow keys walk the player', walked.worldX > landed.worldX + 40, `x ${landed.worldX.toFixed(1)} → ${walked.worldX.toFixed(1)}`); const afterCoin = await gameVars(); const coinsAfter = await coins(); check('running into a coin collects it', afterCoin.coins >= 1 && coinsAfter < coinsBefore, JSON.stringify({ coins: afterCoin.coins, before: coinsBefore, after: coinsAfter })); - const hud = await evaluate('Boolean(__gm.locate("Coins 1/10"))'); - check('the HUD shows the counter', Boolean(hud)); - check('the clock is counting down', afterCoin.timeLeft < 118, `timeLeft ${afterCoin.timeLeft}`); + check('the HUD shows the counter', await visible('Coins 1/10')); + check('the clock is counting down', afterCoin.timeLeft < 28, `timeLeft ${afterCoin.timeLeft}`); await shot('05-collected'); // 7. Winning: with all ten coins the Game behavior swaps the level for the win screen. await setVariable('ogame', 'coins', 10); await sleep(300); - check('collecting everything wins the game', Boolean(await evaluate('Boolean(__gm.locate("YOU WIN!"))')) && (await entity('W')).props.enabled === true && (await entity('L')).props.enabled === false); + check('collecting everything wins the game', (await visible('YOU WIN!')) && (await entity('W')).props.enabled === true && (await entity('L')).props.enabled === false); await shot('06-won'); // 8. Restart game: back to a fresh run at the title screen, coins and clock reset. await clickObject('W-win-button'); await sleep(1200); - check('Play again restarts the scene at the title screen', Boolean(await evaluate('Boolean(__gm.locate("JUMP & RUN"))')) && (await evaluate('__gm.state.mode')) === 'play'); - const restartedCoins = (await world()).entities.filter((e) => e.name === 'Coin').length; - check('the restarted run has all ten coins back', restartedCoins === 10, `${restartedCoins} coins`); + check('Play again restarts the scene at the title screen', (await visible('JUMP & RUN')) && (await evaluate('__gm.state.mode')) === 'play'); + check('the restarted run has all ten coins back', (await coins()) === 10, `${await coins()} coins`); // 9. Losing: start again, run the clock out. await clickObject('T-title-button'); await sleep(300); await setVariable('ogame', 'timeLeft', 0.2); await sleep(600); - check('the clock running out loses the game', Boolean(await evaluate('Boolean(__gm.locate("TIME UP"))')) && (await entity('B')).props.enabled === true); + check('the clock running out loses the game', (await visible('TIME UP')) && (await entity('B')).props.enabled === true); await shot('07-lost'); // 10. Stop throws the run away – the saved project is untouched. - await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 }); - await sleep(500); - const saved = JSON.parse(await evaluate('JSON.stringify({ objects: __gm.state.activeScene.objects.length, mode: __gm.state.mode, tab: __gm.tab() })')); - check('stop restores the edited scene', saved.objects === 77 && saved.mode === 'edit' && saved.tab === 'scene', JSON.stringify(saved)); - check('no exceptions during the run', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).slice(0, 3).join(' | ')); + await stop(); + await sleep(200); + const saved = await json('{ objects: __gm.state.activeScene.objects.length, mode: __gm.state.mode, tab: __gm.tab() }'); + check('stop restores the edited scene', saved.objects === OBJECT_COUNT && saved.mode === 'edit' && saved.tab === 'scene', JSON.stringify(saved)); + check('no exceptions during the run', exceptions().length === 0, exceptions().slice(0, 3).join(' | ')); });