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:
1 parent
3234eb480f
commit
1eb28314cf
189 files changed
+6129
-5441
No files matched your search
@@ -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
|
||||
@@ -187,15 +188,20 @@ selected and switched back on.
|
||||
|
||||
```
|
||||
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/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/ scene renderer: Scene tab (model, editable) and Game tab (world through its camera)
|
||||
src/editor/ App composition, state, panels, inspector field factory
|
||||
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/ harness/projects + e2e, smoke-demo, seed-*
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,5 +2,7 @@
|
||||
import './nodes';
|
||||
|
||||
export * from './Graph';
|
||||
export * from './NodeContext';
|
||||
export * from './NodeRegistry';
|
||||
export * from './values';
|
||||
export { GraphInterpreter, type TraceListener } from './GraphInterpreter';
|
||||
@@ -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 {}; } });
|
||||
@@ -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' };
|
||||
},
|
||||
};
|
||||
@@ -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' };
|
||||
},
|
||||
};
|
||||
@@ -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 {};
|
||||
},
|
||||
};
|
||||
@@ -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' };
|
||||
},
|
||||
};
|
||||
@@ -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' };
|
||||
},
|
||||
};
|
||||
@@ -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 } };
|
||||
},
|
||||
};
|
||||
@@ -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 {};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { unaryMath } from '../shared';
|
||||
|
||||
export const Abs = unaryMath('Abs', 'Absolute value (drops the sign).', Math.abs);
|
||||
@@ -0,0 +1,3 @@
|
||||
import { binaryMath } from '../shared';
|
||||
|
||||
export const Add = binaryMath('Add', 'a + b', (a, b) => a + b);
|
||||
@@ -0,0 +1,3 @@
|
||||
import { binaryLogic } from '../shared';
|
||||
|
||||
export const And = binaryLogic('And', 'Both true.', (a, b) => a && b);
|
||||
@@ -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 } };
|
||||
},
|
||||
};
|
||||
@@ -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) } }),
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { unaryMath } from '../shared';
|
||||
|
||||
export const Ceil = unaryMath('Ceil', 'Rounds up to a whole number.', Math.ceil);
|
||||
@@ -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)) },
|
||||
}),
|
||||
};
|
||||
@@ -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)) } };
|
||||
},
|
||||
};
|
||||
@@ -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 } };
|
||||
},
|
||||
};
|
||||
@@ -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));
|
||||
@@ -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) } };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { unaryMath } from '../shared';
|
||||
|
||||
export const Floor = unaryMath('Floor', 'Rounds down to a whole number.', Math.floor);
|
||||
@@ -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 } };
|
||||
},
|
||||
};
|
||||
@@ -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 },
|
||||
}),
|
||||
};
|
||||
@@ -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 } };
|
||||
},
|
||||
};
|
||||
@@ -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 } };
|
||||
},
|
||||
};
|
||||
@@ -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)) } };
|
||||
},
|
||||
};
|
||||
@@ -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)) } };
|
||||
},
|
||||
};
|
||||
@@ -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) } }),
|
||||
};
|
||||
@@ -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 ?? '')) } }),
|
||||
};
|
||||
@@ -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)) } }),
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { binaryMath } from '../shared';
|
||||
|
||||
export const Max = binaryMath('Max', 'The larger of a and b.', Math.max);
|
||||
@@ -0,0 +1,3 @@
|
||||
import { binaryMath } from '../shared';
|
||||
|
||||
export const Min = binaryMath('Min', 'The smaller of a and b.', Math.min);
|
||||
@@ -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 } };
|
||||
},
|
||||
};
|
||||
@@ -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 } }),
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { binaryMath } from '../shared';
|
||||
|
||||
export const Multiply = binaryMath('Multiply', 'a × b', (a, b) => a * b);
|
||||
@@ -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 ?? '' } }),
|
||||
};
|
||||
@@ -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)) }),
|
||||
};
|
||||
@@ -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) } }),
|
||||
};
|
||||
@@ -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) } }),
|
||||
};
|
||||
@@ -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 ?? '')) } }),
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { binaryLogic } from '../shared';
|
||||
|
||||
export const Or = binaryLogic('Or', 'Either true.', (a, b) => a || b);
|
||||
@@ -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 } }),
|
||||
};
|
||||
@@ -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 } }),
|
||||
};
|
||||
@@ -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) } };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { unaryMath } from '../shared';
|
||||
|
||||
export const Round = unaryMath('Round', 'Rounds to the nearest whole number.', Math.round);
|
||||
@@ -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 } }),
|
||||
};
|
||||
@@ -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)));
|
||||
@@ -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) } }),
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { binaryMath } from '../shared';
|
||||
|
||||
export const Subtract = binaryMath('Subtract', 'a − b', (a, b) => a - b);
|
||||
@@ -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 } }),
|
||||
};
|
||||
@@ -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) } };
|
||||
},
|
||||
};
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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],
|
||||
};
|
||||
@@ -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],
|
||||
};
|
||||
@@ -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],
|
||||
};
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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 }],
|
||||
};
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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' }),
|
||||
};
|
||||
@@ -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'] }),
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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' };
|
||||
},
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
+22
-21
@@ -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') {
|
||||
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: current, asset, template };
|
||||
}
|
||||
seen.add(current.id);
|
||||
current = current.parentId ? findObject(tree, current.parentId) : undefined;
|
||||
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;
|
||||
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.
|
||||
result.push({ id, assetId: template.assetId, name: template.name, position: { ...template.position }, editorOffset: { x: 0, y: 0 }, overrides: { ...template.overrides }, parentId, templateId: template.id });
|
||||
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
@@ -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) {
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in new issue
Block a user