Refactoring: one file per node, consistent comments, shared helpers, split large modules

- Graph: nodes.ts split into nodes/<category>/<Type>.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 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-08 08:37:22 +02:00
1 parent 3234eb480f
commit 1eb28314cf
189 files changed
+6231 -5543

No files matched your search

+30 -27
View File
@@ -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/<category>/<Type>.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 <demoCheckId> \
&& 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
+3 -2
View File
@@ -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);
+2 -15
View File
@@ -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<CommandListener>();
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);
}
}
+18 -27
View File
@@ -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 {
+22 -31
View File
@@ -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<Asset>;
/** 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<SceneObject>[] }[] = [];
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;
}
+8 -9
View File
@@ -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);
}
}
+9 -17
View File
@@ -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<string>();
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 };
+26 -40
View File
@@ -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<GraphNode>; edges: Removed<GraphEdge>[] };
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<GraphEdge>[] = [];
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<GraphEdge>[] = [];
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;
}
+21
View File
@@ -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<string>, 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);
}
-10
View File
@@ -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;
}
}
+49
View File
@@ -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;
}
+36 -100
View File
@@ -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<string>();
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<SceneObject>[] = [];
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);
}
}
+11 -28
View File
@@ -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);
}
+3 -1
View File
@@ -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
+98
View File
@@ -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<EntityHandle>([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;
}
+4 -129
View File
@@ -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<EntityHandle>([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;
}
+2
View File
@@ -2,5 +2,7 @@
import './nodes';
export * from './Graph';
export * from './NodeContext';
export * from './NodeRegistry';
export * from './values';
export { GraphInterpreter, type TraceListener } from './GraphInterpreter';
-572
View File
@@ -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<string, (a: number, b: number) => 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 {}; } });
+18
View File
@@ -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' };
},
};
+17
View File
@@ -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' };
},
};
+27
View File
@@ -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 } };
},
};
@@ -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 {};
},
};
+20
View File
@@ -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' };
},
};
@@ -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' };
},
};
@@ -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' };
},
};
+18
View File
@@ -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' };
},
};
+22
View File
@@ -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 } };
},
};
+16
View File
@@ -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 {};
},
};
+3
View File
@@ -0,0 +1,3 @@
import { unaryMath } from '../shared';
export const Abs = unaryMath('Abs', 'Absolute value (drops the sign).', Math.abs);
+3
View File
@@ -0,0 +1,3 @@
import { binaryMath } from '../shared';
export const Add = binaryMath('Add', 'a + b', (a, b) => a + b);
+3
View File
@@ -0,0 +1,3 @@
import { binaryLogic } from '../shared';
export const And = binaryLogic('And', 'Both true.', (a, b) => a && b);
+24
View File
@@ -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 } };
},
};
+14
View File
@@ -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) } }),
};
+3
View File
@@ -0,0 +1,3 @@
import { unaryMath } from '../shared';
export const Ceil = unaryMath('Ceil', 'Rounds up to a whole number.', Math.ceil);
+16
View File
@@ -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)) },
}),
};
+34
View File
@@ -0,0 +1,34 @@
import type { NodeDef } from '../../NodeRegistry';
import { asNumber } from '../../values';
import { bool, num } from '../shared';
const OPERATORS: Record<string, (a: number, b: number) => 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)) } };
},
};
+18
View File
@@ -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 } };
},
};
+3
View File
@@ -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));
+19
View File
@@ -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) } };
},
};
+3
View File
@@ -0,0 +1,3 @@
import { unaryMath } from '../shared';
export const Floor = unaryMath('Floor', 'Rounds down to a whole number.', Math.floor);
+19
View File
@@ -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 } };
},
};
+16
View File
@@ -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 },
}),
};
+17
View File
@@ -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 } };
},
};
+19
View File
@@ -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 } };
},
};
+18
View File
@@ -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)) } };
},
};
+20
View File
@@ -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)) } };
},
};
+14
View File
@@ -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) } }),
};
+13
View File
@@ -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 ?? '')) } }),
};
+14
View File
@@ -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)) } }),
};
+3
View File
@@ -0,0 +1,3 @@
import { binaryMath } from '../shared';
export const Max = binaryMath('Max', 'The larger of a and b.', Math.max);
+3
View File
@@ -0,0 +1,3 @@
import { binaryMath } from '../shared';
export const Min = binaryMath('Min', 'The smaller of a and b.', Math.min);
+17
View File
@@ -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 } };
},
};
+13
View File
@@ -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 } }),
};
@@ -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 } }),
};
+3
View File
@@ -0,0 +1,3 @@
import { binaryMath } from '../shared';
export const Multiply = binaryMath('Multiply', 'a × b', (a, b) => a * b);
+14
View File
@@ -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 ?? '' } }),
};
+14
View File
@@ -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)) }),
};
+14
View File
@@ -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) } }),
};
+14
View File
@@ -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) } }),
};
+13
View File
@@ -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 ?? '')) } }),
};
+3
View File
@@ -0,0 +1,3 @@
import { binaryLogic } from '../shared';
export const Or = binaryLogic('Or', 'Either true.', (a, b) => a || b);
+13
View File
@@ -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 } }),
};
+14
View File
@@ -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 } }),
};
+17
View File
@@ -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) } };
},
};
+3
View File
@@ -0,0 +1,3 @@
import { unaryMath } from '../shared';
export const Round = unaryMath('Round', 'Rounds to the nearest whole number.', Math.round);
+13
View File
@@ -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 } }),
};
+3
View File
@@ -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)));
+14
View File
@@ -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) } }),
};
+3
View File
@@ -0,0 +1,3 @@
import { binaryMath } from '../shared';
export const Subtract = binaryMath('Subtract', 'a − b', (a, b) => a - b);
+13
View File
@@ -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 } }),
};
+18
View File
@@ -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) } };
},
};
+14
View File
@@ -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: [],
};
@@ -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: [],
};
@@ -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: [],
};
@@ -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: [],
};
+12
View File
@@ -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],
};
+12
View File
@@ -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],
};
+12
View File
@@ -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],
};
+12
View File
@@ -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: [],
};
+12
View File
@@ -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 }],
};
+12
View File
@@ -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: [],
};
+14
View File
@@ -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' }),
};
+13
View File
@@ -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'] }),
};
+95
View File
@@ -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);
+79
View File
@@ -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 };
}
@@ -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 } }),
};
@@ -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' };
},
};
+42
View File
@@ -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);
}
+7 -5
View File
@@ -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;
}
-4
View File
@@ -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';
}
+16
View File
@@ -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<AssetType, string> = {
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<AssetType, 'scene'>;
@@ -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';
}
+15 -10
View File
@@ -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);
}
+14 -1
View File
@@ -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<string, PropertyValue>;
/** 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<T extends { z: number }>(items: readonly T[]): T[] {
return [...items].sort((a, b) => a.z - b.z);
+2 -2
View File
@@ -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,
+25 -24
View File
@@ -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<string>([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;
}
+45 -54
View File
@@ -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<string>();
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<string>): Set<string> {
const ids = new Set<string>();
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<string>();
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<string>();
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<SceneObject>[] {
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<string>([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<string>): 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) {
+29 -10
View File
@@ -55,11 +55,20 @@ export const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
// 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<AssetType, PropertySchema> = {
{ 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<AssetType, PropertySchema> = Object.fromEntries(
(Object.keys(ASSET_SCHEMAS) as AssetType[]).map((type) => [type, type === 'scene' ? ASSET_SCHEMAS[type] : [...COMMON_PROPS, ...ASSET_SCHEMAS[type]]]),
) as Record<AssetType, PropertySchema>;
/** 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': {
+9 -9
View File
@@ -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;
}
+4 -3
View File
@@ -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 {
+25 -17
View File
@@ -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<ColliderShape, { kind: 'box' }>;
type Circle = Extract<ColliderShape, { kind: 'circle' }>;
/** 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<ColliderShape, { kind: 'box' }>);
const circle = a.kind === 'circle' ? a : (b as Extract<ColliderShape, { kind: 'circle' }>);
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<ColliderShape, { kind: 'box' }>);
const circle = a.kind === 'circle' ? a : (b as Extract<ColliderShape, { kind: 'circle' }>);
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<ColliderShape, { kind: 'box' }>, 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;
}
Loaded 100 of 189 files, more files were not shown because too many files have changed in this diff. Show more