Add ESLint and Prettier, and close the node test gaps

Tooling:
- Prettier as the single formatting authority (110 columns, single quotes,
  measured against the existing style); Markdown excluded because table-cell
  padding turns the README glossary into 1000-character lines.
- Type-aware ESLint ending in eslint-config-prettier, so a rule always means
  "this is a bug", never "this looks different".
- .editorconfig and an `npm run check` that runs typecheck, lint, format and
  tests in one go.

Findings fixed on the first run: three more duplicate imports, four
String(unknown) calls (parameter types narrowed instead of casts), two dead
initialisers, two redundant casts, a console.log for a real error. Two
warnings were false here and are disabled with a reason in place.

Tests 132 -> 269:
- node-catalog.test.ts covers every node with a run(), with a guard that fails
  when a new node has no row.
- behavior-events.test.ts covers the ten event nodes through a running World,
  checking timing rather than existence.
- Input's state machine (press/release/releaseAll) is reachable without DOM
  events now; the DOM handlers are thin adapters.

Compare fell back to == without a configured operator although the schema
default is <; aligned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 10:02:22 +02:00
1 parent f140c2fd77
commit d54a620ebd
113 files changed
+4985 -602

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.php]
indent_size = 4
+9
View File
@@ -0,0 +1,9 @@
node_modules/
dist/
data/
index.html
package-lock.json
# Markdown is left alone: Prettier pads table cells to equal width, which turns the
# README's long glossary rows into 1000-character lines.
*.md
+12
View File
@@ -0,0 +1,12 @@
{
"printWidth": 110,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": true,
"quoteProps": "as-needed",
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf"
}
+14 -3
View File
@@ -213,9 +213,10 @@ 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; 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.
property is one schema line; a new node is one file **plus a row in the table in
`tests/core/node-catalog.test.ts`**, which fails if a node has no case. 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
@@ -226,11 +227,21 @@ npm install
npm run dev # http://localhost:5175/game-maker/dist/ – API calls are proxied to production
npm test # unit tests (core)
npm run typecheck
npm run lint # ESLint, type-aware; --fix for the fixable half
npm run format # Prettier over src, tests and tools
npm run check # typecheck + lint + format:check + tests, what CI would run
npm run build # writes dist/ and regenerates index.html
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
```
**Prettier owns the formatting** (110 columns, single quotes, `.prettierrc.json`); Markdown is
excluded because it pads table cells to equal width. ESLint therefore only ever means "this is a
mistake", never "this looks different": `eslint-config-prettier` switches every stylistic rule
off, and what is left is type-aware – unnecessary conditions, floating promises, duplicate
imports, base-to-string. Where a rule fires on something deliberate, the disable comment states
why (the `Log` node's `console.log`, a guard against untyped test hooks).
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.
+62
View File
@@ -0,0 +1,62 @@
import js from '@eslint/js';
import prettier from 'eslint-config-prettier';
import globals from 'globals';
import tseslint from 'typescript-eslint';
/**
* Formatting is Prettier's job – `eslint-config-prettier` switches every stylistic rule off,
* so a rule here always means "this is a mistake", never "this looks different".
*/
export default tseslint.config(
{ ignores: ['dist/**', 'data/**', 'node_modules/**', 'index.html'] },
// Type-aware linting for everything the tsconfig covers (src, tests, vite.config.ts).
{
files: ['src/**/*.ts', 'tests/**/*.ts', 'vite.config.ts'],
extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked],
languageOptions: {
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
globals: globals.browser,
},
rules: {
// The duplicate `import` statements the last refactoring round had to find by hand.
'no-duplicate-imports': 'error',
// `tsc` already reports unused locals; keep ESLint quiet about the same thing.
'@typescript-eslint/no-unused-vars': 'off',
// Off by design: the codebase builds objects from closures (`createFieldRow` returns
// `{ setValue }`, test fixtures return `{ sceneOf, object }`). Those have no `this`,
// but the rule cannot tell them from class methods and fired ~50 times on them.
'@typescript-eslint/unbound-method': 'off',
// A `?? []` on something that is never nullish hides that the type says otherwise.
'@typescript-eslint/no-unnecessary-condition': 'error',
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
'@typescript-eslint/consistent-type-imports': ['error', { fixStyle: 'inline-type-imports' }],
'@typescript-eslint/no-import-type-side-effects': 'error',
// Async work that nobody awaits silently swallows its rejection.
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': ['error', { checksVoidReturn: false }],
eqeqeq: ['error', 'always', { null: 'ignore' }],
'no-console': ['error', { allow: ['warn', 'error'] }],
'prefer-const': 'error',
'object-shorthand': ['error', 'properties'],
},
},
// Node scripts: plain JavaScript, no type information available.
{
files: ['tools/**/*.mjs'],
extends: [js.configs.recommended],
languageOptions: {
ecmaVersion: 2023,
sourceType: 'module',
globals: globals.node,
},
rules: {
'no-duplicate-imports': 'error',
eqeqeq: ['error', 'always', { null: 'ignore' }],
'prefer-const': 'error',
},
},
prettier,
);
+1276
View File
File diff suppressed because it is too large. Load diff
+12 -1
View File
@@ -9,11 +9,22 @@
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"test": "vitest run",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"check": "npm run typecheck && npm run lint && npm run format:check && npm run test"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^22.20.1",
"eslint": "^10.10.0",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.12.0",
"prettier": "^3.9.6",
"typescript": "^5.6.0",
"typescript-eslint": "^8.70.0",
"vite": "^6.0.0",
"vitest": "^3.0.0"
},
+4 -1
View File
@@ -6,7 +6,10 @@ export class CommandStack {
private readonly undoStack: Command[] = [];
private readonly redoStack: Command[] = [];
constructor(private readonly project: Project, private readonly limit = 200) {}
constructor(
private readonly project: Project,
private readonly limit = 200,
) {}
execute(command: Command): void {
command.execute(this.project);
+4 -1
View File
@@ -7,7 +7,10 @@ import type { Command } from './Command';
* so the project is never left half-changed.
*/
export class CompoundCommand implements Command {
constructor(readonly label: string, private readonly commands: readonly Command[]) {}
constructor(
readonly label: string,
private readonly commands: readonly Command[],
) {}
execute(project: Project): void {
const done: Command[] = [];
+4 -1
View File
@@ -15,7 +15,10 @@ export abstract class SetBagValueCommand<T> implements Command {
readonly label: string;
private previous: PropertyValue | undefined;
protected constructor(protected readonly key: string, label: string) {
protected constructor(
protected readonly key: string,
label: string,
) {
this.label = label;
}
+42 -8
View File
@@ -55,9 +55,18 @@ interface SlotMemo<T> {
* with the template along – copies the user changed on their own keep their value. Returns
* the memos in application order; `restoreSlots` undoes them.
*/
function applyToTemplate<T>(project: Project, link: TemplateLink, slots: readonly Slot<T>[], values: readonly T[]): SlotMemo<T>[] {
function applyToTemplate<T>(
project: Project,
link: TemplateLink,
slots: readonly Slot<T>[],
values: readonly T[],
): SlotMemo<T>[] {
const before = slots.map((slot) => slot.read(link.template));
const memos: SlotMemo<T>[] = slots.map((slot, i) => ({ object: link.template, slot, previous: before[i]! }));
const memos: SlotMemo<T>[] = slots.map((slot, i) => ({
object: link.template,
slot,
previous: before[i]!,
}));
for (const { object } of instancesOf(project, link.template.id)) {
slots.forEach((slot, i) => {
if (slot.read(object) !== before[i]) return;
@@ -84,7 +93,11 @@ export class ApplyOverrideToAssetCommand implements Command {
private previousOverride: PropertyValue | undefined;
private templateMemos: SlotMemo<PropertyValue | undefined>[] = [];
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly key: string) {
constructor(
private readonly sceneId: string,
private readonly objectId: string,
private readonly key: string,
) {
this.label = `Apply ${key} to asset`;
}
@@ -116,7 +129,11 @@ export class ApplyOverrideToContainerCommand implements Command {
readonly label: string;
private memos: SlotMemo<PropertyValue | undefined>[] = [];
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly key: string) {
constructor(
private readonly sceneId: string,
private readonly objectId: string,
private readonly key: string,
) {
this.label = `Apply ${key} to container`;
}
@@ -136,7 +153,11 @@ export class RevertOverrideToTemplateCommand implements Command {
readonly label: string;
private previous: PropertyValue | undefined;
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly key: string) {
constructor(
private readonly sceneId: string,
private readonly objectId: string,
private readonly key: string,
) {
this.label = `Revert ${key}`;
}
@@ -160,7 +181,11 @@ export class ApplyPositionToContainerCommand implements Command {
readonly label: string;
private memos: SlotMemo<number>[] = [];
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly axis?: Axis) {
constructor(
private readonly sceneId: string,
private readonly objectId: string,
private readonly axis?: Axis,
) {
this.label = axis ? `Apply ${axis} to container` : 'Apply position to container';
}
@@ -168,7 +193,12 @@ export class ApplyPositionToContainerCommand implements Command {
const object = requireObject(project, this.sceneId, this.objectId);
const link = requireLink(project, this.sceneId, object);
const axes = this.axis ? [this.axis] : ALL_AXES;
this.memos = applyToTemplate(project, link, axes.map(positionSlot), axes.map((a) => object.position[a]));
this.memos = applyToTemplate(
project,
link,
axes.map(positionSlot),
axes.map((a) => object.position[a]),
);
}
undo(): void {
@@ -181,7 +211,11 @@ export class RevertPositionToTemplateCommand implements Command {
readonly label: string;
private previous: Position = { x: 0, y: 0, z: 0 };
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly axis?: Axis) {
constructor(
private readonly sceneId: string,
private readonly objectId: string,
private readonly axis?: Axis,
) {
this.label = axis ? `Revert ${axis}` : 'Revert position';
}
+14 -3
View File
@@ -104,7 +104,10 @@ export class RenameAssetCommand implements Command {
readonly label = 'Rename asset';
private previousName = '';
constructor(private readonly assetId: string, private readonly name: string) {}
constructor(
private readonly assetId: string,
private readonly name: string,
) {}
execute(project: Project): void {
const asset = requireAsset(project, this.assetId);
@@ -119,7 +122,11 @@ export class RenameAssetCommand implements Command {
/** The half of `SetBagValueCommand` both asset property edits share: the bag is the asset's own. */
abstract class AssetPropertyCommand extends SetBagValueCommand<Asset> {
protected constructor(private readonly assetId: string, key: string, label: string) {
protected constructor(
private readonly assetId: string,
key: string,
label: string,
) {
super(key, label);
}
@@ -134,7 +141,11 @@ abstract class AssetPropertyCommand extends SetBagValueCommand<Asset> {
/** Sets an asset property (coerced against its schema entry). */
export class SetAssetPropertyCommand extends AssetPropertyCommand {
constructor(assetId: string, key: string, private readonly value: PropertyValue) {
constructor(
assetId: string,
key: string,
private readonly value: PropertyValue,
) {
super(assetId, key, `Set ${key}`);
}
+9 -2
View File
@@ -18,7 +18,10 @@ export class AddColliderCommand implements Command {
readonly assetId = newId();
readonly objectId = newId();
constructor(private readonly sceneId: string, private readonly targetId: string) {}
constructor(
private readonly sceneId: string,
private readonly targetId: string,
) {}
execute(project: Project): void {
const scene = requireScene(project, this.sceneId);
@@ -26,7 +29,11 @@ export class AddColliderCommand implements Command {
const targetAsset = requireAsset(project, target.assetId);
if (targetAsset.type === 'collider') throw new CommandError('A collider cannot have a collider');
const asset = buildAsset('collider', nextAssetName(project, `${target.name || targetAsset.name} collider`), this.assetId);
const asset = buildAsset(
'collider',
nextAssetName(project, `${target.name || targetAsset.name} collider`),
this.assetId,
);
if (targetAsset.type === 'sprite') {
const props = resolveSceneObjectProps(targetAsset, target);
asset.props.width = Number(props.width);
+42 -9
View File
@@ -1,6 +1,13 @@
import { newId } from '../ids';
import type { ContainerAsset } from '../model/Asset';
import { findObject, localPositionFor, subtreeIds, 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';
@@ -25,7 +32,10 @@ export class CreateContainerCommand implements Command {
readonly objectId = newId();
private previous: { id: string; parentId: string; position: Position; templateId: string }[] = [];
constructor(private readonly sceneId: string, private readonly objectIds: readonly string[]) {}
constructor(
private readonly sceneId: string,
private readonly objectIds: readonly string[],
) {}
execute(project: Project): void {
const scene = requireScene(project, this.sceneId);
@@ -41,14 +51,25 @@ 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 roots = new Set(
subtreeRoots(
scene,
selected.map((o) => o.id),
),
);
const memberIds = subtreeIds(scene, roots);
const members = treeOrder(scene).map((e) => e.object).filter((o) => memberIds.has(o.id));
const members = treeOrder(scene)
.map((e) => e.object)
.filter((o) => memberIds.has(o.id));
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.
const asset = buildAsset('container', nextAssetName(project, 'Container'), this.assetId) as ContainerAsset;
const asset = buildAsset(
'container',
nextAssetName(project, 'Container'),
this.assetId,
) as ContainerAsset;
const templateIds = new Map(members.map((o) => [o.id, newId()]));
for (const object of members) {
const isRoot = roots.has(object.id);
@@ -57,18 +78,30 @@ export class CreateContainerCommand implements Command {
id: templateIds.get(object.id)!,
assetId: object.assetId,
name: object.name,
position: isRoot ? { x: world.x - centre.x, y: world.y - centre.y, z: world.z } : { ...object.position },
position: isRoot
? { x: world.x - centre.x, y: world.y - centre.y, z: world.z }
: { ...object.position },
// A view-only offset belongs to the placed object, not to the template (see container.ts).
editorOffset: { x: 0, y: 0 },
overrides: { ...object.overrides },
parentId: isRoot ? '' : templateIds.get(object.parentId) ?? '',
parentId: isRoot ? '' : (templateIds.get(object.parentId) ?? ''),
templateId: '',
});
}
// Scene: the container takes the roots' place, the members become its instance.
this.previous = members.map((o) => ({ id: o.id, parentId: o.parentId, position: { ...o.position }, templateId: o.templateId }));
const container = createSceneObject(this.objectId, this.assetId, localPositionFor(scene, containerParentId, centre), containerParentId);
this.previous = members.map((o) => ({
id: o.id,
parentId: o.parentId,
position: { ...o.position },
templateId: o.templateId,
}));
const container = createSceneObject(
this.objectId,
this.assetId,
localPositionFor(scene, containerParentId, centre),
containerParentId,
);
for (const object of members) {
if (roots.has(object.id)) {
const world = worldPosition(scene, object);
+55 -10
View File
@@ -1,4 +1,11 @@
import { edgesInto, edgesOfNode, findNode, type GraphEdge, type GraphNode, portsCompatible } from '../graph/Graph';
import {
edgesInto,
edgesOfNode,
findNode,
type GraphEdge,
type GraphNode,
portsCompatible,
} from '../graph/Graph';
import { nodeDef } from '../graph/NodeRegistry';
import { newId } from '../ids';
import type { PropertyBag, PropertyValue } from '../model/Properties';
@@ -14,11 +21,23 @@ export class AddNodeCommand implements Command {
readonly label: string;
readonly node: GraphNode;
constructor(private readonly assetId: string, type: string, x: number, y: number, params: PropertyBag = {}) {
constructor(
private readonly assetId: string,
type: string,
x: number,
y: number,
params: PropertyBag = {},
) {
// An unknown type is rejected in `execute`, not here: constructors run outside the
// editor's error handling, so throwing here would crash instead of showing a notice.
const def = nodeDef(type);
this.node = { id: newId(), type, x: Math.round(x), y: Math.round(y), params: { ...defaultsOf(def?.params ?? []), ...params } };
this.node = {
id: newId(),
type,
x: Math.round(x),
y: Math.round(y),
params: { ...defaultsOf(def?.params ?? []), ...params },
};
this.label = def ? `Add ${def.label}` : `Add ${type}`;
}
@@ -38,7 +57,10 @@ export class RemoveNodeCommand implements Command {
readonly label = 'Remove node';
private removed?: { node: Removed<GraphNode>; edges: Removed<GraphEdge>[] };
constructor(private readonly assetId: string, private readonly nodeId: string) {}
constructor(
private readonly assetId: string,
private readonly nodeId: string,
) {}
execute(project: Project): void {
const graph = requireBehavior(project, this.assetId).graph;
@@ -65,7 +87,12 @@ 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) {}
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);
@@ -83,7 +110,12 @@ export class MoveNodeCommand implements Command {
/** Sets a node param or the inline value of an unconnected data input. */
export class SetNodeParamCommand extends SetBagValueCommand<GraphNode> {
constructor(private readonly assetId: string, private readonly nodeId: string, key: string, private readonly value: PropertyValue) {
constructor(
private readonly assetId: string,
private readonly nodeId: string,
key: string,
private readonly value: PropertyValue,
) {
super(key, `Set ${key}`);
}
@@ -111,7 +143,11 @@ export class ConnectCommand implements Command {
readonly edge: GraphEdge;
private replaced: Removed<GraphEdge>[] = [];
constructor(private readonly assetId: string, from: { node: string; port: string }, to: { node: string; port: string }) {
constructor(
private readonly assetId: string,
from: { node: string; port: string },
to: { node: string; port: string },
) {
this.edge = { id: newId(), from: { ...from }, to: { ...to } };
}
@@ -124,7 +160,8 @@ export class ConnectCommand implements Command {
const out = requireNodeDef(fromNode.type).outputs.find((p) => p.key === this.edge.from.port);
const inp = requireNodeDef(toNode.type).inputs.find((p) => p.key === this.edge.to.port);
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 (!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');
const occupied = inp.type === 'exec' ? [] : edgesInto(graph, this.edge.to.node, this.edge.to.port);
this.replaced = removeWhere(graph.edges, (e) => occupied.includes(e));
@@ -143,7 +180,10 @@ export class DisconnectCommand implements Command {
readonly label = 'Disconnect';
private removed: Removed<GraphEdge>[] = [];
constructor(private readonly assetId: string, private readonly edgeId: string) {}
constructor(
private readonly assetId: string,
private readonly edgeId: string,
) {}
execute(project: Project): void {
const graph = requireBehavior(project, this.assetId).graph;
@@ -157,5 +197,10 @@ export class DisconnectCommand implements Command {
}
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;
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
);
}
+49 -10
View File
@@ -2,7 +2,13 @@ import { newId } from '../ids';
import { isPlaceableType } from '../model/AssetTypes';
import { instantiateContainer } from '../model/container';
import { isSelfOrAncestor, localPositionFor, removeSubtrees, worldPosition } from '../model/hierarchy';
import { type Position, type PropertyBag, type PropertyValue, toPosition, type Vec2 } from '../model/Properties';
import {
type Position,
type PropertyBag,
type PropertyValue,
toPosition,
type Vec2,
} from '../model/Properties';
import type { Project } from '../model/Project';
import { createSceneObject, type SceneObject } from '../model/SceneObject';
import { coerceValue } from '../properties/PropertySchema';
@@ -53,7 +59,10 @@ export class RemoveSceneObjectsCommand implements Command {
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';
}
@@ -83,7 +92,11 @@ 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) {}
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);
@@ -105,7 +118,11 @@ 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) {}
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);
@@ -122,7 +139,11 @@ export class RenameSceneObjectCommand implements Command {
readonly label = 'Rename object';
private previous = '';
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly name: string) {}
constructor(
private readonly sceneId: string,
private readonly objectId: string,
private readonly name: string,
) {}
execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId);
@@ -137,7 +158,12 @@ export class RenameSceneObjectCommand implements Command {
/** The half of `SetBagValueCommand` both override edits share: the bag is the object's overrides. */
abstract class OverrideCommand extends SetBagValueCommand<SceneObject> {
protected constructor(private readonly sceneId: string, private readonly objectId: string, key: string, label: string) {
protected constructor(
private readonly sceneId: string,
private readonly objectId: string,
key: string,
label: string,
) {
super(key, label);
}
@@ -152,7 +178,12 @@ abstract class OverrideCommand extends SetBagValueCommand<SceneObject> {
/** Sets a per-object override of an asset property. */
export class SetOverrideCommand extends OverrideCommand {
constructor(sceneId: string, objectId: string, key: string, private readonly value: PropertyValue) {
constructor(
sceneId: string,
objectId: string,
key: string,
private readonly value: PropertyValue,
) {
super(sceneId, objectId, key, `Override ${key}`);
}
@@ -180,9 +211,16 @@ export class ClearOverrideCommand extends OverrideCommand {
*/
export class SetParentCommand implements Command {
readonly label = 'Set parent';
private previous: { parentId: string; position: Position } = { parentId: '', position: { x: 0, y: 0, z: 0 } };
private previous: { parentId: string; position: Position } = {
parentId: '',
position: { x: 0, y: 0, z: 0 },
};
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly parentId: string) {}
constructor(
private readonly sceneId: string,
private readonly objectId: string,
private readonly parentId: string,
) {}
execute(project: Project): void {
const scene = requireScene(project, this.sceneId);
@@ -190,7 +228,8 @@ export class SetParentCommand implements Command {
if (this.parentId) {
if (this.parentId === this.objectId) throw new CommandError('An object cannot be its own parent');
requireObject(project, this.sceneId, this.parentId);
if (isSelfOrAncestor(scene, this.objectId, this.parentId)) throw new CommandError('An object cannot be parented to one of its own children');
if (isSelfOrAncestor(scene, this.objectId, this.parentId))
throw new CommandError('An object cannot be parented to one of its own children');
}
this.previous = { parentId: object.parentId, position: { ...object.position } };
const world = worldPosition(scene, object);
+16 -4
View File
@@ -10,7 +10,11 @@ export class AddVariableCommand implements Command {
readonly label: string;
readonly def: PropertyDef;
constructor(private readonly assetId: string, key: string, kind: VariableKind) {
constructor(
private readonly assetId: string,
key: string,
kind: VariableKind,
) {
this.def = variableDef(key, kind);
this.label = `Add variable ${key}`;
}
@@ -18,7 +22,8 @@ export class AddVariableCommand implements Command {
execute(project: Project): void {
const asset = requireBehavior(project, this.assetId);
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`);
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;
}
@@ -36,7 +41,10 @@ export class RemoveVariableCommand implements Command {
private removed?: { def: Removed<PropertyDef>; value: PropertyValue | undefined };
private overrides: { holderId: string; objectId: string; value: PropertyValue }[] = [];
constructor(private readonly assetId: string, private readonly key: string) {
constructor(
private readonly assetId: string,
private readonly key: string,
) {
this.label = `Remove variable ${key}`;
}
@@ -75,7 +83,11 @@ export class RemoveVariableCommand implements Command {
export class RenameVariableCommand implements Command {
readonly label: string;
constructor(private readonly assetId: string, private readonly from: string, private readonly to: string) {
constructor(
private readonly assetId: string,
private readonly from: string,
private readonly to: string,
) {
this.label = `Rename variable ${from}`;
}
+10 -3
View File
@@ -41,7 +41,12 @@ export class GraphInterpreter {
* Fires every event node of `type` whose params satisfy `filter`, seeding its outputs with
* `outputs` (e.g. `{ dt }`). Returns how many event nodes fired.
*/
fire(type: string, ctx: NodeContext, outputs: Record<string, Value> = {}, filter?: (node: GraphNode) => boolean): number {
fire(
type: string,
ctx: NodeContext,
outputs: Record<string, Value> = {},
filter?: (node: GraphNode) => boolean,
): number {
let fired = 0;
for (const node of this.eventNodes(type)) {
if (filter && !filter(node)) continue;
@@ -66,7 +71,8 @@ export class GraphInterpreter {
}
private execute(node: GraphNode, ctx: NodeContext): void {
if (++this.steps > MAX_STEPS_PER_FIRE) throw new Error('Behavior graph exceeded the step limit (infinite loop?)');
if (++this.steps > MAX_STEPS_PER_FIRE)
throw new Error('Behavior graph exceeded the step limit (infinite loop?)');
const def = requireNodeDef(node.type);
if (!def.run) return;
const inputs = this.collectInputs(node, ctx);
@@ -99,7 +105,8 @@ export class GraphInterpreter {
const def = requireNodeDef(source.type);
// Exec nodes that have not run in this pulse have no value yet.
if (isEventNode(def) || hasExecInput(def) || !def.run) return null;
if (++this.steps > MAX_STEPS_PER_FIRE) throw new Error('Behavior graph exceeded the step limit (data cycle?)');
if (++this.steps > MAX_STEPS_PER_FIRE)
throw new Error('Behavior graph exceeded the step limit (data cycle?)');
// Mark before evaluating so a data cycle reads null instead of recursing forever.
this.outputCache.set(nodeId, {});
const outputs = def.run(ctx, this.collectInputs(source, ctx), source.params).outputs ?? {};
+15 -3
View File
@@ -22,12 +22,20 @@ export interface EntityHandle {
/** World position of a handle: its local position plus those of all ancestors. */
export function worldPositionOf(handle: EntityHandle): { x: number; y: number; z: number } {
return sumAlongChain(handle, (h) => h.parent, (h) => h);
return sumAlongChain(
handle,
(h) => h.parent,
(h) => h,
);
}
/** 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);
return enabledAlongChain(
handle,
(h) => h.props,
(h) => h.parent,
);
}
/** Where the mouse is (world units) and what it did this frame. */
@@ -83,7 +91,11 @@ export interface NodeContext {
* 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;
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). */
+7 -1
View File
@@ -88,7 +88,13 @@ export function inlineInputDef(port: PortDef): PropertyDef {
case 'any':
return { key: port.key, label: port.label, kind: 'text', default: asString(port.default ?? '') };
default:
return { key: port.key, label: port.label, kind: 'number', default: asNumber(port.default ?? 0), step: 1 };
return {
key: port.key,
label: port.label,
kind: 'number',
default: asNumber(port.default ?? 0),
step: 1,
};
}
}
+8 -1
View File
@@ -10,7 +10,14 @@ export const Move: NodeDef = {
"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)],
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) => {
+2 -1
View File
@@ -5,7 +5,8 @@ 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.',
description:
'Starts the scene over from its saved state – everything spawned, moved or destroyed is reset.',
inputs: [exec()],
outputs: [],
params: [],
+3 -1
View File
@@ -28,7 +28,9 @@ export const Compare: NodeDef = {
},
],
run: (_ctx, inputs, params) => {
const compare = OPERATORS[String(params.op)] ?? OPERATORS['==']!;
// The fallback matches the param's declared default, so a node whose setting is missing
// (an old document, a hand-written graph) behaves like a freshly created one.
const compare = OPERATORS[String(params.op)] ?? OPERATORS['<']!;
return { outputs: { result: compare(asNumber(inputs.a), asNumber(inputs.b)) } };
},
};
+67 -10
View File
@@ -73,23 +73,80 @@ import { SetVariable } from './variable/SetVariable';
*/
export const BUILT_IN_NODES: readonly NodeDef[] = [
// events
OnStart, OnUpdate, OnKeyDown, OnKeyUp, OnKeyHeld, OnTimer,
OnCollisionEnter, OnCollisionStay, OnCollisionExit, OnClick,
OnStart,
OnUpdate,
OnKeyDown,
OnKeyUp,
OnKeyHeld,
OnTimer,
OnCollisionEnter,
OnCollisionStay,
OnCollisionExit,
OnClick,
// flow
Branch, Sequence,
Branch,
Sequence,
// variables
GetVariable, SetVariable,
GetVariable,
SetVariable,
// data: references and properties
Self, Owner, Parent, ObjectByName, NameOf, IsAsset, IsEnabled, GetProperty, GetPosition,
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,
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,
ToText,
Join,
Equals,
Compare,
And,
Or,
Not,
// data: vectors and input
Length, Distance, Normalize, AxisInput, KeyIsDown, MousePosition, MouseButton, IsHovered, IsTouching,
Length,
Distance,
Normalize,
AxisInput,
KeyIsDown,
MousePosition,
MouseButton,
IsHovered,
IsTouching,
// actions
Move, SetPosition, SetProperty, SetEnabled, SetText, Spawn, Destroy, Log, StopGame, RestartGame,
Move,
SetPosition,
SetProperty,
SetEnabled,
SetText,
Spawn,
Destroy,
Log,
StopGame,
RestartGame,
];
for (const def of BUILT_IN_NODES) registerNode(def);
+25 -4
View File
@@ -16,17 +16,34 @@ export const any = (key: string, label = key): PortDef => ({ key, label, type: '
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')];
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: [] };
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: [] };
export const assetParam: PropertyDef = {
key: 'asset',
label: 'Asset',
kind: 'enum',
default: '',
options: [],
};
// --- node factories --------------------------------------------------------------------------
@@ -59,7 +76,11 @@ export function unaryMath(type: string, description: string, fn: (value: number)
}
/** A data node combining the booleans `a` and `b`. */
export function binaryLogic(type: string, description: string, fn: (a: boolean, b: boolean) => boolean): NodeDef {
export function binaryLogic(
type: string,
description: string,
fn: (a: boolean, b: boolean) => boolean,
): NodeDef {
return {
type,
label: type,
+3 -1
View File
@@ -9,5 +9,7 @@ export const GetVariable: NodeDef = {
inputs: [],
outputs: [any('value')],
params: [variableParam],
run: (ctx, _inputs, params) => ({ outputs: { value: ctx.variables[String(params.variable ?? '')] ?? null } }),
run: (ctx, _inputs, params) => ({
outputs: { value: ctx.variables[String(params.variable ?? '')] ?? null },
}),
};
+2 -1
View File
@@ -65,7 +65,8 @@ export interface ContainerAsset extends AssetBase {
objects: SceneObject[];
}
export type Asset = SpriteAsset | CameraAsset | TextAsset | SceneAsset | BehaviorAsset | ColliderAsset | ContainerAsset;
export type Asset =
SpriteAsset | CameraAsset | TextAsset | SceneAsset | BehaviorAsset | ColliderAsset | ContainerAsset;
export function isSceneAsset(asset: Asset): asset is SceneAsset {
return asset.type === 'scene';
+9 -1
View File
@@ -1,5 +1,13 @@
/** All asset types. Scenes are assets too, but cannot be placed in scenes. */
export const ASSET_TYPES = ['scene', 'camera', 'sprite', 'text', 'behavior', 'collider', 'container'] as const;
export const ASSET_TYPES = [
'scene',
'camera',
'sprite',
'text',
'behavior',
'collider',
'container',
] as const;
export type AssetType = (typeof ASSET_TYPES)[number];
+5 -3
View File
@@ -1,5 +1,4 @@
import type { Asset, ContainerAsset, SceneAsset } from './Asset';
import { isSceneAsset } from './Asset';
import { type Asset, type ContainerAsset, isSceneAsset, type SceneAsset } from './Asset';
import type { SceneObject } from './SceneObject';
/** Written into every document. Reserved for format migrations – the loader does not branch on it yet. */
@@ -49,7 +48,10 @@ export interface SceneObjectRef {
}
/** Objects of all scenes and container templates that satisfy `predicate`, each with its holder. */
export function objectsWhere(project: Project, predicate: (object: SceneObject) => boolean): SceneObjectRef[] {
export function objectsWhere(
project: Project,
predicate: (object: SceneObject) => boolean,
): SceneObjectRef[] {
const result: SceneObjectRef[] = [];
for (const holder of objectHolders(project)) {
for (const object of holder.objects) if (predicate(object)) result.push({ holder, object });
+6 -1
View File
@@ -37,7 +37,12 @@ export interface SceneObject {
}
/** Creates a scene object; `z` defaults to 0 when the caller only has a 2D point. */
export function createSceneObject(id: string, assetId: string, position: Vec2 | Position, parentId = ''): SceneObject {
export function createSceneObject(
id: string,
assetId: string,
position: Vec2 | Position,
parentId = '',
): SceneObject {
return {
id,
assetId,
+14 -3
View File
@@ -16,7 +16,11 @@ export interface TemplateLink {
* asset holds the template this object was instantiated from. Undefined for everything else
* (no template id, or the link is stale).
*/
export function templateLinkOf(project: Project, tree: ObjectTree, object: SceneObject): TemplateLink | undefined {
export function templateLinkOf(
project: Project,
tree: ObjectTree,
object: SceneObject,
): TemplateLink | undefined {
if (!object.templateId) return undefined;
for (const containerObject of chainOf(tree, object).slice(1)) {
const asset = findAsset(project, containerObject.assetId);
@@ -46,12 +50,19 @@ const MAX_NESTING = 8;
* turn – unless the template already spells its content out (that is what a container built
* from a selection containing another container does).
*/
export function instantiateContainer(project: Project, asset: ContainerAsset, containerObjectId: string, depth = 0): SceneObject[] {
export function instantiateContainer(
project: Project,
asset: ContainerAsset,
containerObjectId: string,
depth = 0,
): SceneObject[] {
const ids = new Map(asset.objects.map((t) => [t.id, newId()]));
const result: SceneObject[] = [];
for (const template of asset.objects) {
const id = ids.get(template.id)!;
const parentId = template.parentId ? ids.get(template.parentId) ?? containerObjectId : containerObjectId;
const parentId = template.parentId
? (ids.get(template.parentId) ?? containerObjectId)
: containerObjectId;
result.push({
id,
assetId: template.assetId,
+16 -4
View File
@@ -31,7 +31,11 @@ function parentOf(tree: ObjectTree, object: SceneObject): SceneObject | undefine
/** All objects below `id`, depth-first, parents before children. */
export function descendantsOf(tree: ObjectTree, id: string): SceneObject[] {
return descendantsBelow(id, (parentId) => childrenOf(tree, parentId), (o) => o.id);
return descendantsBelow(
id,
(parentId) => childrenOf(tree, parentId),
(o) => o.id,
);
}
/** The ids of `rootIds` and of everything below them – what group operations act on. */
@@ -58,7 +62,11 @@ export function chainOf(tree: ObjectTree, object: SceneObject): SceneObject[] {
/** 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) : [];
return object
? chainOf(tree, object)
.slice(1)
.map((o) => o.id)
: [];
}
/** Whether `ancestorId` is `objectId` itself or one of its ancestors. */
@@ -80,7 +88,10 @@ function positionAlongChain(tree: ObjectTree, object: SceneObject, withEditorOff
return sumAlongChain(
object,
(o) => parentOf(tree, o),
(o) => (withEditorOffset ? { x: o.position.x + o.editorOffset.x, y: o.position.y + o.editorOffset.y, z: o.position.z } : o.position),
(o) =>
withEditorOffset
? { x: o.position.x + o.editorOffset.x, y: o.position.y + o.editorOffset.y, z: o.position.z }
: o.position,
);
}
@@ -128,7 +139,8 @@ export function treeOrder(tree: ObjectTree): TreeEntry[] {
visit('', 0);
// Objects whose parent is missing (should not happen after loading) are listed at the top level.
for (const object of tree.objects) {
if (object.parentId && !ids.has(object.parentId) && !result.some((e) => e.object === object)) result.push({ object, depth: 0 });
if (object.parentId && !ids.has(object.parentId) && !result.some((e) => e.object === object))
result.push({ object, depth: 0 });
}
return result;
}
+15 -3
View File
@@ -13,7 +13,11 @@ import type { Position } from './Properties';
*/
export function* parentChain<T>(start: T, parentOf: (item: T) => T | null | undefined): Generator<T> {
const seen = new Set<T>();
for (let current: T | null | undefined = start; current && !seen.has(current); current = parentOf(current)) {
for (
let current: T | null | undefined = start;
current && !seen.has(current);
current = parentOf(current)
) {
seen.add(current);
yield current;
}
@@ -31,7 +35,11 @@ export function hasParentCycle<T>(start: T, parentOf: (item: T) => T | null | un
* The local positions of `start` and all its ancestors summed up – the world position, as
* long as the hierarchy has no rotation or scale (it has none).
*/
export function sumAlongChain<T>(start: T, parentOf: (item: T) => T | null | undefined, positionOf: (item: T) => Position): Position {
export function sumAlongChain<T>(
start: T,
parentOf: (item: T) => T | null | undefined,
positionOf: (item: T) => Position,
): Position {
const total: Position = { x: 0, y: 0, z: 0 };
for (const item of parentChain(start, parentOf)) {
const position = positionOf(item);
@@ -47,7 +55,11 @@ export function sumAlongChain<T>(start: T, parentOf: (item: T) => T | null | und
* back into the key its own children are looked up by, which is what lets ids (scene objects)
* and object identity (entities) use the same walk.
*/
export function descendantsBelow<T, K>(rootKey: K, childrenOf: (key: K) => readonly T[], keyOf: (item: T) => K): T[] {
export function descendantsBelow<T, K>(
rootKey: K,
childrenOf: (key: K) => readonly T[],
keyOf: (item: T) => K,
): T[] {
const result: T[] = [];
const visit = (key: K) => {
for (const child of childrenOf(key)) {
+7 -2
View File
@@ -28,7 +28,9 @@ export type VariableKind = (typeof VARIABLE_KINDS)[number];
* same goes for everything parented below it. Assets default to on, so switching a single
* instance off is an ordinary override. Scenes have no such switch (they are not placed).
*/
export const COMMON_PROPS: PropertySchema = [{ key: 'enabled', label: 'Enabled', kind: 'boolean', default: true }];
export const COMMON_PROPS: PropertySchema = [
{ key: 'enabled', label: 'Enabled', kind: 'boolean', default: true },
];
const COMMON_KEYS = new Set(COMMON_PROPS.map((d) => d.key));
@@ -91,7 +93,10 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
/** Type schema with the common properties in front – the effective schema of an asset type. */
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]]]),
(Object.keys(ASSET_SCHEMAS) as AssetType[]).map((type) => [
type,
type === 'scene' ? ASSET_SCHEMAS[type] : [...COMMON_PROPS, ...ASSET_SCHEMAS[type]],
]),
) as Record<AssetType, PropertySchema>;
/** Schema shared by every asset of a type (common properties first). */
+4 -1
View File
@@ -25,7 +25,10 @@ export function textSize(props: PropertyBag): { w: number; h: number } {
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 };
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. */
+4 -1
View File
@@ -17,7 +17,10 @@ export interface WorldRect {
*/
export function cameraFrame(props: PropertyBag): { w: number; h: number } {
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 };
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 {
+22 -4
View File
@@ -19,8 +19,20 @@ 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 {
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)) };
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). */
@@ -36,7 +48,8 @@ export function colliderSize(props: PropertyBag): { w: number; h: number } {
/** Whether a point lies inside a shape (used for pointer hit tests). */
export function shapeContains(shape: ColliderShape, point: Vec2): boolean {
if (shape.kind === 'circle') return (point.x - shape.x) ** 2 + (point.y - shape.y) ** 2 <= shape.r * shape.r;
if (shape.kind === 'circle')
return (point.x - shape.x) ** 2 + (point.y - shape.y) ** 2 <= shape.r * shape.r;
return Math.abs(point.x - shape.x) * 2 <= shape.w && Math.abs(point.y - shape.y) * 2 <= shape.h;
}
@@ -86,7 +99,12 @@ export function axisSeparation(a: ColliderShape, b: ColliderShape, axis: PlaneAx
* `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: PlaneAxis, 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));
}
+5 -1
View File
@@ -16,7 +16,11 @@ function propsEnabled(props: PropertyBag): boolean {
* Whether `start` and every ancestor of it are switched on. Generic over the shape so the
* model (scene objects), the viewport (draw items) and the runtime (entities) share one rule.
*/
export function enabledAlongChain<T>(start: T, propsOf: (item: T) => PropertyBag, parentOf: (item: T) => T | null): boolean {
export function enabledAlongChain<T>(
start: T,
propsOf: (item: T) => PropertyBag,
parentOf: (item: T) => T | null,
): boolean {
for (const item of parentChain(start, parentOf)) if (!propsEnabled(propsOf(item))) return false;
return true;
}
+17 -5
View File
@@ -1,5 +1,10 @@
import type { Project } from '../model/Project';
import { type LoadedProject, type ProjectStore, type ProjectSummary, StaleRevisionError } from './ProjectStore';
import {
type LoadedProject,
type ProjectStore,
type ProjectSummary,
StaleRevisionError,
} from './ProjectStore';
import { deserializeProject, serializeProject } from './serialize';
/**
@@ -14,15 +19,22 @@ export class HttpProjectStore implements ProjectStore {
}
async load(id: string): Promise<LoadedProject> {
const body = await this.request<{ revision: number; project: unknown }>('GET', `projects/${encodeURIComponent(id)}`);
const body = await this.request<{ revision: number; project: unknown }>(
'GET',
`projects/${encodeURIComponent(id)}`,
);
return { project: deserializeProject(body.project, id), revision: body.revision };
}
async save(project: Project, revision: number): Promise<number> {
const body = await this.request<{ revision: number }>('PUT', `projects/${encodeURIComponent(project.id)}`, {
const body = await this.request<{ revision: number }>(
'PUT',
`projects/${encodeURIComponent(project.id)}`,
{
revision,
project: serializeProject(project),
});
},
);
return body.revision;
}
@@ -46,7 +58,7 @@ export class HttpProjectStore implements ProjectStore {
});
if (response.status === 204) return undefined as T;
const text = await response.text();
let data: unknown = null;
let data: unknown;
try {
data = text ? JSON.parse(text) : null;
} catch {
+37 -7
View File
@@ -6,7 +6,17 @@ import type { PropertyBag, PropertyValue } from '../model/Properties';
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
import { hasParentCycle } from '../model/walk';
import { coerceValue, COMMON_PROPS, isCommonProp, type PropertyDef, type PropertySchema, schemaFor, VARIABLE_KINDS, variableDef, type VariableKind } from '../properties/PropertySchema';
import {
coerceValue,
COMMON_PROPS,
isCommonProp,
type PropertyDef,
type PropertySchema,
schemaFor,
VARIABLE_KINDS,
variableDef,
type VariableKind,
} from '../properties/PropertySchema';
/**
* Turns untrusted JSON (server file, import) into a well-formed Project. Unknown keys are
@@ -44,7 +54,14 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
if (type === 'scene' || type === 'container') {
assets.push({ id, type, name, props, objects: normalizeObjects(a.objects, schemas) });
} else if (type === 'behavior') {
assets.push({ id, type, name, props, variables: variables.get(id) ?? [], graph: normalizeGraph(a.graph) });
assets.push({
id,
type,
name,
props,
variables: variables.get(id) ?? [],
graph: normalizeGraph(a.graph),
});
} else {
assets.push({ id, type, name, props });
}
@@ -107,12 +124,14 @@ function normalizeVariables(raw: unknown): PropertyDef[] {
for (const item of Array.isArray(raw) ? raw : []) {
const v = asRecord(item);
const key = typeof v.key === 'string' ? v.key : '';
const kind = (VARIABLE_KINDS as readonly string[]).includes(String(v.kind)) ? (v.kind as VariableKind) : 'number';
const kind = (VARIABLE_KINDS as readonly string[]).includes(String(v.kind))
? (v.kind as VariableKind)
: 'number';
// Common property keys are reserved – a variable of that name would shadow the switch.
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || seen.has(key) || isCommonProp(key)) continue;
seen.add(key);
const def = variableDef(key, kind);
def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue) as never;
def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue);
defs.push(def);
}
return defs;
@@ -130,7 +149,8 @@ function normalizeGraph(raw: unknown): Graph {
nodeIds.add(id);
const params: PropertyBag = {};
for (const [key, value] of Object.entries(asRecord(n.params))) {
if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') params[key] = value;
if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean')
params[key] = value;
}
const node: GraphNode = { id, type, x: toNumber(n.x), y: toNumber(n.y), params };
graph.nodes.push(node);
@@ -142,10 +162,20 @@ function normalizeGraph(raw: unknown): Graph {
const to = asRecord(e.to);
const id = typeof e.id === 'string' ? e.id : '';
if (!id || edgeIds.has(id)) continue;
if (typeof from.node !== 'string' || typeof from.port !== 'string' || typeof to.node !== 'string' || typeof to.port !== 'string') continue;
if (
typeof from.node !== 'string' ||
typeof from.port !== 'string' ||
typeof to.node !== 'string' ||
typeof to.port !== 'string'
)
continue;
if (!nodeIds.has(from.node) || !nodeIds.has(to.node)) continue;
edgeIds.add(id);
const edge: GraphEdge = { id, from: { node: from.node, port: from.port }, to: { node: to.node, port: to.port } };
const edge: GraphEdge = {
id,
from: { node: from.node, port: from.port },
to: { node: to.node, port: to.port },
};
graph.edges.push(edge);
}
return graph;
+31 -4
View File
@@ -18,7 +18,17 @@ import type { EditorState } from './state';
/** Facts about the running world for the check scripts. */
export interface WorldSnapshot {
entities: { objectId: string; assetId: string; name: string; x: number; y: number; worldX: number; worldY: number; parent: string; props: Record<string, unknown> }[];
entities: {
objectId: string;
assetId: string;
name: string;
x: number;
y: number;
worldX: number;
worldY: number;
parent: string;
props: Record<string, unknown>;
}[];
log: string[];
}
@@ -37,7 +47,10 @@ export class PlaySession {
private behaviors: BehaviorSystem | null = null;
private readonly input = new Input();
constructor(private readonly state: EditorState, private readonly host: PlayHost) {}
constructor(
private readonly state: EditorState,
private readonly host: PlayHost,
) {}
get isPlaying(): boolean {
return this.world !== null;
@@ -76,7 +89,11 @@ export class PlaySession {
world.addSystem(this.behaviors);
this.host.gameView.setWorld(world);
this.host.showGame();
this.state.setNotice(world.firstOfType('camera') ? 'Playing · edits are not saved · Esc stops' : 'This scene has no camera – nothing is rendered · Esc stops');
this.state.setNotice(
world.firstOfType('camera')
? 'Playing · edits are not saved · Esc stops'
: 'This scene has no camera – nothing is rendered · Esc stops',
);
if (world.stopRequested) this.stop();
}
@@ -125,7 +142,17 @@ export class PlaySession {
return {
entities: world.entities.map((e) => {
const w = worldPositionOf(e);
return { objectId: e.objectId, assetId: e.assetId, name: e.name, x: e.x, y: e.y, worldX: w.x, worldY: w.y, parent: e.parent?.objectId ?? '', props: { ...e.props } };
return {
objectId: e.objectId,
assetId: e.assetId,
name: e.name,
x: e.x,
y: e.y,
worldX: w.x,
worldY: w.y,
parent: e.parent?.objectId ?? '',
props: { ...e.props },
};
}),
log: [...world.log],
};
+16 -5
View File
@@ -13,7 +13,11 @@ export class ProjectSession {
* `beforeSwitch` runs before a different project is loaded or the current one is deleted –
* a run in progress has to end first, since it holds a copy of the project being replaced.
*/
constructor(private readonly state: EditorState, private readonly store: ProjectStore, private readonly beforeSwitch: () => void) {}
constructor(
private readonly state: EditorState,
private readonly store: ProjectStore,
private readonly beforeSwitch: () => void,
) {}
/** Opens the project named in the URL, else the first one on the server, else a new one. */
async boot(): Promise<void> {
@@ -25,7 +29,9 @@ export class ProjectSession {
}
await this.openFirstOrCreate();
} catch (error) {
this.state.setNotice(`Could not load project: ${error instanceof Error ? error.message : String(error)}`);
this.state.setNotice(
`Could not load project: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
@@ -69,10 +75,14 @@ export class ProjectSession {
label: 'Delete this project…',
danger: true,
onSelect: () =>
new Dialog(`Delete project "${this.state.project.name}"?`, 'All assets and scenes are removed permanently.', [
new Dialog(
`Delete project "${this.state.project.name}"?`,
'All assets and scenes are removed permanently.',
[
{ label: 'Cancel' },
{ label: 'Delete', danger: true, onClick: () => void this.deleteCurrent() },
]).openCentered(),
],
).openCentered(),
},
];
new Menu(items).openBelow(anchor);
@@ -85,7 +95,8 @@ export class ProjectSession {
label: scene.id === this.state.project.activeSceneId ? `● ${scene.name}` : scene.name,
onSelect: () => this.state.execute(new SetActiveSceneCommand(scene.id)),
}));
if (items.length === 0) items.push({ label: 'No scenes – create one in Assets', onSelect: () => {}, disabled: true });
if (items.length === 0)
items.push({ label: 'No scenes – create one in Assets', onSelect: () => {}, disabled: true });
new Menu(items).openBelow(anchor);
}
}
+5 -1
View File
@@ -7,7 +7,11 @@ import type { EditorState } from './state';
* and never overlap; a change that arrives while a save is in flight triggers another save
* afterwards.
*/
export function attachAutosave(state: EditorState, store: ProjectStore, delayMs = 1000): { flush: () => Promise<void> } {
export function attachAutosave(
state: EditorState,
store: ProjectStore,
delayMs = 1000,
): { flush: () => Promise<void> } {
let timer = 0;
let saving: Promise<void> | null = null;
let pending = false;
+5 -1
View File
@@ -24,6 +24,9 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
for (const child of node.children) walk(child);
};
walk(app.stage);
// TypeScript does not track the assignment inside `walk`, so it believes `found` is still
// `null` here – hence both the disable and the cast below.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!found) return null;
const node = found as Text;
const p = node.getGlobalPosition();
@@ -43,7 +46,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
gameScreen: (id: string) => gameView.objectScreen(id),
graph: {
current: () => graphEditor.currentAssetId,
portAt: (nodeId: string, port: string, isOutput: boolean) => graphEditor.portGlobal(nodeId, port, isOutput),
portAt: (nodeId: string, port: string, isOutput: boolean) =>
graphEditor.portGlobal(nodeId, port, isOutput),
nodeAt: (nodeId: string) => graphEditor.nodeGlobal(nodeId),
center: () => centreOf(graphEditor),
},
+92 -19
View File
@@ -1,10 +1,22 @@
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import { AddNodeCommand, ConnectCommand, DisconnectCommand, MoveNodeCommand, RemoveNodeCommand } from '../../core/commands/graphCommands';
import { edgesInto, edgesOutOf, type GraphEdge, type NodeDef, nodeDef, portsCompatible } from '../../core/graph';
import {
AddNodeCommand,
ConnectCommand,
DisconnectCommand,
MoveNodeCommand,
RemoveNodeCommand,
} from '../../core/commands/graphCommands';
import {
edgesInto,
edgesOutOf,
type GraphEdge,
type NodeDef,
nodeDef,
portsCompatible,
} from '../../core/graph';
import type { BehaviorAsset } from '../../core/model/Asset';
import { findAsset } from '../../core/model/Project';
import type { Trace } from '../../runtime/systems/BehaviorSystem';
import { TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
import { clamp, drawGrid, Menu, type Size, Theme, textStyle, Widget, zoomFactor } from '../../ui';
import type { EditorState } from '../state';
import { NodePalette } from './NodePalette';
@@ -47,7 +59,12 @@ export class GraphEditor extends Widget {
private pan = { x: 0, y: 0 };
private zoom = 1;
private panning: { startX: number; startY: number; panX: number; panY: number } | null = null;
private dragging: { nodeId: string; start: { x: number; y: number }; pointer: { x: number; y: number }; moved: boolean } | null = null;
private dragging: {
nodeId: string;
start: { x: number; y: number };
pointer: { x: number; y: number };
moved: boolean;
} | null = null;
private connecting: { from: PortAnchor; pointer: { x: number; y: number } } | null = null;
/** Fit the view once the editor has a real size (it may be hidden behind the Scene tab). */
private needsFit = true;
@@ -78,7 +95,10 @@ export class GraphEditor extends Widget {
});
state.on('selection', () => this.followSelection());
state.on('load', () => { this.assetId = null; this.followSelection(); });
state.on('load', () => {
this.assetId = null;
this.followSelection();
});
state.on('project', () => this.sync());
state.on('mode', () => this.redraw());
}
@@ -183,11 +203,13 @@ export class GraphEditor extends Widget {
if (e.target !== this) return;
const local = this.toLocal(e.global);
if (e.button === 2) {
if (this.assetId && this.state.mode === 'edit') this.openPalette(e.global.x, e.global.y, this.screenToGraph(local.x, local.y));
if (this.assetId && this.state.mode === 'edit')
this.openPalette(e.global.x, e.global.y, this.screenToGraph(local.x, local.y));
return;
}
if (e.button === 0 || e.button === 1) {
if (e.button === 0 && this.assetId && this.state.mode === 'edit') this.state.select({ kind: 'asset', assetId: this.assetId });
if (e.button === 0 && this.assetId && this.state.mode === 'edit')
this.state.select({ kind: 'asset', assetId: this.assetId });
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
}
}
@@ -205,13 +227,21 @@ export class GraphEditor extends Widget {
if (e.button !== 0) return;
if (hit) {
const p = this.toLocal(e.global);
this.connecting = { from: { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput }, pointer: this.screenToGraph(p.x, p.y) };
this.connecting = {
from: { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput },
pointer: this.screenToGraph(p.x, p.y),
};
this.redraw();
return;
}
this.state.select({ kind: 'node', assetId: this.assetId, nodeId: view.node.id });
const p = this.toLocal(e.global);
this.dragging = { nodeId: view.node.id, start: { x: view.node.x, y: view.node.y }, pointer: this.screenToGraph(p.x, p.y), moved: false };
this.dragging = {
nodeId: view.node.id,
start: { x: view.node.x, y: view.node.y },
pointer: this.screenToGraph(p.x, p.y),
moved: false,
};
this.nodeLayer.addChild(view); // raise
}
@@ -261,7 +291,13 @@ export class GraphEditor extends Widget {
if (target && target.isOutput !== connecting.from.isOutput) {
const out = connecting.from.isOutput ? connecting.from : target;
const inp = connecting.from.isOutput ? target : connecting.from;
this.state.execute(new ConnectCommand(this.assetId, { node: out.nodeId, port: out.port }, { node: inp.nodeId, port: inp.port }));
this.state.execute(
new ConnectCommand(
this.assetId,
{ node: out.nodeId, port: out.port },
{ node: inp.nodeId, port: inp.port },
),
);
}
}
this.redraw();
@@ -282,7 +318,8 @@ export class GraphEditor extends Widget {
new NodePalette((def: NodeDef) => {
if (!this.assetId) return;
const command = new AddNodeCommand(this.assetId, def.type, at.x, at.y);
if (this.state.execute(command)) this.state.select({ kind: 'node', assetId: this.assetId, nodeId: command.node.id });
if (this.state.execute(command))
this.state.select({ kind: 'node', assetId: this.assetId, nodeId: command.node.id });
}).open(globalX, globalY);
}
@@ -290,15 +327,37 @@ export class GraphEditor extends Widget {
const assetId = this.assetId;
if (!assetId) return;
this.state.select({ kind: 'node', assetId, nodeId: view.node.id });
new Menu([{ label: 'Delete node', danger: true, onSelect: () => this.state.execute(new RemoveNodeCommand(assetId, view.node.id)) }]).open(globalX, globalY);
new Menu([
{
label: 'Delete node',
danger: true,
onSelect: () => this.state.execute(new RemoveNodeCommand(assetId, view.node.id)),
},
]).open(globalX, globalY);
}
private openPortMenu(view: NodeView, port: string, isOutput: boolean, globalX: number, globalY: number): void {
private openPortMenu(
view: NodeView,
port: string,
isOutput: boolean,
globalX: number,
globalY: number,
): void {
const asset = this.asset();
if (!asset) return;
const edges = isOutput ? edgesOutOf(asset.graph, view.node.id, port) : edgesInto(asset.graph, view.node.id, port);
const edges = isOutput
? edgesOutOf(asset.graph, view.node.id, port)
: edgesInto(asset.graph, view.node.id, port);
if (edges.length === 0) return;
new Menu([{ label: edges.length === 1 ? 'Disconnect' : `Disconnect ${edges.length} links`, danger: true, onSelect: () => { for (const edge of edges) this.state.execute(new DisconnectCommand(asset.id, edge.id)); } }]).open(globalX, globalY);
new Menu([
{
label: edges.length === 1 ? 'Disconnect' : `Disconnect ${edges.length} links`,
danger: true,
onSelect: () => {
for (const edge of edges) this.state.execute(new DisconnectCommand(asset.id, edge.id));
},
},
]).open(globalX, globalY);
}
// --- drawing -----------------------------------------------------------------------------
@@ -320,7 +379,10 @@ export class GraphEditor extends Widget {
return;
}
const playing = this.state.mode === 'play';
this.hint.text = asset.graph.nodes.length === 0 ? `${asset.name} · right-click to add a node` : `${asset.name}${playing ? ' · live' : ''}`;
this.hint.text =
asset.graph.nodes.length === 0
? `${asset.name} · right-click to add a node`
: `${asset.name}${playing ? ' · live' : ''}`;
const trace = playing ? this.traceProvider(asset.id) : null;
const now = this.timeProvider();
@@ -362,7 +424,11 @@ export class GraphEditor extends Widget {
const inType = to.def.inputs.find((p) => p.key === edge.to.port)?.type ?? 'any';
const valid = portsCompatible(type, inType);
bezier(this.edges, from.x + a.x, from.y + a.y, to.x + b.x, to.y + b.y);
this.edges.stroke({ width: hot ? 4 : type === 'exec' ? 2.5 : 2, color: hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger, alpha: type === 'exec' || hot ? 1 : 0.85 });
this.edges.stroke({
width: hot ? 4 : type === 'exec' ? 2.5 : 2,
color: hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger,
alpha: type === 'exec' || hot ? 1 : 0.85,
});
}
private syncGrid(): void {
@@ -371,7 +437,14 @@ export class GraphEditor extends Widget {
if (step < MIN_GRID_STEP) return;
// `pan` is where the graph origin sits on screen – but the graph has no origin worth
// marking, so no axis lines.
drawGrid(this.grid, { originX: this.pan.x, originY: this.pan.y, step, w: this.w, h: this.h, alpha: 0.04 });
drawGrid(this.grid, {
originX: this.pan.x,
originY: this.pan.y,
step,
w: this.w,
h: this.h,
alpha: 0.04,
});
}
// --- test hooks -----------------------------------------------------------------------------
+12 -2
View File
@@ -47,8 +47,18 @@ export class NodePalette extends Popup {
private refresh(): void {
const query = this.search.value.trim().toLowerCase();
this.filtered = allNodeDefs()
.filter((d) => !query || d.label.toLowerCase().includes(query) || d.type.toLowerCase().includes(query) || d.description.toLowerCase().includes(query))
.sort((a, b) => CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category) || a.label.localeCompare(b.label));
.filter(
(d) =>
!query ||
d.label.toLowerCase().includes(query) ||
d.type.toLowerCase().includes(query) ||
d.description.toLowerCase().includes(query),
)
.sort(
(a, b) =>
CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category) ||
a.label.localeCompare(b.label),
);
this.list.setItems(this.filtered.map((d) => ({ id: d.type, label: d.label, hint: d.category })));
this.scroll.relayoutContent();
}
+112 -29
View File
@@ -1,7 +1,20 @@
import { CanvasTextMetrics, Graphics, Rectangle, Text } from 'pixi.js';
import { edgesInto, type Graph, type GraphNode, inlineInputs, inlineValue, type NodeCategory, type NodeDef, type PortDef, type PortType } from '../../core/graph';
import {
asString,
edgesInto,
type Graph,
type GraphNode,
inlineInputs,
inlineValue,
type NodeCategory,
type NodeDef,
type PortDef,
type PortType,
type Value,
} from '../../core/graph';
import type { BehaviorAsset } from '../../core/model/Asset';
import { findAsset, type Project } from '../../core/model/Project';
import type { PropertyValue } from '../../core/model/Properties';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Theme, textStyle, type TextStyleToken, Widget } from '../../ui';
import { keyLabel } from '../inspector/fieldFactory';
@@ -22,23 +35,34 @@ const PAD = 6;
export function categoryColor(category: NodeCategory): number {
const c = Theme.colors;
switch (category) {
case 'event': return c.categoryEvent;
case 'flow': return c.categoryFlow;
case 'data': return c.categoryData;
case 'variable': return c.categoryVariable;
case 'action': return c.categoryAction;
case 'event':
return c.categoryEvent;
case 'flow':
return c.categoryFlow;
case 'data':
return c.categoryData;
case 'variable':
return c.categoryVariable;
case 'action':
return c.categoryAction;
}
}
export function portColor(type: PortType): number {
const c = Theme.colors;
switch (type) {
case 'exec': return c.portExec;
case 'number': return c.portNumber;
case 'boolean': return c.portBoolean;
case 'string': return c.portString;
case 'object': return c.portObject;
case 'any': return c.portAny;
case 'exec':
return c.portExec;
case 'number':
return c.portNumber;
case 'boolean':
return c.portBoolean;
case 'string':
return c.portString;
case 'object':
return c.portObject;
case 'any':
return c.portAny;
}
}
@@ -62,7 +86,10 @@ export class NodeView extends Widget {
/** Data inputs that currently have a connection (their inline value is hidden). */
private connected = new Set<string>();
constructor(readonly node: GraphNode, readonly def: NodeDef) {
constructor(
readonly node: GraphNode,
readonly def: NodeDef,
) {
super();
this.cursor = 'grab';
this.addChild(this.box, this.ports);
@@ -77,9 +104,16 @@ export class NodeView extends Widget {
/** Re-reads params and connections; rebuilds the text lines. `project` resolves asset references (Spawn, Is asset). */
sync(node: GraphNode, graph: Graph, asset: BehaviorAsset | null, project: Project | null): void {
this.paramLines = this.def.params.map((p) => `${p.label}: ${formatParam(p, node.params[p.key] ?? p.default, asset, project)}`);
this.connected = new Set(inlineInputs(this.def).filter((p) => edgesInto(graph, node.id, p.key).length > 0).map((p) => p.key));
const height = TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + this.rowCount() * ROW_HEIGHT + PAD;
this.paramLines = this.def.params.map(
(p) => `${p.label}: ${formatParam(p, node.params[p.key] ?? p.default, asset, project)}`,
);
this.connected = new Set(
inlineInputs(this.def)
.filter((p) => edgesInto(graph, node.id, p.key).length > 0)
.map((p) => p.key),
);
const height =
TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + this.rowCount() * ROW_HEIGHT + PAD;
this.rebuildTexts();
this.resize(NODE_WIDTH, height);
this.position.set(node.x, node.y);
@@ -103,8 +137,12 @@ export class NodeView extends Widget {
/** Every port with its local centre – inputs on the left edge, outputs on the right. */
private *portCentres(): Iterable<{ port: PortDef; isOutput: boolean; x: number; y: number }> {
for (const [list, isOutput] of [[this.def.inputs, false], [this.def.outputs, true]] as const) {
for (let i = 0; i < list.length; i++) yield { port: list[i]!, isOutput, x: isOutput ? this.w : 0, y: this.rowY(i) };
for (const [list, isOutput] of [
[this.def.inputs, false],
[this.def.outputs, true],
] as const) {
for (let i = 0; i < list.length; i++)
yield { port: list[i]!, isOutput, x: isOutput ? this.w : 0, y: this.rowY(i) };
}
}
@@ -119,7 +157,14 @@ export class NodeView extends Widget {
private rebuildTexts(): void {
for (const t of this.texts) t.destroy();
this.texts.length = 0;
const add = (text: string, style: TextStyleToken, x: number, y: number, align: 'left' | 'right' = 'left', maxWidth = NODE_WIDTH - PAD * 2) => {
const add = (
text: string,
style: TextStyleToken,
x: number,
y: number,
align: 'left' | 'right' = 'left',
maxWidth = NODE_WIDTH - PAD * 2,
) => {
const node = new Text({ text: fit(text, style, maxWidth), style: textStyle(style) });
node.position.set(align === 'left' ? x : x - node.width, y);
this.addChild(node);
@@ -141,9 +186,24 @@ export class NodeView extends Widget {
const value = inlineValue(this.node, input);
if (value !== null && value !== '') label += ` ${formatValue(value)}`;
}
add(label, input.type === 'exec' ? 'body' : 'muted', PAD + PORT_RADIUS + 4, rowTop, 'left', NODE_WIDTH / 2 + 20);
add(
label,
input.type === 'exec' ? 'body' : 'muted',
PAD + PORT_RADIUS + 4,
rowTop,
'left',
NODE_WIDTH / 2 + 20,
);
}
if (output) add(output.label, output.type === 'exec' ? 'body' : 'muted', this.w - PAD - PORT_RADIUS - 4, rowTop, 'right', NODE_WIDTH / 2 - 10);
if (output)
add(
output.label,
output.type === 'exec' ? 'body' : 'muted',
this.w - PAD - PORT_RADIUS - 4,
rowTop,
'right',
NODE_WIDTH / 2 - 10,
);
}
}
@@ -153,16 +213,27 @@ export class NodeView extends Widget {
this.box.roundRect(2, 3, this.w, this.h, Theme.radius).fill({ color: 0x000000, alpha: 0.35 });
this.box.roundRect(0, 0, this.w, this.h, Theme.radius).fill(c.node);
this.box.roundRect(0, 0, this.w, TITLE_HEIGHT, Theme.radius).fill(categoryColor(this.def.category));
this.box.rect(0, TITLE_HEIGHT - Theme.radius, this.w, Theme.radius).fill(categoryColor(this.def.category));
this.box
.rect(0, TITLE_HEIGHT - Theme.radius, this.w, Theme.radius)
.fill(categoryColor(this.def.category));
const border = this.hot ? c.hot : this.selected ? c.nodeSelected : c.nodeBorder;
this.box.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: this.selected || this.hot ? 2 : 1, color: border });
this.box
.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius)
.stroke({ width: this.selected || this.hot ? 2 : 1, color: border });
this.ports.clear();
const r = PORT_RADIUS;
for (const { port, x, y } of this.portCentres()) {
if (port.type === 'exec') {
// A pentagon pointing right: the Blueprint-style exec pin.
this.ports.moveTo(x - r, y - r).lineTo(x + 1, y - r).lineTo(x + r + 1, y).lineTo(x + 1, y + r).lineTo(x - r, y + r).closePath().fill(portColor('exec'));
this.ports
.moveTo(x - r, y - r)
.lineTo(x + 1, y - r)
.lineTo(x + r + 1, y)
.lineTo(x + 1, y + r)
.lineTo(x - r, y + r)
.closePath()
.fill(portColor('exec'));
} else {
this.ports.circle(x, y, r).fill(portColor(port.type));
this.ports.circle(x, y, r).stroke({ width: 1, color: c.node });
@@ -172,21 +243,32 @@ export class NodeView extends Widget {
}
/** The value of a setting as shown on the node; references that no longer resolve get a "?". */
function formatParam(param: PropertyDef, value: unknown, asset: BehaviorAsset | null, project: Project | null): string {
function formatParam(
param: PropertyDef,
value: PropertyValue | undefined,
asset: BehaviorAsset | null,
project: Project | null,
): string {
if (value === undefined || value === '') return '—';
if (param.kind === 'key') return keyLabel(String(value));
if (param.kind === 'enum' && param.key === 'asset') {
const target = project ? findAsset(project, String(value)) : undefined;
return target ? target.name : `${value}?`;
}
if (param.kind === 'enum' && param.key === 'variable' && asset && !asset.variables.some((v) => v.key === value)) return `${value}?`;
if (
param.kind === 'enum' &&
param.key === 'variable' &&
asset &&
!asset.variables.some((v) => v.key === value)
)
return `${value}?`;
return formatValue(value);
}
function formatValue(value: unknown): string {
function formatValue(value: Value): string {
if (typeof value === 'number') return Number.isInteger(value) ? String(value) : value.toFixed(2);
if (typeof value === 'boolean') return value ? 'true' : 'false';
return String(value);
return asString(value);
}
/** Truncates with an ellipsis so the text fits `maxWidth`. */
@@ -194,6 +276,7 @@ function fit(text: string, style: TextStyleToken, maxWidth: number): string {
const s = textStyle(style);
if (CanvasTextMetrics.measureText(text, s).width <= maxWidth) return text;
let cut = text;
while (cut.length > 1 && CanvasTextMetrics.measureText(cut + '…', s).width > maxWidth) cut = cut.slice(0, -1);
while (cut.length > 1 && CanvasTextMetrics.measureText(cut + '…', s).width > maxWidth)
cut = cut.slice(0, -1);
return cut + '…';
}
+16 -4
View File
@@ -4,7 +4,12 @@ import { type EditorState, sameSelection, type Selection } from '../state';
import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection';
import type { InspectorHost } from './host';
import { buildNodeSection, nodeSignature, refreshNodeSection } from './nodeSection';
import { buildObjectSection, buildObjectsSection, objectSignature, refreshObjectSection } from './objectSection';
import {
buildObjectSection,
buildObjectsSection,
objectSignature,
refreshObjectSection,
} from './objectSection';
/**
* Shows the properties of the current selection. The rows are generated from the
@@ -33,7 +38,12 @@ export class Inspector extends Panel {
}
private emptyHost(): InspectorHost {
return { state: this.state, body: new VStack({ padding: Theme.padding, gap: Theme.gap }), fields: new Map(), nameField: null };
return {
state: this.state,
body: new VStack({ padding: Theme.padding, gap: Theme.gap }),
fields: new Map(),
nameField: null,
};
}
/**
@@ -80,8 +90,10 @@ export class Inspector extends Panel {
}
if (!s) return;
const ok =
s.kind === 'asset' ? refreshAssetSection(this.host, s.assetId)
: s.kind === 'node' ? refreshNodeSection(this.host, s.assetId, s.nodeId)
s.kind === 'asset'
? refreshAssetSection(this.host, s.assetId)
: s.kind === 'node'
? refreshNodeSection(this.host, s.assetId, s.nodeId)
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.sceneId, s.objectId);
if (!ok) this.rebuild();
}
+57 -12
View File
@@ -1,22 +1,50 @@
import { RenameAssetCommand, ResetAssetPropertyCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands';
import {
RenameAssetCommand,
ResetAssetPropertyCommand,
SetAssetPropertyCommand,
} from '../../core/commands/assetCommands';
import { nextVariableName } from '../../core/commands/naming';
import { AddVariableCommand, RemoveVariableCommand, RenameVariableCommand } from '../../core/commands/variableCommands';
import {
AddVariableCommand,
RemoveVariableCommand,
RenameVariableCommand,
} from '../../core/commands/variableCommands';
import type { Asset, BehaviorAsset } from '../../core/model/Asset';
import { findAsset, type Project } from '../../core/model/Project';
import { COMMON_PROPS, type PropertyDef, schemaForAsset, VARIABLE_KINDS, type VariableKind } from '../../core/properties/PropertySchema';
import {
COMMON_PROPS,
type PropertyDef,
schemaForAsset,
VARIABLE_KINDS,
type VariableKind,
} from '../../core/properties/PropertySchema';
import { isModified, resolveAssetProps } from '../../core/properties/resolve';
import { Button, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui';
import { createField, createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory';
import { addNote, addTitle, type InspectorHost } from './host';
import { previewingHandlers } from './previewHandlers';
const VARIABLE_KIND_LABELS: Record<VariableKind, string> = { number: 'Number', boolean: 'Boolean', text: 'Text' };
const VARIABLE_KIND_MARKS: Record<VariableKind, string> = { number: '#', boolean: '✓', text: 'Aa' };
const VARIABLE_KIND_LABELS: Record<VariableKind, string> = {
number: 'Number',
boolean: 'Boolean',
text: 'Text',
};
// Keyed by the wide `PropertyDef['kind']` on purpose: `variables` is a `PropertyDef[]`, so a
// document could carry a kind that is not a VariableKind – that renders as '?' rather than blank.
const VARIABLE_KIND_MARKS: Partial<Record<PropertyDef['kind'], string>> = {
number: '#',
boolean: '✓',
text: 'Aa',
};
/** Structure-defining facts of an asset selection: the schema keys and kinds. */
export function assetSignature(project: Project, assetId: string): string {
const asset = findAsset(project, assetId);
return asset ? schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',') : 'missing';
return asset
? schemaForAsset(asset)
.map((d) => `${d.key}:${d.kind}`)
.join(',')
: 'missing';
}
/** Name, properties (asset values marked when they differ from the default) and, for behaviors, the variables. */
@@ -58,11 +86,21 @@ export function refreshAssetSection(host: InspectorHost, assetId: string): boole
function buildProps(host: InspectorHost, asset: Asset, schema: readonly PropertyDef[]): void {
const values = resolveAssetProps(asset);
for (const def of schema) {
const row = createFieldRow(def, values[def.key] ?? def.default, assetHandlers(host, asset.id, def), () => {
const row = createFieldRow(
def,
values[def.key] ?? def.default,
assetHandlers(host, asset.id, def),
() => {
const target = findAsset(host.state.project, asset.id);
if (!target || !isModified(target, def.key)) return [];
return [{ label: 'Reset to default', onSelect: () => host.state.execute(new ResetAssetPropertyCommand(asset.id, def.key)) }];
});
return [
{
label: 'Reset to default',
onSelect: () => host.state.execute(new ResetAssetPropertyCommand(asset.id, def.key)),
},
];
},
);
row.setModified(isModified(asset, def.key));
host.fields.set(def.key, row);
host.body.add(row.row);
@@ -90,7 +128,8 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
label: VARIABLE_KIND_LABELS[kind],
onSelect: () => {
const current = findAsset(host.state.project, asset.id);
if (current?.type === 'behavior') host.state.execute(new AddVariableCommand(asset.id, nextVariableName(current), kind));
if (current?.type === 'behavior')
host.state.execute(new AddVariableCommand(asset.id, nextVariableName(current), kind));
},
}));
new Menu(items).openBelow(add);
@@ -103,7 +142,7 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
for (const def of asset.variables) {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
const mark = new Label(VARIABLE_KIND_MARKS[def.kind as VariableKind] ?? '?', { style: 'small', align: 'center' });
const mark = new Label(VARIABLE_KIND_MARKS[def.kind] ?? '?', { style: 'small', align: 'center' });
mark.minWidth = 18;
row.add(mark);
row.add(
@@ -117,7 +156,13 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
const field = createField(def, values[def.key] ?? def.default, assetHandlers(host, asset.id, def));
row.add(field.widget, field.grow ? 1 : 0);
if (!field.grow) row.add(new Spacer(), 1);
row.add(new Button('×', { variant: 'ghost', width: Theme.controlHeight, onClick: () => host.state.execute(new RemoveVariableCommand(asset.id, def.key)) }));
row.add(
new Button('×', {
variant: 'ghost',
width: Theme.controlHeight,
onClick: () => host.state.execute(new RemoveVariableCommand(asset.id, def.key)),
}),
);
host.fields.set(def.key, { row, setValue: field.setValue, setModified: () => {} });
host.body.add(row);
}
+30 -5
View File
@@ -1,6 +1,19 @@
import type { PropertyValue } from '../../core/model/Properties';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, Checkbox, ColorField, HStack, Label, Menu, NumberField, Spacer, TextField, Theme, ui, type Widget } from '../../ui';
import {
Button,
Checkbox,
ColorField,
HStack,
Label,
Menu,
NumberField,
Spacer,
TextField,
Theme,
ui,
type Widget,
} from '../../ui';
export interface FieldHandlers {
/** Live value while dragging/typing – apply as a preview, no undo entry. */
@@ -54,7 +67,13 @@ export function labelledRow(text: string): { row: HStack; label: Label } {
* its source. Which of them exist is asked from `reset`/`apply` on every refresh: no entry
* means no button, one entry means a direct button, several mean a menu (↺ ▾ / ↑ ▾).
*/
export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers: FieldHandlers, reset?: FieldActions, apply?: FieldActions): FieldRow {
export function createFieldRow(
def: PropertyDef,
value: PropertyValue,
handlers: FieldHandlers,
reset?: FieldActions,
apply?: FieldActions,
): FieldRow {
const { row, label } = labelledRow(def.label);
const field = createField(def, value, handlers);
row.add(field.widget, field.grow ? 1 : 0);
@@ -141,10 +160,13 @@ export function createField(def: PropertyDef, value: PropertyValue, handlers: Fi
return { widget: field, grow: false, setValue: (v) => field.setChecked(Boolean(v)) };
}
case 'enum': {
const labelOf = (v: PropertyValue) => def.options.find((o) => o.value === String(v))?.label ?? (String(v) || '—');
const labelOf = (v: PropertyValue) =>
def.options.find((o) => o.value === String(v))?.label ?? (String(v) || '—');
const button = new Button(`${labelOf(value)} ▾`);
button.onClick = () => {
new Menu(def.options.map((o) => ({ label: o.label, onSelect: () => handlers.onChange(o.value) }))).openBelow(button);
new Menu(
def.options.map((o) => ({ label: o.label, onSelect: () => handlers.onChange(o.value) })),
).openBelow(button);
};
return { widget: button, grow: true, setValue: (v) => button.setText(`${labelOf(v)} ▾`) };
}
@@ -160,7 +182,10 @@ class KeyCaptureButton extends Button {
private key: string;
private capturing = false;
constructor(key: string, private readonly onCapture: (key: string) => void) {
constructor(
key: string,
private readonly onCapture: (key: string) => void,
) {
super(keyLabel(key));
this.key = key;
this.onClick = () => {
+31 -6
View File
@@ -16,7 +16,11 @@ export function nodeSignature(project: Project, assetId: string, nodeId: string)
const node = asset.graph.nodes.find((n) => n.id === nodeId);
if (!node) return 'missing';
const def = nodeDef(node.type);
const connected = def ? inlineInputs(def).filter((p) => edgesInto(asset.graph, node.id, p.key).length > 0).map((p) => p.key) : [];
const connected = def
? inlineInputs(def)
.filter((p) => edgesInto(asset.graph, node.id, p.key).length > 0)
.map((p) => p.key)
: [];
return `${node.type}|${connected.join(',')}|${asset.variables.map((v) => v.key).join(',')}`;
}
@@ -35,7 +39,11 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
if (def.params.length > 0) addTitle(host, 'SETTINGS');
for (const param of def.params) {
const effective = resolveParamDef(host.state.project, param, asset);
const row = createFieldRow(effective, node.params[param.key] ?? effective.default, paramHandlers(host, assetId, nodeId, param.key));
const row = createFieldRow(
effective,
node.params[param.key] ?? effective.default,
paramHandlers(host, assetId, nodeId, param.key),
);
host.fields.set(`param:${param.key}`, row);
host.body.add(row.row);
}
@@ -44,13 +52,22 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
if (free.length > 0) addTitle(host, 'INPUTS (unconnected)');
for (const port of free) {
const inline = inlineInputDef(port);
const row = createFieldRow(inline, node.params[port.key] ?? inline.default, paramHandlers(host, assetId, nodeId, port.key));
const row = createFieldRow(
inline,
node.params[port.key] ?? inline.default,
paramHandlers(host, assetId, nodeId, port.key),
);
host.fields.set(`input:${port.key}`, row);
host.body.add(row.row);
}
addGap(host);
host.body.add(new Button('Delete node', { variant: 'danger', onClick: () => host.state.execute(new RemoveNodeCommand(assetId, nodeId)) }));
host.body.add(
new Button('Delete node', {
variant: 'danger',
onClick: () => host.state.execute(new RemoveNodeCommand(assetId, nodeId)),
}),
);
}
/** Pushes the node's current params into the rows. False when the node is gone (rebuild). */
@@ -60,7 +77,10 @@ export function refreshNodeSection(host: InspectorHost, assetId: string, nodeId:
if (!node || !def) return false;
for (const [key, row] of host.fields) {
const [scope, name] = key.split(':') as ['param' | 'input', string];
const fallback = scope === 'param' ? def.params.find((p) => p.key === name)?.default : inlineInputDef(def.inputs.find((p) => p.key === name)!).default;
const fallback =
scope === 'param'
? def.params.find((p) => p.key === name)?.default
: inlineInputDef(def.inputs.find((p) => p.key === name)!).default;
row.setValue(node.params[name] ?? fallback ?? '');
}
return true;
@@ -76,7 +96,12 @@ function resolveParamDef(project: Project, param: PropertyDef, asset: Asset): Pr
return { ...param, options: asset.variables.map((v) => ({ value: v.key, label: v.key })) };
}
if (param.key === 'asset') {
return { ...param, options: project.assets.filter((a) => isPlaceableType(a.type)).map((a) => ({ value: a.id, label: `${a.name} (${a.type})` })) };
return {
...param,
options: project.assets
.filter((a) => isPlaceableType(a.type))
.map((a) => ({ value: a.id, label: `${a.name} (${a.type})` })),
};
}
return param;
}
+173 -29
View File
@@ -20,8 +20,17 @@ import { findObject, isSelfOrAncestor, treeOrder } from '../../core/model/hierar
import type { Axis, PropertyValue } from '../../core/model/Properties';
import { findAsset, findScene, type Project } from '../../core/model/Project';
import type { SceneObject } from '../../core/model/SceneObject';
import { COMMON_PROPS, isCommonProp, type PropertyDef, schemaForAsset } from '../../core/properties/PropertySchema';
import { deviatesFromAsset, deviatesFromTemplate, resolveSceneObjectProps } from '../../core/properties/resolve';
import {
COMMON_PROPS,
isCommonProp,
type PropertyDef,
schemaForAsset,
} from '../../core/properties/PropertySchema';
import {
deviatesFromAsset,
deviatesFromTemplate,
resolveSceneObjectProps,
} from '../../core/properties/resolve';
import { Button, Menu, type MenuItem, TextField } from '../../ui';
import { addCollider, createContainer, removeObjects } from '../actions';
import { createFieldRow, type FieldActions, type FieldRow, labelledRow } from './fieldFactory';
@@ -34,13 +43,23 @@ const OFFSET_AXES = ['x', 'y'] as const;
const offsetKey = (axis: 'x' | 'y') => `editorOffset${axis.toUpperCase()}`;
/** Structure-defining facts of an object selection: parent, template link and schema. */
export function objectSignature(project: Project, sceneId: string, objectIds: readonly string[], objectId: string): string {
export function objectSignature(
project: Project,
sceneId: string,
objectIds: readonly string[],
objectId: string,
): string {
if (objectIds.length > 1) return `multi|${objectIds.join(',')}`;
const scene = findScene(project, sceneId);
const object = scene && findObject(scene, objectId);
const asset = object && findAsset(project, object.assetId);
if (!object || !asset) return 'missing';
return `${object.parentId}|${object.templateId}|` + schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',');
return (
`${object.parentId}|${object.templateId}|` +
schemaForAsset(asset)
.map((d) => `${d.key}:${d.kind}`)
.join(',')
);
}
/** Several objects: what they are, and the actions that apply to all of them at once. */
@@ -57,11 +76,18 @@ export function buildObjectsSection(host: InspectorHost, sceneId: string, object
addNote(host, 'Ctrl-click adds or removes objects.', 'small');
addNote(host, 'Dragging one in the scene moves all of them.', 'small');
addGap(host);
host.body.add(new Button('Create container', { onClick: () => createContainer(host.state, sceneId, objectIds) }));
host.body.add(
new Button('Create container', { onClick: () => createContainer(host.state, sceneId, objectIds) }),
);
addNote(host, 'Groups the selection into a reusable container', 'small');
addNote(host, 'asset placed at its centre (Ctrl+G).', 'small');
addGap(host);
host.body.add(new Button('Remove from scene', { variant: 'danger', onClick: () => removeObjects(host.state, sceneId, objectIds) }));
host.body.add(
new Button('Remove from scene', {
variant: 'danger',
onClick: () => removeObjects(host.state, sceneId, objectIds),
}),
);
}
/** One object: name, asset, parent, container link, the switch, position, editor offset, properties, collider button. */
@@ -81,7 +107,13 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
host.body.add(nameRow.row);
const assetRow = labelledRow('Asset');
assetRow.row.add(new Button(asset.name, { variant: 'ghost', onClick: () => host.state.select({ kind: 'asset', assetId: asset.id }) }), 1);
assetRow.row.add(
new Button(asset.name, {
variant: 'ghost',
onClick: () => host.state.select({ kind: 'asset', assetId: asset.id }),
}),
1,
);
host.body.add(assetRow.row);
host.body.add(parentRow(host, scene, object));
@@ -90,12 +122,19 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
const link = templateLinkOf(host.state.project, scene, object);
if (link) {
const linkRow = labelledRow('Part of');
linkRow.row.add(new Button(link.asset.name, { variant: 'ghost', onClick: () => host.state.select({ kind: 'asset', assetId: link.asset.id }) }), 1);
linkRow.row.add(
new Button(link.asset.name, {
variant: 'ghost',
onClick: () => host.state.select({ kind: 'asset', assetId: link.asset.id }),
}),
1,
);
host.body.add(linkRow.row);
}
// The master switch, right where it is looked for: above everything type-specific.
for (const def of COMMON_PROPS) propertyRow(host, sceneId, objectId, def, resolveSceneObjectProps(asset, object));
for (const def of COMMON_PROPS)
propertyRow(host, sceneId, objectId, def, resolveSceneObjectProps(asset, object));
addTitle(host, object.parentId ? 'POSITION (LOCAL TO PARENT)' : 'POSITION');
for (const axis of POSITION_AXES) {
@@ -107,8 +146,26 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
read: (o) => o.position,
write: (o, v) => (o.position = v),
command: (v) => new MoveSceneObjectCommand(sceneId, objectId, v),
reset: () => (moved() ? [{ label: 'Reset to container', onSelect: () => host.state.execute(new RevertPositionToTemplateCommand(sceneId, objectId, axis)) }] : []),
apply: () => (moved() ? [{ label: 'Apply to container', onSelect: () => host.state.execute(new ApplyPositionToContainerCommand(sceneId, objectId, axis)) }] : []),
reset: () =>
moved()
? [
{
label: 'Reset to container',
onSelect: () =>
host.state.execute(new RevertPositionToTemplateCommand(sceneId, objectId, axis)),
},
]
: [],
apply: () =>
moved()
? [
{
label: 'Apply to container',
onSelect: () =>
host.state.execute(new ApplyPositionToContainerCommand(sceneId, objectId, axis)),
},
]
: [],
});
}
@@ -126,7 +183,15 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
reset: () => {
const current = currentObject(host.state, sceneId, objectId);
if (!current || current.editorOffset[axis] === 0) return [];
return [{ label: 'Reset to 0', onSelect: () => host.state.execute(new SetEditorOffsetCommand(sceneId, objectId, { ...current.editorOffset, [axis]: 0 })) }];
return [
{
label: 'Reset to 0',
onSelect: () =>
host.state.execute(
new SetEditorOffsetCommand(sceneId, objectId, { ...current.editorOffset, [axis]: 0 }),
),
},
];
},
});
}
@@ -135,7 +200,8 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
const schema = schemaForAsset(asset);
const values = resolveSceneObjectProps(asset, object);
const own = schema.filter((def) => !isCommonProp(def.key));
if (own.length > 0 || asset.type === 'behavior') addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES');
if (own.length > 0 || asset.type === 'behavior')
addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES');
if (own.length === 0 && asset.type === 'behavior') addNote(host, 'The behavior declares no variables.');
for (const def of own) propertyRow(host, sceneId, objectId, def, values);
markObjectRows(host, object, asset, link);
@@ -149,7 +215,12 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
if (asset.type !== 'collider') {
addGap(host);
host.body.add(new Button('+ Add collider', { variant: 'ghost', onClick: () => addCollider(host.state, sceneId, objectId) }));
host.body.add(
new Button('+ Add collider', {
variant: 'ghost',
onClick: () => addCollider(host.state, sceneId, objectId),
}),
);
}
}
@@ -184,7 +255,13 @@ interface ComponentSpec<V extends object> {
* The row edits one axis, the command carries the whole vector: the other components come
* from the target after the preview was taken back, so they are the pre-preview ones.
*/
function componentRow<V extends object>(host: InspectorHost, sceneId: string, objectId: string, value: number, spec: ComponentSpec<V>): void {
function componentRow<V extends object>(
host: InspectorHost,
sceneId: string,
objectId: string,
value: number,
spec: ComponentSpec<V>,
): void {
const withAxis = (vector: V, next: PropertyValue): V => ({ ...vector, [spec.axis]: Number(next) });
const row = createFieldRow(
{ key: spec.key, label: spec.label, kind: 'number', default: 0, step: 1 },
@@ -214,7 +291,13 @@ function componentRow<V extends object>(host: InspectorHost, sceneId: string, ob
* override is what makes the object follow the asset again: the template is only read when
* the container is placed, never at draw time.
*/
function propertyRow(host: InspectorHost, sceneId: string, objectId: string, def: PropertyDef, values: Record<string, PropertyValue>): FieldRow {
function propertyRow(
host: InspectorHost,
sceneId: string,
objectId: string,
def: PropertyDef,
values: Record<string, PropertyValue>,
): FieldRow {
const bases = () => deviations(host.state, sceneId, objectId, def.key);
const row = createFieldRow(
def,
@@ -229,15 +312,46 @@ function propertyRow(host: InspectorHost, sceneId: string, objectId: string, def
() => {
const { template, asset } = bases();
return [
...(template ? [{ label: 'Reset to container', onSelect: () => host.state.execute(new RevertOverrideToTemplateCommand(sceneId, objectId, def.key)) }] : []),
...(asset ? [{ label: 'Reset to asset', onSelect: () => host.state.execute(new ClearOverrideCommand(sceneId, objectId, def.key)) }] : []),
...(template
? [
{
label: 'Reset to container',
onSelect: () =>
host.state.execute(new RevertOverrideToTemplateCommand(sceneId, objectId, def.key)),
},
]
: []),
...(asset
? [
{
label: 'Reset to asset',
onSelect: () => host.state.execute(new ClearOverrideCommand(sceneId, objectId, def.key)),
},
]
: []),
];
},
() => {
const { template, asset } = bases();
return [
...(template ? [{ label: 'Apply to container', onSelect: () => host.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)) }] : []),
...(asset ? [{ label: 'Apply to asset', onSelect: () => host.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)) }] : []),
...(template
? [
{
label: 'Apply to container',
onSelect: () =>
host.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)),
},
]
: []),
...(asset
? [
{
label: 'Apply to asset',
onSelect: () =>
host.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)),
},
]
: []),
];
},
);
@@ -256,11 +370,16 @@ function parentRow(host: InspectorHost, scene: SceneAsset, object: SceneObject)
const parent = findObject(scene, object.parentId);
const button = new Button(`${parent ? nameOf(parent) : 'None'} ▾`);
button.onClick = () => {
const items: MenuItem[] = [{ label: 'None', onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, '')) }];
const items: MenuItem[] = [
{ label: 'None', onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, '')) },
];
for (const { object: candidate, depth } of treeOrder(scene)) {
if (isSelfOrAncestor(scene, object.id, candidate.id)) continue;
const name = `${' '.repeat(depth)}${nameOf(candidate)}`;
items.push({ label: candidate.id === object.parentId ? `● ${name}` : name, onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, candidate.id)) });
items.push({
label: candidate.id === object.parentId ? `● ${name}` : name,
onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, candidate.id)),
});
}
new Menu(items).openBelow(button);
};
@@ -273,11 +392,22 @@ function parentRow(host: InspectorHost, scene: SceneAsset, object: SceneObject)
* the template (position per axis, overrides), for any other object one from the asset. The
* editor offset has one base only – no offset at all.
*/
function markObjectRows(host: InspectorHost, object: SceneObject, asset: Asset, link: TemplateLink | undefined): void {
for (const axis of POSITION_AXES) host.fields.get(axis)?.setModified(Boolean(link) && object.position[axis] !== link!.template.position[axis]);
for (const axis of OFFSET_AXES) host.fields.get(offsetKey(axis))?.setModified(object.editorOffset[axis] !== 0);
function markObjectRows(
host: InspectorHost,
object: SceneObject,
asset: Asset,
link: TemplateLink | undefined,
): void {
for (const axis of POSITION_AXES)
host.fields
.get(axis)
?.setModified(Boolean(link) && object.position[axis] !== link!.template.position[axis]);
for (const axis of OFFSET_AXES)
host.fields.get(offsetKey(axis))?.setModified(object.editorOffset[axis] !== 0);
for (const def of schemaForAsset(asset)) {
const deviates = link ? deviatesFromTemplate(object, link.template, def.key) : deviatesFromAsset(object, asset, def.key);
const deviates = link
? deviatesFromTemplate(object, link.template, def.key)
: deviatesFromAsset(object, asset, def.key);
host.fields.get(def.key)?.setModified(deviates);
}
}
@@ -287,7 +417,12 @@ function markObjectRows(host: InspectorHost, object: SceneObject, asset: Asset,
* differs: the template of the container the object belongs to, and the asset behind it.
* Evaluated on demand so a menu never acts on a stale snapshot.
*/
function deviations(state: InspectorHost['state'], sceneId: string, objectId: string, key: string): { template: boolean; asset: boolean } {
function deviations(
state: InspectorHost['state'],
sceneId: string,
objectId: string,
key: string,
): { template: boolean; asset: boolean } {
const scene = findScene(state.project, sceneId);
const object = scene && findObject(scene, objectId);
const asset = object && findAsset(state.project, object.assetId);
@@ -300,14 +435,23 @@ function deviations(state: InspectorHost['state'], sceneId: string, objectId: st
}
/** Whether the object sits somewhere else on `axis` than its container template says. */
function positionMoved(state: InspectorHost['state'], sceneId: string, objectId: string, axis: Axis): boolean {
function positionMoved(
state: InspectorHost['state'],
sceneId: string,
objectId: string,
axis: Axis,
): boolean {
const scene = findScene(state.project, sceneId);
const object = scene && findObject(scene, objectId);
const link = scene && object && templateLinkOf(state.project, scene, object);
return Boolean(link) && object!.position[axis] !== link!.template.position[axis];
}
function currentObject(state: InspectorHost['state'], sceneId: string, objectId: string): SceneObject | undefined {
function currentObject(
state: InspectorHost['state'],
sceneId: string,
objectId: string,
): SceneObject | undefined {
const scene = findScene(state.project, sceneId);
return scene && findObject(scene, objectId);
}
+4 -1
View File
@@ -27,7 +27,10 @@ export interface PreviewedValue<Target, Model = PropertyValue> {
* value from before the preview is written back first – including "the key did not exist",
* via `remove` – and only then is the command executed.
*/
export function previewingHandlers<Target, Model = PropertyValue>(host: InspectorHost, spec: PreviewedValue<Target, Model>): FieldHandlers {
export function previewingHandlers<Target, Model = PropertyValue>(
host: InspectorHost,
spec: PreviewedValue<Target, Model>,
): FieldHandlers {
const toModel = spec.toModel ?? ((input: PropertyValue) => input as unknown as Model);
let previewed = false;
let before: Model | undefined;
+21 -4
View File
@@ -13,12 +13,21 @@ export class AssetPanel extends Panel {
private readonly list = new ListView();
private readonly scroll = new ScrollContainer();
constructor(private readonly state: EditorState, private readonly placementPoint: () => Vec2) {
constructor(
private readonly state: EditorState,
private readonly placementPoint: () => Vec2,
) {
super({ title: 'Assets', padding: 0, gap: 0 });
this.minWidth = 160;
const add = new Button('+', { variant: 'ghost', width: 28 });
add.onClick = () => new Menu(ASSET_TYPES.map((type) => ({ label: ASSET_TYPE_LABELS[type], onSelect: () => this.createAsset(type) }))).openBelow(add);
add.onClick = () =>
new Menu(
ASSET_TYPES.map((type) => ({
label: ASSET_TYPE_LABELS[type],
onSelect: () => this.createAsset(type),
})),
).openBelow(add);
this.header.add(add);
this.list.emptyText = 'No assets – press +';
@@ -81,9 +90,17 @@ export class AssetPanel extends Panel {
if (!asset) return;
const items: MenuItem[] = [];
if (asset.type === 'scene') {
items.push({ label: 'Set as active scene', onSelect: () => this.state.execute(new SetActiveSceneCommand(id)), disabled: this.state.project.activeSceneId === id });
items.push({
label: 'Set as active scene',
onSelect: () => this.state.execute(new SetActiveSceneCommand(id)),
disabled: this.state.project.activeSceneId === id,
});
} else {
items.push({ label: 'Place in scene', onSelect: () => this.place(id), disabled: !this.state.activeScene });
items.push({
label: 'Place in scene',
onSelect: () => this.place(id),
disabled: !this.state.activeScene,
});
}
items.push({ label: 'Delete…', danger: true, onSelect: () => confirmDeleteAsset(this.state, id) });
new Menu(items).open(x, y);
+27 -6
View File
@@ -49,7 +49,10 @@ export class HierarchyPanel extends Panel {
state.on('project', () => this.refresh());
// A different project brings different objects – start folded again.
state.on('load', () => { this.expanded.clear(); this.refresh(); });
state.on('load', () => {
this.expanded.clear();
this.refresh();
});
state.on('selection', () => (this.revealSelection() ? this.refresh() : this.syncSelection()));
this.refresh();
}
@@ -88,13 +91,31 @@ export class HierarchyPanel extends Panel {
const selected = state.selectedObjectIds.includes(id) ? state.selectedObjectIds : [id];
const many = selected.length > 1;
new Menu([
{ label: 'Select asset', onSelect: () => object && state.select({ kind: 'asset', assetId: object.assetId }) },
{ label: 'Add collider', disabled: !type || type === 'collider' || many, onSelect: () => addCollider(state, scene.id, id) },
{ label: many ? `Create container from ${selected.length} objects` : 'Create container', onSelect: () => createContainer(state, scene.id, selected) },
{ label: 'Unparent (move to top level)', disabled: !object?.parentId || many, onSelect: () => state.execute(new SetParentCommand(scene.id, id, '')) },
{
label: 'Select asset',
onSelect: () => object && state.select({ kind: 'asset', assetId: object.assetId }),
},
{
label: 'Add collider',
disabled: !type || type === 'collider' || many,
onSelect: () => addCollider(state, scene.id, id),
},
{
label: many ? `Create container from ${selected.length} objects` : 'Create container',
onSelect: () => createContainer(state, scene.id, selected),
},
{
label: 'Unparent (move to top level)',
disabled: !object?.parentId || many,
onSelect: () => state.execute(new SetParentCommand(scene.id, id, '')),
},
{ label: 'Expand all', onSelect: () => this.expandAll() },
{ label: 'Collapse all', onSelect: () => this.collapseAll() },
{ label: many ? `Remove ${selected.length} objects from scene` : 'Remove from scene', danger: true, onSelect: () => removeObjects(state, scene.id, selected) },
{
label: many ? `Remove ${selected.length} objects from scene` : 'Remove from scene',
danger: true,
onSelect: () => removeObjects(state, scene.id, selected),
},
]).open(x, y);
}
+13 -3
View File
@@ -34,7 +34,10 @@ export class Toolbar extends HStack {
private readonly notice: Label;
private readonly fps: Label;
constructor(private readonly state: EditorState, actions: ToolbarActions) {
constructor(
private readonly state: EditorState,
actions: ToolbarActions,
) {
super({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
this.minHeight = Theme.controlHeight + Theme.padding * 2;
this.addChildAt(this.bg, 0);
@@ -53,7 +56,10 @@ export class Toolbar extends HStack {
this.add(new Spacer(Theme.padding));
this.add(new Label('Scene', { style: 'muted' }));
this.sceneButton = new Button('—', { width: 160, onClick: () => actions.openSceneMenu(this.sceneButton) });
this.sceneButton = new Button('—', {
width: 160,
onClick: () => actions.openSceneMenu(this.sceneButton),
});
this.add(this.sceneButton);
this.add(new Spacer(Theme.padding));
@@ -63,7 +69,11 @@ export class Toolbar extends HStack {
this.add(this.redoButton);
this.add(new Spacer(), 1);
this.playButton = new Button('▶ Play', { variant: 'primary', width: 110, onClick: () => actions.togglePlay() });
this.playButton = new Button('▶ Play', {
variant: 'primary',
width: 110,
onClick: () => actions.togglePlay(),
});
this.add(this.playButton);
this.add(new Spacer(), 1);
+15 -4
View File
@@ -194,7 +194,10 @@ export class EditorState {
select(selection: Selection): void {
// Test hooks pass the short form `{ kind: 'object', sceneId, objectId }` without `objectIds`.
if (selection?.kind === 'object' && !selection.objectIds) selection = objectSelection(selection.sceneId, [selection.objectId]);
// The type says `objectIds` is always there – untyped callers are exactly why this guard exists.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (selection?.kind === 'object' && !selection.objectIds)
selection = objectSelection(selection.sceneId, [selection.objectId]);
if (sameSelection(this.selection, selection)) return;
this.selection = selection;
this.emit('selection');
@@ -204,7 +207,9 @@ export class EditorState {
toggleObject(sceneId: string, objectId: string): void {
const s = this.selection;
const current = s?.kind === 'object' && s.sceneId === sceneId ? s.objectIds : [];
const next = current.includes(objectId) ? current.filter((id) => id !== objectId) : [...current, objectId];
const next = current.includes(objectId)
? current.filter((id) => id !== objectId)
: [...current, objectId];
this.select(objectSelection(sceneId, next, current.includes(objectId) ? undefined : objectId));
}
@@ -227,7 +232,8 @@ export class EditorState {
const asset = findAsset(this.project, s.assetId);
// Fall back to the behavior asset so the graph stays open when its node vanished (undo).
if (!asset || asset.type !== 'behavior') this.select(null);
else if (!asset.graph.nodes.some((n) => n.id === s.nodeId)) this.select({ kind: 'asset', assetId: s.assetId });
else if (!asset.graph.nodes.some((n) => n.id === s.nodeId))
this.select({ kind: 'asset', assetId: s.assetId });
}
}
@@ -262,7 +268,12 @@ export function sameSelection(a: Selection, b: Selection): boolean {
if (!a || !b || a.kind !== b.kind) return false;
if (a.kind === 'asset' && b.kind === 'asset') return a.assetId === b.assetId;
if (a.kind === 'object' && b.kind === 'object') {
return a.sceneId === b.sceneId && a.objectId === b.objectId && a.objectIds.length === b.objectIds.length && a.objectIds.every((id, i) => id === b.objectIds[i]);
return (
a.sceneId === b.sceneId &&
a.objectId === b.objectId &&
a.objectIds.length === b.objectIds.length &&
a.objectIds.every((id, i) => id === b.objectIds[i])
);
}
if (a.kind === 'node' && b.kind === 'node') return a.assetId === b.assetId && a.nodeId === b.nodeId;
return false;
+4 -1
View File
@@ -35,7 +35,10 @@ async function main(): Promise<void> {
});
}
window.addEventListener('error', (event) => console.log('[uncaught]', event.error?.stack ?? event.message));
window.addEventListener('error', (event) => {
const error: unknown = event.error;
console.error('[uncaught]', error instanceof Error ? error.stack : event.message);
});
main().catch((error) => {
console.error(error);
+26 -13
View File
@@ -13,7 +13,7 @@ import {
ScrollContainer,
TextField,
Theme,
VStack,
type VStack,
toHex,
} from './ui';
@@ -33,24 +33,32 @@ export function mountPlayground(app: Application, root: VStack): void {
// Column 1: inputs
const inputs = new Panel({ title: 'Inputs' });
inputs.content.add(new Label('Text field'));
inputs.content.add(new TextField('Crate', {
inputs.content.add(
new TextField('Crate', {
placeholder: 'Name',
onInput: (v) => log(`text input: ${v}`),
onChange: (v) => log(`text change: ${v}`),
}));
}),
);
inputs.content.add(new Label('Number field (drag to scrub)'));
inputs.content.add(new NumberField(100, {
inputs.content.add(
new NumberField(100, {
min: 1,
step: 1,
onInput: (v) => log(`number input: ${v}`),
onChange: (v) => log(`number change: ${v}`),
}));
inputs.content.add(new NumberField(1, { min: 0.1, max: 10, step: 0.1, onChange: (v) => log(`zoom change: ${v}`) }));
}),
);
inputs.content.add(
new NumberField(1, { min: 0.1, max: 10, step: 0.1, onChange: (v) => log(`zoom change: ${v}`) }),
);
inputs.content.add(new Label('Color field'));
inputs.content.add(new ColorField(0xe0553f, {
inputs.content.add(
new ColorField(0xe0553f, {
onInput: (c) => log(`color input: ${toHex(c)}`),
onChange: (c) => log(`color change: ${toHex(c)}`),
}));
}),
);
const checkRow = new HStack({ gap: Theme.gap, align: 'center' });
const checkbox = new Checkbox(true);
checkbox.onChange = (v) => log(`checkbox: ${v}`);
@@ -83,25 +91,30 @@ export function mountPlayground(app: Application, root: VStack): void {
]).openBelow(menuButton);
};
actions.content.add(menuButton);
actions.content.add(new Button('Open dialog…', {
onClick: () => new Dialog('Delete asset?', 'Every placed instance is removed as well.', [
actions.content.add(
new Button('Open dialog…', {
onClick: () =>
new Dialog('Delete asset?', 'Every placed instance is removed as well.', [
{ label: 'Cancel' },
{ label: 'Delete', danger: true, onClick: () => log('dialog: deleted') },
]).openCentered(),
}));
}),
);
row.add(actions, 1);
// Column 3: list in a scroll container
const listPanel = new Panel({ title: 'List (scroll)', padding: 0 });
const scroll = new ScrollContainer();
const list = new ListView();
list.setItems(Array.from({ length: 40 }, (_, i) => ({
list.setItems(
Array.from({ length: 40 }, (_, i) => ({
id: `item-${i}`,
label: `Item ${i + 1}${i % 7 === 0 ? ' with a rather long name that must truncate' : ''}`,
hint: i % 3 === 0 ? 'sprite' : i % 3 === 1 ? 'camera' : 'scene',
swatch: i % 3 === 0 ? (0x3fa0e0 + i * 0x040201) & 0xffffff : undefined,
depth: i % 5 === 4 ? 1 : 0,
})));
})),
);
list.onSelect = (id) => log(`list select: ${id}`);
list.onActivate = (id) => log(`list activate: ${id}`);
list.onContextMenu = (id, x, y) =>
+52 -12
View File
@@ -33,7 +33,14 @@ export class World {
* Mouse state in world units, fed by the game view (the only place with the camera
* transform). `pressed`/`released` last for one tick, like the keyboard edges.
*/
readonly pointer: PointerState = { x: 0, y: 0, inside: false, down: false, pressed: false, released: false };
readonly pointer: PointerState = {
x: 0,
y: 0,
inside: false,
down: false,
pressed: false,
released: false,
};
/** Messages logged by behaviors (Log node), newest last. */
readonly log: string[] = [];
private readonly systems: System[] = [];
@@ -41,7 +48,10 @@ export class World {
/** Set when `spawn` added an object; reported (and cleared) by the next `syncToModel`. */
private structureChanged = false;
private constructor(readonly project: Project, readonly sceneId: string) {}
private constructor(
readonly project: Project,
readonly sceneId: string,
) {}
/** Builds a world from the given scene. Throws when the scene does not exist. */
static instantiate(project: Project, sceneId: string): World {
@@ -75,11 +85,12 @@ export class World {
if (!asset || !isPlaceableType(asset.type)) continue;
keep.add(this.materialize(object, asset));
}
for (let i = this.entities.length - 1; i >= 0; i--) if (!keep.has(this.entities[i]!)) this.entities.splice(i, 1);
for (let i = this.entities.length - 1; i >= 0; i--)
if (!keep.has(this.entities[i]!)) this.entities.splice(i, 1);
// Link parents once every entity exists (storage order is not guaranteed to be parent-first).
for (const entity of this.entities) {
const object = findObject(scene, entity.objectId);
entity.parent = object?.parentId ? this.findByObjectId(object.parentId) ?? null : null;
entity.parent = object?.parentId ? (this.findByObjectId(object.parentId) ?? null) : null;
}
}
@@ -87,7 +98,18 @@ export class World {
private materialize(object: SceneObject, asset: Asset): Entity {
let entity = this.findByObjectId(object.id);
if (!entity) {
entity = { id: `e-${object.id}`, objectId: object.id, assetId: asset.id, type: asset.type, name: '', x: 0, y: 0, z: 0, props: {}, parent: null };
entity = {
id: `e-${object.id}`,
objectId: object.id,
assetId: asset.id,
type: asset.type,
name: '',
x: 0,
y: 0,
z: 0,
props: {},
parent: null,
};
this.entities.push(entity);
}
entity.assetId = asset.id;
@@ -109,14 +131,21 @@ export class World {
* during this tick already). Position is local to `parent`, world when there is none.
* Returns null for unknown or unplaceable assets.
*/
spawn(assetId: string, position: { x: number; y: number; z: number }, parent: Entity | null): Entity | null {
spawn(
assetId: string,
position: { x: number; y: number; z: number },
parent: Entity | null,
): Entity | null {
const scene = findScene(this.project, this.sceneId);
const asset = findAsset(this.project, assetId);
if (!scene || !asset || !isPlaceableType(asset.type)) return null;
const parentEntity = parent && this.entities.includes(parent) ? parent : null;
const object = createSceneObject(newId(), asset.id, position, parentEntity?.objectId ?? '');
// A container brings its content along (parented below it, in template order).
const created = [object, ...(asset.type === 'container' ? instantiateContainer(this.project, asset, object.id) : [])];
const created = [
object,
...(asset.type === 'container' ? instantiateContainer(this.project, asset, object.id) : []),
];
scene.objects.push(...created);
const entity = this.materialize(object, asset);
entity.parent = parentEntity;
@@ -152,9 +181,11 @@ export class World {
const asset = object && findAsset(this.project, entity.assetId);
if (!object || !asset) continue;
const p = object.position;
if (p.x !== entity.x || p.y !== entity.y || p.z !== entity.z) object.position = { x: entity.x, y: entity.y, z: entity.z };
if (p.x !== entity.x || p.y !== entity.y || p.z !== entity.z)
object.position = { x: entity.x, y: entity.y, z: entity.z };
const base = resolveSceneObjectProps(asset, object);
for (const [key, value] of Object.entries(entity.props)) if (key in base && base[key] !== value) object.overrides[key] = value;
for (const [key, value] of Object.entries(entity.props))
if (key in base && base[key] !== value) object.overrides[key] = value;
}
return spawned || scene.objects.length !== before;
}
@@ -210,8 +241,13 @@ export class World {
const point = { x, y };
const parts = [entity, ...this.descendantsOf(entity)].filter((e) => isHandleEnabled(e));
const colliders = parts.filter((e) => e.type === 'collider');
if (colliders.length > 0) return colliders.some((c) => shapeContains(colliderShape(c.props, worldPositionOf(c)), point));
return parts.some((e) => (e.type === 'sprite' || e.type === 'text') && footprintContains(e.type, e.props, worldPositionOf(e), point));
if (colliders.length > 0)
return colliders.some((c) => shapeContains(colliderShape(c.props, worldPositionOf(c)), point));
return parts.some(
(e) =>
(e.type === 'sprite' || e.type === 'text') &&
footprintContains(e.type, e.props, worldPositionOf(e), point),
);
}
childrenOf(entity: Entity): Entity[] {
@@ -220,7 +256,11 @@ export class World {
/** Everything parented below an entity, at any depth. */
descendantsOf(entity: Entity): Entity[] {
return descendantsBelow(entity, (parent) => this.childrenOf(parent), (child) => child);
return descendantsBelow(
entity,
(parent) => this.childrenOf(parent),
(child) => child,
);
}
findByObjectId(objectId: string): Entity | undefined {
+31 -7
View File
@@ -1,4 +1,11 @@
import { type EntityHandle, GraphInterpreter, isHandleEnabled, type NodeContext, type TraceListener } from '../../core/graph';
import {
type EntityHandle,
GraphInterpreter,
isHandleEnabled,
type NodeContext,
type TraceListener,
} from '../../core/graph';
import type { PropertyBag } from '../../core/model/Properties';
import { findAsset, type Project } from '../../core/model/Project';
import type { Entity } from '../Entity';
import type { World } from '../World';
@@ -56,7 +63,12 @@ export class BehaviorSystem implements System {
private syncInstances(world: World): void {
this.instances = this.instances.filter((i) => world.entities.includes(i.entity));
for (const entity of world.entities) {
if (entity.type !== 'behavior' || !isHandleEnabled(entity) || this.instances.some((i) => i.entity === entity)) continue;
if (
entity.type !== 'behavior' ||
!isHandleEnabled(entity) ||
this.instances.some((i) => i.entity === entity)
)
continue;
const asset = findAsset(this.project, entity.assetId);
if (!asset || asset.type !== 'behavior') continue;
const trace: Trace = { edges: new Map(), nodes: new Map() };
@@ -73,11 +85,13 @@ export class BehaviorSystem implements System {
update(world: World, dt: number): void {
this.syncInstances(world);
const keyOf = (node: { params: Record<string, unknown> }) => String(node.params.key ?? '');
const keyOf = (node: { params: PropertyBag }) => String(node.params.key ?? '');
for (const instance of this.instances) {
if (!world.entities.includes(instance.entity)) continue; // destroyed during play
if (!isHandleEnabled(instance.entity)) continue; // switched off (itself or a parent)
this.run(instance, (i, ctx) => {
this.run(
instance,
(i, ctx) => {
for (const key of this.input.justPressed) i.fire('OnKeyDown', ctx, {}, (n) => keyOf(n) === key);
for (const key of this.input.justReleased) i.fire('OnKeyUp', ctx, {}, (n) => keyOf(n) === key);
for (const key of this.input.heldKeys) i.fire('OnKeyHeld', ctx, { dt }, (n) => keyOf(n) === key);
@@ -100,7 +114,9 @@ export class BehaviorSystem implements System {
i.fire('OnClick', ctx, { x: pointer.x, y: pointer.y });
}
i.fire('OnUpdate', ctx, { dt });
}, dt);
},
dt,
);
}
}
@@ -140,14 +156,20 @@ export class BehaviorSystem implements System {
}
/** Runs graph code for one instance; an exception stops play and is logged, never thrown. */
private run(instance: Instance, body: (interpreter: GraphInterpreter, ctx: NodeContext) => void, dt: number): void {
private run(
instance: Instance,
body: (interpreter: GraphInterpreter, ctx: NodeContext) => void,
dt: number,
): void {
const world = this.world;
if (!world) return;
const ctx = this.context(instance.entity, world, dt);
try {
body(instance.interpreter, ctx);
} catch (error) {
world.log.push(`Error in behavior "${instance.entity.name}": ${error instanceof Error ? error.message : String(error)}`);
world.log.push(
`Error in behavior "${instance.entity.name}": ${error instanceof Error ? error.message : String(error)}`,
);
world.stopRequested = true;
}
}
@@ -184,6 +206,8 @@ export class BehaviorSystem implements System {
},
log: (message) => {
world.log.push(message);
// The `Log` node's whole purpose: the author wants this in the browser console.
// eslint-disable-next-line no-console
console.log(`[behavior ${entity.name}]`, message);
},
spawn: (assetId, position, parent) => world.spawn(assetId, position, resolve(parent) ?? null),
+45 -8
View File
@@ -1,6 +1,13 @@
import { isHandleEnabled, worldPositionOf } from '../../core/graph';
import { parentChain } from '../../core/model/walk';
import { axisCorrection, colliderShape, colliderSize, isTrigger, type PlaneAxis, shapesOverlap } from '../../core/properties/collider';
import {
axisCorrection,
colliderShape,
colliderSize,
isTrigger,
type PlaneAxis,
shapesOverlap,
} from '../../core/properties/collider';
import type { Entity } from '../Entity';
import type { World } from '../World';
import type { System } from './System';
@@ -79,22 +86,52 @@ export class CollisionSystem implements System {
* are split into sub-steps so nothing jumps across a thin wall. Triggers, the entity's own
* colliders and colliders that travel with it (descendants) never block.
*/
moveAndSlide(world: World, entity: Entity, dx: number, dy: number): { blockedX: boolean; blockedY: boolean } {
moveAndSlide(
world: World,
entity: Entity,
dx: number,
dy: number,
): { blockedX: boolean; blockedY: boolean } {
const own = collidersOf(world, entity).filter((c) => !isTrigger(c.props) && isHandleEnabled(c));
const obstacles = own.length === 0 ? [] : world.entities.filter((c) => c.type === 'collider' && !isTrigger(c.props) && isHandleEnabled(c) && !travelsWith(c, entity));
return { blockedX: this.slideAxis(entity, own, obstacles, 'x', dx), blockedY: this.slideAxis(entity, own, obstacles, 'y', dy) };
const obstacles =
own.length === 0
? []
: world.entities.filter(
(c) =>
c.type === 'collider' && !isTrigger(c.props) && isHandleEnabled(c) && !travelsWith(c, entity),
);
return {
blockedX: this.slideAxis(entity, own, obstacles, 'x', dx),
blockedY: this.slideAxis(entity, own, obstacles, 'y', dy),
};
}
private slideAxis(entity: Entity, own: Entity[], obstacles: Entity[], axis: PlaneAxis, delta: number): boolean {
private slideAxis(
entity: Entity,
own: Entity[],
obstacles: Entity[],
axis: PlaneAxis,
delta: number,
): boolean {
if (delta === 0 || own.length === 0) return false;
// Long steps are split so a fast mover cannot jump across a thin obstacle in one frame.
const steps = Math.min(MAX_STEPS, Math.max(1, Math.ceil(Math.abs(delta) / stepLimit(own, obstacles, axis))));
for (let i = 0; i < steps; i++) if (this.stepAxis(entity, own, obstacles, axis, delta / steps)) return true;
const steps = Math.min(
MAX_STEPS,
Math.max(1, Math.ceil(Math.abs(delta) / stepLimit(own, obstacles, axis))),
);
for (let i = 0; i < steps; i++)
if (this.stepAxis(entity, own, obstacles, axis, delta / steps)) return true;
return false;
}
/** One indivisible move along `axis`; returns whether an obstacle stopped it. */
private stepAxis(entity: Entity, own: Entity[], obstacles: Entity[], axis: PlaneAxis, delta: number): boolean {
private stepAxis(
entity: Entity,
own: Entity[],
obstacles: Entity[],
axis: PlaneAxis,
delta: number,
): boolean {
const dir: 1 | -1 = delta > 0 ? 1 : -1;
// How deep each pair already sits in the other before the step. A step may never deepen
// that, but resolving it is not our job (no jolt, and leaving a wall is always possible).
+18 -7
View File
@@ -8,18 +8,29 @@ export class Input {
private readonly released = new Set<string>();
private readonly onKeyDown = (e: KeyboardEvent) => {
if ((e.target as HTMLElement | null)?.tagName === 'INPUT') return;
if (!this.down.has(e.key)) this.pressed.add(e.key); // ignore auto-repeat
this.down.add(e.key);
this.press(e.key);
if (e.key.startsWith('Arrow') || e.key === ' ') e.preventDefault();
};
private readonly onKeyUp = (e: KeyboardEvent) => {
if (this.down.delete(e.key)) this.released.add(e.key);
};
private readonly onKeyUp = (e: KeyboardEvent) => this.release(e.key);
/** Losing the window releases every key, so nothing stays "held" behind the user's back. */
private readonly onBlur = () => {
private readonly onBlur = () => this.releaseAll();
// The state machine below is what the DOM handlers above drive – and what tests drive
// directly, since a headless run has no window to dispatch key events on.
press(key: string): void {
if (!this.down.has(key)) this.pressed.add(key); // ignore auto-repeat
this.down.add(key);
}
release(key: string): void {
if (this.down.delete(key)) this.released.add(key);
}
releaseAll(): void {
for (const key of this.down) this.released.add(key);
this.down.clear();
};
}
attach(): void {
window.addEventListener('keydown', this.onKeyDown);
+1 -3
View File
@@ -62,9 +62,7 @@ export class FocusManager {
const list = collectFocusables(this.root);
if (list.length === 0) return this.focus(null);
const index = this.focusedWidget ? list.indexOf(this.focusedWidget) : -1;
const next = backwards
? (index <= 0 ? list.length : index) - 1
: (index + 1) % list.length;
const next = backwards ? (index <= 0 ? list.length : index) - 1 : (index + 1) % list.length;
this.focus(list[next] ?? null);
}
+6 -1
View File
@@ -66,7 +66,12 @@ export class KeyboardBridge {
}
/** Start editing: mirror the field's value into the input and grab DOM focus. */
attach(target: TextEditTarget, value: string, selectionStart = value.length, selectionEnd = selectionStart): void {
attach(
target: TextEditTarget,
value: string,
selectionStart = value.length,
selectionEnd = selectionStart,
): void {
this.suppressBlur = true;
this.target = target;
this.input.value = value;
+2 -6
View File
@@ -1,4 +1,4 @@
import type { Application, Container, FederatedPointerEvent } from 'pixi.js';
import type { Application, Container } from 'pixi.js';
import { FocusManager } from './FocusManager';
import { KeyboardBridge } from './KeyboardBridge';
@@ -21,11 +21,7 @@ export function initUI(app: Application, root: Container, overlay: Container): U
context = { app, focus: new FocusManager(root), keyboard: new KeyboardBridge(), overlay };
// Capture phase on the stage runs before any widget handler: a press outside the focused
// widget blurs it (committing text edits) before the press can change selection.
app.stage.addEventListener(
'pointerdown',
(e) => context?.focus.blurIfOutside((e as FederatedPointerEvent).target as Container),
{ capture: true },
);
app.stage.addEventListener('pointerdown', (e) => context?.focus.blurIfOutside(e.target), { capture: true });
// Clicking the canvas must not steal DOM focus from the keyboard bridge input.
app.canvas.addEventListener('mousedown', (e) => e.preventDefault());
app.canvas.addEventListener('contextmenu', (e) => e.preventDefault());
+1 -1
View File
@@ -46,7 +46,7 @@ export function hsvToRgb({ h, s, v }: HSV): number {
const c = v * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = v - c;
let r = 0, g = 0, b = 0;
let r: number, g: number, b: number;
if (h < 60) [r, g, b] = [c, x, 0];
else if (h < 120) [r, g, b] = [x, c, 0];
else if (h < 180) [r, g, b] = [0, c, x];
+4 -1
View File
@@ -17,7 +17,10 @@ export abstract class Stack extends Widget {
padding: number;
align: NonNullable<StackOptions['align']>;
constructor(protected readonly axis: 'x' | 'y', options: StackOptions = {}) {
constructor(
protected readonly axis: 'x' | 'y',
options: StackOptions = {},
) {
super();
this.gap = options.gap ?? 0;
this.padding = options.padding ?? 0;
+19 -5
View File
@@ -34,7 +34,9 @@ export class Button extends Widget {
this.addChild(this.bg, this.labelText);
// Leaving while pressed cancels the click: a release elsewhere must not activate.
this.trackHover(() => { this.pressed = false; });
this.trackHover(() => {
this.pressed = false;
});
this.on('pointerdown', (e: FederatedPointerEvent) => {
if (e.button !== 0) return;
this.pressed = true;
@@ -89,10 +91,22 @@ export class Button extends Widget {
const flat = this.variant === 'default' || this.variant === 'ghost';
let fill: number | null = c.control;
let border: number | null = c.border;
if (this.variant === 'primary') { fill = c.accent; border = null; }
if (this.variant === 'danger') { fill = c.danger; border = null; }
if (this.variant === 'ghost') { fill = null; border = null; }
if (this.toggled) { fill = c.accent; border = null; }
if (this.variant === 'primary') {
fill = c.accent;
border = null;
}
if (this.variant === 'danger') {
fill = c.danger;
border = null;
}
if (this.variant === 'ghost') {
fill = null;
border = null;
}
if (this.toggled) {
fill = c.accent;
border = null;
}
if (this.hovered && !this.toggled && flat) fill = c.controlHover;
if (this.pressed && flat) fill = c.controlActive;
const stroke = this.focused ? c.accent : border;
+5 -1
View File
@@ -54,7 +54,11 @@ export class Checkbox extends Widget {
y,
});
if (this.checked) {
this.box.moveTo(4, y + 8.5).lineTo(7, y + 11.5).lineTo(12.5, y + 5).stroke({ width: 2, color: c.accentText });
this.box
.moveTo(4, y + 8.5)
.lineTo(7, y + 11.5)
.lineTo(12.5, y + 5)
.stroke({ width: 2, color: c.accentText });
}
}
}
+3 -1
View File
@@ -54,7 +54,9 @@ export class ColorPicker extends Popup {
this.applyPointer(local.x, local.y);
});
// The press starts on the body; its release bubbles up to the popup either way.
this.onPointerRelease(() => { this.dragging = null; });
this.onPointerRelease(() => {
this.dragging = null;
});
this.content.add(this.body);
this.renderSv();
}
+3 -1
View File
@@ -22,7 +22,9 @@ export class Dialog extends Popup {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.add(new Spacer(), 1); // pushes the buttons to the right
for (const b of buttons) {
const button = new Button(b.label, { variant: b.primary ? 'primary' : b.danger ? 'danger' : 'default' });
const button = new Button(b.label, {
variant: b.primary ? 'primary' : b.danger ? 'danger' : 'default',
});
button.onClick = this.closeThen(b.onClick);
row.add(button);
}
+28 -6
View File
@@ -20,7 +20,11 @@ export class ListRow extends Widget {
private selected = false;
private listFocused = false;
constructor(readonly item: ListItem, height: number, private readonly chevronSlot: boolean) {
constructor(
readonly item: ListItem,
height: number,
private readonly chevronSlot: boolean,
) {
super();
this.cursor = 'pointer';
this.minHeight = height;
@@ -59,12 +63,17 @@ export class ListRow extends Widget {
const depth = this.item.depth ?? 0;
const indent = this.indent;
this.bg.clear();
if (this.selected) this.bg.rect(0, 0, this.w, this.h).fill(this.listFocused ? c.selection : c.controlActive);
if (this.selected)
this.bg.rect(0, 0, this.w, this.h).fill(this.listFocused ? c.selection : c.controlActive);
else if (this.hovered) this.bg.rect(0, 0, this.w, this.h).fill(c.controlHover);
// Tree guide: a short "└" mark in front of nested rows.
if (depth > 0) {
const gx = indent - 8;
this.bg.moveTo(gx, 0).lineTo(gx, this.h / 2).lineTo(indent - 2, this.h / 2).stroke({ width: 1, color: c.border });
this.bg
.moveTo(gx, 0)
.lineTo(gx, this.h / 2)
.lineTo(indent - 2, this.h / 2)
.stroke({ width: 1, color: c.border });
}
let x = indent;
if (this.chevronSlot) {
@@ -86,13 +95,26 @@ export class ListRow extends Widget {
const hintW = this.hint.text ? this.hint.width + Theme.padding : 0;
this.labelText.text = truncate(this.item.label, this.labelText.style, this.w - x - hintW - Theme.padding);
this.labelText.position.set(x, centreY(this.h, this.labelText.height));
this.hint.position.set(Math.round(this.w - Theme.padding - this.hint.width), centreY(this.h, this.hint.height));
this.hint.position.set(
Math.round(this.w - Theme.padding - this.hint.width),
centreY(this.h, this.hint.height),
);
}
/** ▸ when collapsed, ▾ when expanded – drawn, not glyphs, so it stays crisp at any size. */
private drawChevron(x: number, y: number, expanded: boolean): void {
const color = this.selected ? Theme.colors.text : Theme.colors.textMuted;
if (expanded) this.bg.moveTo(x - 3, y - 2).lineTo(x + 5, y - 2).lineTo(x + 1, y + 3).fill(color);
else this.bg.moveTo(x - 1, y - 4).lineTo(x + 4, y).lineTo(x - 1, y + 4).fill(color);
if (expanded)
this.bg
.moveTo(x - 3, y - 2)
.lineTo(x + 5, y - 2)
.lineTo(x + 1, y + 3)
.fill(color);
else
this.bg
.moveTo(x - 1, y - 4)
.lineTo(x + 4, y)
.lineTo(x - 1, y + 4)
.fill(color);
}
}
+2
View File
@@ -124,6 +124,8 @@ export class NumberField extends TextField {
/** Clamps to min/max; NaN/Infinity keep the current value (0 before the first assignment). */
private clampValue(value: number): number {
// `?? 0` is not dead despite the type: the constructor calls this before assigning `numeric`.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!Number.isFinite(value)) return this.numeric ?? 0;
return clamp(value, this.min, this.max);
}
+6 -2
View File
@@ -39,7 +39,9 @@ export class ScrollContainer extends Widget {
this.scrollTo(this.dragScrollStart + (e.global.y - this.dragStart) * ratio);
});
// The press starts on the scrollbar graphics; its release bubbles up to us either way.
this.onPointerRelease(() => { this.draggingBar = false; });
this.onPointerRelease(() => {
this.draggingBar = false;
});
}
/** Replaces the scrolled widget. The previous content is detached, not destroyed. */
@@ -99,6 +101,8 @@ export class ScrollContainer extends Widget {
const x = this.w - trackW - 2;
const barH = Math.max(20, (this.h / this.contentHeight) * this.h);
const barY = (this.scrollY / this.maxScroll) * (this.h - barH);
this.scrollbar.roundRect(x, barY, trackW, barH, trackW / 2).fill({ color: Theme.colors.textMuted, alpha: 0.6 });
this.scrollbar
.roundRect(x, barY, trackW, barH, trackW / 2)
.fill({ color: Theme.colors.textMuted, alpha: 0.6 });
}
}
+22 -5
View File
@@ -61,7 +61,14 @@ export class TextField extends Widget implements TextEditTarget {
this.minWidth = options.width ?? 80;
this.textNode = new Text({ text: value, style: textStyle(options.style ?? 'body') });
this.placeholderNode = new Text({ text: this.placeholder, style: textStyle('muted') });
this.addChild(this.bg, this.selectionShape, this.placeholderNode, this.textNode, this.caret, this.clipShape);
this.addChild(
this.bg,
this.selectionShape,
this.placeholderNode,
this.textNode,
this.caret,
this.clipShape,
);
this.mask = this.clipShape;
this.trackHover();
@@ -71,7 +78,9 @@ export class TextField extends Widget implements TextEditTarget {
const index = this.indexAt(this.toLocal(e.global).x);
this.setSelection(this.selStart, index, true);
});
this.onPointerRelease(() => { this.dragSelecting = false; });
this.onPointerRelease(() => {
this.dragSelecting = false;
});
}
/** Current text, including an edit in progress that has not been committed yet. */
@@ -216,7 +225,10 @@ export class TextField extends Widget implements TextEditTarget {
let bestDist = Infinity;
for (let i = 0; i <= this.valueText.length; i++) {
const dist = Math.abs(this.textWidth(this.valueText.slice(0, i)) - x);
if (dist < bestDist) { bestDist = dist; best = i; }
if (dist < bestDist) {
bestDist = dist;
best = i;
}
}
return best;
}
@@ -248,7 +260,10 @@ export class TextField extends Widget implements TextEditTarget {
fill: this.hovered && !this.editing ? c.controlHover : c.control,
border: this.focused ? c.accent : c.border,
});
this.clipShape.clear().rect(1, 1, this.w - 2, this.h - 2).fill(0xffffff);
this.clipShape
.clear()
.rect(1, 1, this.w - 2, this.h - 2)
.fill(0xffffff);
this.textNode.text = this.valueText;
this.placeholderNode.text = this.valueText ? '' : this.placeholder;
@@ -271,7 +286,9 @@ export class TextField extends Widget implements TextEditTarget {
if (hi > lo) {
const x1 = textX + this.textWidth(this.valueText.slice(0, lo));
const x2 = textX + this.textWidth(this.valueText.slice(0, hi));
this.selectionShape.rect(x1, textY, x2 - x1, this.textNode.height).fill({ color: c.accent, alpha: 0.45 });
this.selectionShape
.rect(x1, textY, x2 - x1, this.textNode.height)
.fill({ color: c.accent, alpha: 0.45 });
}
if (this.caretVisible) {
this.caret.rect(Math.round(textX + caretX), textY, 1, this.textNode.height).fill(c.text);
+42 -9
View File
@@ -116,7 +116,11 @@ export class SceneViewport extends WorldViewport {
if (e.button === 0 && e.target === this) {
// Empty space: a click deselects, a drag becomes a rubber band (decided on release).
const local = this.toLocal(e.global);
this.marquee = { start: { x: local.x, y: local.y }, current: { x: local.x, y: local.y }, active: false };
this.marquee = {
start: { x: local.x, y: local.y },
current: { x: local.x, y: local.y },
active: false,
};
e.stopPropagation();
}
}
@@ -137,13 +141,22 @@ export class SceneViewport extends WorldViewport {
// A world-space delta is a local-space delta as well (no rotation/scale in the hierarchy).
for (const item of this.dragging.items) {
const object = findObject(scene, item.objectId);
if (object) object.position = { x: Math.round(item.start.x + dx), y: Math.round(item.start.y + dy), z: item.start.z };
if (object)
object.position = {
x: Math.round(item.start.x + dx),
y: Math.round(item.start.y + dy),
z: item.start.z,
};
}
this.state.preview();
} else if (this.marquee) {
const local = this.toLocal(e.global);
this.marquee.current = { x: local.x, y: local.y };
if (!this.marquee.active && Math.hypot(local.x - this.marquee.start.x, local.y - this.marquee.start.y) < MARQUEE_THRESHOLD) return;
if (
!this.marquee.active &&
Math.hypot(local.x - this.marquee.start.x, local.y - this.marquee.start.y) < MARQUEE_THRESHOLD
)
return;
this.marquee.active = true;
this.redraw();
}
@@ -198,7 +211,8 @@ export class SceneViewport extends WorldViewport {
return;
}
// Clicking a selected object keeps the selection so the whole group can be dragged.
if (!this.state.selectedObjectIds.includes(objectId)) this.state.select(objectSelection(scene.id, [objectId]));
if (!this.state.selectedObjectIds.includes(objectId))
this.state.select(objectSelection(scene.id, [objectId]));
// Only the tops of the selected subtrees move; their descendants follow through the hierarchy.
const items = subtreeRoots(scene, this.state.selectedObjectIds)
.map((id) => findObject(scene, id))
@@ -219,7 +233,9 @@ export class SceneViewport extends WorldViewport {
const maxX = Math.max(a.x, b.x);
const minY = Math.min(a.y, b.y);
const maxY = Math.max(a.y, b.y);
const ids = this.lastItems.filter((i) => i.enabled && i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY).map((i) => i.id);
const ids = this.lastItems
.filter((i) => i.enabled && i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY)
.map((i) => i.id);
this.state.select(objectSelection(scene.id, ids));
}
@@ -249,7 +265,15 @@ export class SceneViewport extends WorldViewport {
const step = GRID_STEP * this.view.zoom;
if (step < MIN_GRID_STEP) return;
const origin = worldToScreen(this.currentView(), 0, 0);
drawGrid(this.grid, { originX: origin.x, originY: origin.y, step, w: this.w, h: this.h, alpha: 0.05, axisAlpha: 0.15 });
drawGrid(this.grid, {
originX: origin.x,
originY: origin.y,
step,
w: this.w,
h: this.h,
alpha: 0.05,
axisAlpha: 0.15,
});
}
/**
@@ -287,7 +311,8 @@ export class SceneViewport extends WorldViewport {
label.visible = true;
label.text = item.name;
label.scale.set(1 / scale);
if (item.type === 'camera' || item.type === 'collider') label.position.set(item.x - size.w / 2, item.y - size.h / 2 - 16 / scale);
if (item.type === 'camera' || item.type === 'collider')
label.position.set(item.x - size.w / 2, item.y - size.h / 2 - 16 / scale);
else label.position.set(item.x + size.w / 2 + 4 / scale, item.y - 8 / scale);
}
@@ -305,7 +330,12 @@ export class SceneViewport extends WorldViewport {
const sh = h * view.scale;
this.gizmos.rect(tl.x - 2, tl.y - 2, sw + 4, sh + 4).stroke({ width: 2, color: Theme.colors.gizmo });
if (id !== s.objectId) continue;
for (const [cx, cy] of [[tl.x, tl.y], [tl.x + sw, tl.y], [tl.x, tl.y + sh], [tl.x + sw, tl.y + sh]] as const) {
for (const [cx, cy] of [
[tl.x, tl.y],
[tl.x + sw, tl.y],
[tl.x, tl.y + sh],
[tl.x + sw, tl.y + sh],
] as const) {
this.gizmos.rect(cx - 3, cy - 3, 6, 6).fill(Theme.colors.gizmo);
}
}
@@ -316,7 +346,10 @@ export class SceneViewport extends WorldViewport {
const y = Math.min(m.start.y, m.current.y);
const w = Math.abs(m.current.x - m.start.x);
const h = Math.abs(m.current.y - m.start.y);
this.gizmos.rect(x, y, w, h).fill({ color: Theme.colors.marquee, alpha: 0.12 }).stroke({ width: 1, color: Theme.colors.marquee });
this.gizmos
.rect(x, y, w, h)
.fill({ color: Theme.colors.marquee, alpha: 0.12 })
.stroke({ width: 1, color: Theme.colors.marquee });
}
}
}
+12 -4
View File
@@ -55,7 +55,9 @@ export abstract class WorldViewport extends Widget {
state.on('selection', () => this.redraw());
state.on('mode', () => this.redraw());
// Every tick moves things: redraw the live view when it is on screen.
state.on('runtime', () => { if (this.visible) this.redraw(); });
state.on('runtime', () => {
if (this.visible) this.redraw();
});
}
override measure(): Size {
@@ -143,7 +145,10 @@ export abstract class WorldViewport extends Widget {
if (this.paintsBackground()) this.bg.rect(rect.x, rect.y, rect.w, rect.h).fill(background);
this.contentMask.rect(rect.x, rect.y, rect.w, rect.h).fill(0xffffff);
this.world.position.set(rect.x + rect.w / 2 - view.x * view.scale, rect.y + rect.h / 2 - view.y * view.scale);
this.world.position.set(
rect.x + rect.w / 2 - view.x * view.scale,
rect.y + rect.h / 2 - view.y * view.scale,
);
this.world.scale.set(view.scale);
const bounds = worldBounds(view);
@@ -214,7 +219,10 @@ export abstract class WorldViewport extends Widget {
return;
}
if (!text) {
text = new Text({ text: '', style: new TextStyle({ fontFamily: Theme.font, fontSize: 32, fill: 0xffffff }) });
text = new Text({
text: '',
style: new TextStyle({ fontFamily: Theme.font, fontSize: 32, fill: 0xffffff }),
});
text.eventMode = 'none';
this.texts.set(item.id, text);
this.objects.addChild(text);
@@ -227,7 +235,7 @@ export abstract class WorldViewport extends Widget {
const color = Number(item.props.color ?? 0xffffff);
if (text.style.fill !== color) text.style.fill = color;
text.anchor.set(textAnchor(item.props), 0.5);
const resolution = Math.min(4, Math.max(1, Math.round(scale * 2) / 2 * 2));
const resolution = Math.min(4, Math.max(1, (Math.round(scale * 2) / 2) * 2));
if (text.resolution !== resolution) text.resolution = resolution;
}
}
+15 -2
View File
@@ -52,7 +52,12 @@ export function collectSceneItems(state: EditorState, fallbackBackground: number
});
}
const byId = new Map(items.map((i) => [i.id, i]));
for (const item of items) item.enabled = enabledAlongChain(item, (i) => i.props, (i) => byId.get(i.parentId) ?? null);
for (const item of items)
item.enabled = enabledAlongChain(
item,
(i) => i.props,
(i) => byId.get(i.parentId) ?? null,
);
return { items, background: Number(scene.props.backgroundColor ?? fallbackBackground) };
}
@@ -60,6 +65,14 @@ export function collectSceneItems(state: EditorState, fallbackBackground: number
export function collectWorldItems(world: World): DrawList {
const items = world.entities
.filter((e) => !isSymbolType(e.type) && isHandleEnabled(e))
.map((e) => ({ id: e.objectId, type: e.type, name: e.name, ...worldPositionOf(e), props: e.props, parentId: e.parent?.objectId ?? '', enabled: true }));
.map((e) => ({
id: e.objectId,
type: e.type,
name: e.name,
...worldPositionOf(e),
props: e.props,
parentId: e.parent?.objectId ?? '',
enabled: true,
}));
return { items, background: world.backgroundColor };
}
+46 -9
View File
@@ -3,7 +3,7 @@
* separate Pixi Text), and the editor symbols for cameras, behaviors, containers and
* colliders. Everything here draws in world units at the item's origin.
*/
import { Graphics, Rectangle } from 'pixi.js';
import { type Graphics, Rectangle } from 'pixi.js';
import { BEHAVIOR_SYMBOL, CONTAINER_SYMBOL, textAnchor } from '../core/properties/bounds';
import { type ColliderShape, colliderShape, isTrigger } from '../core/properties/collider';
import { Theme } from '../ui';
@@ -16,7 +16,12 @@ const COLLIDER_GRAB_BAND = 6;
* Draws `item` into its node. `scale` is the current view scale (pixels per world unit), so
* strokes and hit bands can stay constant on screen.
*/
export function drawItem(node: Graphics, item: DrawItem, size: { w: number; h: number }, scale: number): void {
export function drawItem(
node: Graphics,
item: DrawItem,
size: { w: number; h: number },
scale: number,
): void {
node.clear();
node.hitArea = null;
switch (item.type) {
@@ -24,7 +29,9 @@ export function drawItem(node: Graphics, item: DrawItem, size: { w: number; h: n
node.rect(-size.w / 2, -size.h / 2, size.w, size.h).fill(Number(item.props.color ?? 0xffffff));
return;
case 'camera':
node.rect(-size.w / 2, -size.h / 2, size.w, size.h).stroke({ width: 2 / scale, color: Theme.colors.textMuted });
node
.rect(-size.w / 2, -size.h / 2, size.w, size.h)
.stroke({ width: 2 / scale, color: Theme.colors.textMuted });
node.rect(-12, -8, 24, 16).fill(Theme.colors.textMuted);
node.moveTo(12, 0).lineTo(20, -6).lineTo(20, 6).closePath().fill(Theme.colors.textMuted);
return;
@@ -32,7 +39,12 @@ export function drawItem(node: Graphics, item: DrawItem, size: { w: number; h: n
// A diamond with a small "circuit" mark: the logic symbol.
const r = BEHAVIOR_SYMBOL / 2;
node.moveTo(0, -r).lineTo(r, 0).lineTo(0, r).lineTo(-r, 0).closePath().fill(Theme.colors.behavior);
node.moveTo(-6, -3).lineTo(-1, -3).lineTo(-1, 3).lineTo(6, 3).stroke({ width: 2, color: Theme.colors.accentText });
node
.moveTo(-6, -3)
.lineTo(-1, -3)
.lineTo(-1, 3)
.lineTo(6, 3)
.stroke({ width: 2, color: Theme.colors.accentText });
node.circle(-6, -3, 2).fill(Theme.colors.accentText);
node.circle(6, 3, 2).fill(Theme.colors.accentText);
return;
@@ -41,12 +53,26 @@ export function drawItem(node: Graphics, item: DrawItem, size: { w: number; h: n
// A square with four small boxes inside: the group symbol. Its content is drawn as
// ordinary objects (they are ordinary objects, parented below the container).
const r = CONTAINER_SYMBOL / 2;
node.roundRect(-r, -r, CONTAINER_SYMBOL, CONTAINER_SYMBOL, 3).fill({ color: Theme.colors.container, alpha: 0.25 }).stroke({ width: 2, color: Theme.colors.container });
for (const [bx, by] of [[-7, -7], [1, -7], [-7, 1], [1, 1]] as const) node.rect(bx, by, 6, 6).fill(Theme.colors.container);
node
.roundRect(-r, -r, CONTAINER_SYMBOL, CONTAINER_SYMBOL, 3)
.fill({ color: Theme.colors.container, alpha: 0.25 })
.stroke({ width: 2, color: Theme.colors.container });
for (const [bx, by] of [
[-7, -7],
[1, -7],
[-7, 1],
[1, 1],
] as const)
node.rect(bx, by, 6, 6).fill(Theme.colors.container);
return;
}
case 'text':
node.hitArea = new Rectangle(-size.w / 2 - (textAnchor(item.props) - 0.5) * size.w, -size.h / 2, size.w, size.h);
node.hitArea = new Rectangle(
-size.w / 2 - (textAnchor(item.props) - 0.5) * size.w,
-size.h / 2,
size.w,
size.h,
);
return;
case 'collider': {
// Outline plus a faint fill so the shape reads as "area". Colliders usually sit right on
@@ -66,7 +92,10 @@ export function drawItem(node: Graphics, item: DrawItem, size: { w: number; h: n
}
/** Hit area covering only the outline band of a collider shape (local coordinates, world units). */
function colliderOutlineHitArea(shape: ColliderShape, band: number): { contains(x: number, y: number): boolean } {
function colliderOutlineHitArea(
shape: ColliderShape,
band: number,
): { contains(x: number, y: number): boolean } {
if (shape.kind === 'circle') return { contains: (x, y) => Math.abs(Math.hypot(x, y) - shape.r) <= band };
const hw = shape.w / 2;
const hh = shape.h / 2;
@@ -80,7 +109,15 @@ function colliderOutlineHitArea(shape: ColliderShape, band: number): { contains(
}
/** Adds a dashed line path to `g` (Pixi has no dash style); the caller strokes it. */
export function dashedLine(g: Graphics, x1: number, y1: number, x2: number, y2: number, dash: number, gap: number): void {
export function dashedLine(
g: Graphics,
x1: number,
y1: number,
x2: number,
y2: number,
dash: number,
gap: number,
): void {
const length = Math.hypot(x2 - x1, y2 - y1);
if (length === 0) return;
const ux = (x2 - x1) / length;
+9 -3
View File
@@ -26,7 +26,9 @@ function setup() {
const instance = place(f, create.assetId, { x: 1000, y: 0 });
const container = () => findAsset(f.project, create.assetId) as ContainerAsset;
const templateOfA = () => container().objects.find((t) => t.id === f.object(a).templateId)!;
const a2 = f.sceneOf().objects.find((o) => o.parentId === instance && o.templateId === f.object(a).templateId)!.id;
const a2 = f
.sceneOf()
.objects.find((o) => o.parentId === instance && o.templateId === f.object(a).templateId)!.id;
return { ...f, a, b, a2, container, templateOfA };
}
@@ -80,7 +82,9 @@ describe('position apply / revert', () => {
it('rejects objects that are not part of a container', () => {
const f = setup();
const loose = place(f, f.spriteId, { x: 0, y: 0 });
expect(() => f.stack.execute(new ApplyPositionToContainerCommand(f.sceneId, loose))).toThrow(/not part of a placed container/);
expect(() => f.stack.execute(new ApplyPositionToContainerCommand(f.sceneId, loose))).toThrow(
/not part of a placed container/,
);
expect(f.sceneOf().objects.find((o) => o.id === loose)).toBeDefined();
});
});
@@ -133,7 +137,9 @@ describe('override apply / revert', () => {
stack.execute(new ApplyOverrideToAssetCommand(sceneId, loose, 'height'));
expect(findAsset(project, spriteId)!.props.height).toBe(33);
expect(object(loose).overrides.height).toBeUndefined();
expect(() => stack.execute(new ApplyOverrideToAssetCommand(sceneId, loose, 'nope'))).toThrow(/not defined/);
expect(() => stack.execute(new ApplyOverrideToAssetCommand(sceneId, loose, 'nope'))).toThrow(
/not defined/,
);
});
it('reverts an override to the template value (or removes it when the template has none)', () => {
+214
View File
@@ -0,0 +1,214 @@
/**
* The event nodes have no `run` – the BehaviorSystem decides when they fire. These tests drive a
* real world and check that timing: key edges vs. held keys, the timer's own clock, and the
* difference between a collision that starts and one that continues.
*/
import { describe, expect, it } from 'vitest';
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { graphEditor, makeBehavior, makeSpriteScene, place, type SceneFixture, startWorld } from '../helpers';
/** A scene with a 100×60 sprite and a behavior placed below it, ready to have an event wired up. */
function scene(): SceneFixture & { behavior: BehaviorAsset; box: string } {
const f = makeSpriteScene('Box');
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 60));
const behavior = makeBehavior(f, 'Logic');
const box = place(f, f.spriteId, { x: 0, y: 0 });
place(f, behavior.id, { x: 0, y: 0 }, box);
return { ...f, behavior, box };
}
/** Wires `<event> → Log(message)` so a fired event shows up as one entry in `world.log`. */
function logOn(
f: SceneFixture & { behavior: BehaviorAsset },
event: string,
params: Record<string, string | number> = {},
message = event,
): void {
const edit = graphEditor(f, f.behavior);
const node = edit.addNode(event, params);
const log = edit.addNode('Log', { message });
edit.connect(node, 'exec', log, 'exec');
}
describe('key events', () => {
it('On key down fires once per press, not while the key stays held', () => {
const f = scene();
logOn(f, 'OnKeyDown', { key: 'a' }, 'down');
const { world, input, frame } = startWorld(f, { behaviors: true });
frame();
expect(world.log).toEqual([]);
input.press('a');
frame();
expect(world.log).toEqual(['down']);
input.press('a'); // auto-repeat while held: no new edge
frame();
frame();
expect(world.log).toEqual(['down']);
input.release('a');
input.press('a');
frame();
expect(world.log).toEqual(['down', 'down']);
});
it('On key up fires once when the key is released', () => {
const f = scene();
logOn(f, 'OnKeyUp', { key: 'a' }, 'up');
const { world, input, frame } = startWorld(f, { behaviors: true });
input.press('a');
frame();
frame();
expect(world.log).toEqual([]);
input.release('a');
frame();
expect(world.log).toEqual(['up']);
frame(); // the edge is consumed, not sticky
expect(world.log).toEqual(['up']);
});
it('On key held fires every frame the key is down', () => {
const f = scene();
logOn(f, 'OnKeyHeld', { key: 'a' }, 'held');
const { world, input, frame } = startWorld(f, { behaviors: true });
input.press('a');
frame();
frame();
frame();
expect(world.log).toEqual(['held', 'held', 'held']);
input.release('a');
frame();
expect(world.log).toHaveLength(3);
});
it('only the node whose key matches fires', () => {
const f = scene();
logOn(f, 'OnKeyDown', { key: 'a' }, 'a');
logOn(f, 'OnKeyDown', { key: 'b' }, 'b');
const { world, input, frame } = startWorld(f, { behaviors: true });
input.press('b');
frame();
expect(world.log).toEqual(['b']);
});
it('losing the window releases every held key, so nothing stays down behind the user’s back', () => {
const f = scene();
logOn(f, 'OnKeyUp', { key: 'a' }, 'up');
logOn(f, 'OnKeyHeld', { key: 'a' }, 'held');
const { world, input, frame } = startWorld(f, { behaviors: true });
input.press('a');
frame();
expect(world.log).toEqual(['held']);
input.releaseAll();
frame();
expect(world.log).toEqual(['held', 'up']);
frame();
expect(world.log).toHaveLength(2);
});
});
describe('On timer', () => {
it('fires on its own clock, independent of the frame rate', () => {
const f = scene();
logOn(f, 'OnTimer', { interval: 1 }, 'tick');
const { world, frame } = startWorld(f, { behaviors: true });
for (let i = 0; i < 4; i++) frame(0.2); // 0.8 s
expect(world.log).toEqual([]);
frame(0.2); // 1.0 s
expect(world.log).toEqual(['tick']);
for (let i = 0; i < 5; i++) frame(0.2); // 2.0 s
expect(world.log).toEqual(['tick', 'tick']);
});
it('catches up when one frame is longer than the interval', () => {
const f = scene();
logOn(f, 'OnTimer', { interval: 0.5 }, 'tick');
const { world, frame } = startWorld(f, { behaviors: true });
frame(2); // a hitch worth four intervals
expect(world.log).toEqual(['tick', 'tick', 'tick', 'tick']);
});
it('two timers keep separate clocks', () => {
const f = scene();
logOn(f, 'OnTimer', { interval: 1 }, 'slow');
logOn(f, 'OnTimer', { interval: 0.5 }, 'fast');
const { world, frame } = startWorld(f, { behaviors: true });
frame(0.5);
expect(world.log).toEqual(['fast']);
frame(0.5);
expect(world.log).toEqual(['fast', 'slow', 'fast']);
});
});
describe('On collision stay', () => {
it('fires every frame the overlap lasts, while enter fires only on the first one', () => {
const f = makeSpriteScene('Crate');
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 60));
const behavior = makeBehavior(f, 'Hit');
const a = place(f, f.spriteId, { x: 0, y: 0 });
const b = place(f, f.spriteId, { x: 300, y: 0 });
f.stack.execute(new AddColliderCommand(f.sceneId, a));
f.stack.execute(new AddColliderCommand(f.sceneId, b));
place(f, behavior.id, { x: 0, y: 0 }, a);
const fixture = { ...f, behavior };
logOn(fixture, 'OnCollisionEnter', {}, 'enter');
logOn(fixture, 'OnCollisionStay', {}, 'stay');
logOn(fixture, 'OnCollisionExit', {}, 'exit');
const { world, frame } = startWorld(f, { collisions: true, behaviors: true });
const crateB = world.findByObjectId(b)!;
frame(0.1);
expect(world.log).toEqual([]);
crateB.x = 50; // now overlapping
frame(0.1);
expect(world.log).toEqual(['enter']);
frame(0.1);
frame(0.1);
expect(world.log).toEqual(['enter', 'stay', 'stay']);
crateB.x = 500;
frame(0.1);
expect(world.log).toEqual(['enter', 'stay', 'stay', 'exit']);
frame(0.1);
expect(world.log).toHaveLength(4);
});
});
describe('a switched-off behavior receives no events', () => {
it('ignores keys and timers until it is switched back on', () => {
const f = scene();
logOn(f, 'OnKeyDown', { key: 'a' }, 'down');
logOn(f, 'OnTimer', { interval: 0.5 }, 'tick');
const { world, input, frame } = startWorld(f, { behaviors: true });
const logic = world.entities.find((e) => e.name === 'Logic')!;
logic.props.enabled = false;
input.press('a');
frame(1);
expect(world.log).toEqual([]);
logic.props.enabled = true;
input.release('a');
input.press('a');
frame(1);
// The timer starts counting when the behavior runs again – no backlog from the off period.
expect(world.log).toEqual(['down', 'tick', 'tick']);
});
});
+25 -5
View File
@@ -3,7 +3,12 @@ import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { findAsset } from '../../src/core/model/Project';
import { axisCorrection, axisSeparation, colliderShape, shapesOverlap } from '../../src/core/properties/collider';
import {
axisCorrection,
axisSeparation,
colliderShape,
shapesOverlap,
} from '../../src/core/properties/collider';
import { graphEditor, makeBehavior, makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
const box = (x: number, y: number, w: number, h: number) => ({ kind: 'box' as const, x, y, w, h });
@@ -92,8 +97,15 @@ describe('AddColliderCommand', () => {
it('refuses a collider on a collider and survives a round trip', () => {
const { project, stack, sceneId, colA } = setup();
expect(() => stack.execute(new AddColliderCommand(sceneId, colA.objectId))).toThrow(/cannot have a collider/);
expect(findAsset(roundTrip(project), colA.assetId)?.props).toMatchObject({ shape: 'box', width: 100, height: 60, radius: 50 });
expect(() => stack.execute(new AddColliderCommand(sceneId, colA.objectId))).toThrow(
/cannot have a collider/,
);
expect(findAsset(roundTrip(project), colA.assetId)?.props).toMatchObject({
shape: 'box',
width: 100,
height: 60,
radius: 50,
});
});
});
@@ -130,7 +142,15 @@ describe('CollisionSystem', () => {
world.findByObjectId(b)!.x = 75; // circle r=30 at 75 vs box half-width 50 → overlap
world.tick(0.1);
expect(collisions.enters).toHaveLength(2); // circle B against both colliders of A
expect(collisions.enters.every((p) => p.a.objectId === colA.objectId || p.b.objectId === colA.objectId || p.a.objectId === second.objectId || p.b.objectId === second.objectId)).toBe(true);
expect(
collisions.enters.every(
(p) =>
p.a.objectId === colA.objectId ||
p.b.objectId === colA.objectId ||
p.a.objectId === second.objectId ||
p.b.objectId === second.objectId,
),
).toBe(true);
});
});
@@ -231,7 +251,7 @@ describe('move and slide', () => {
});
describe('collision events in behaviors', () => {
it('fires On collision enter/exit on the body\'s behavior with the other body; Is touching agrees', () => {
it("fires On collision enter/exit on the body's behavior with the other body; Is touching agrees", () => {
const s = setup();
const { behavior, b, object } = s;
// On collision enter → Log(other); On collision exit → Log("bye"); On update → Log(Is touching(Owner, Object by name "B"))
+9 -2
View File
@@ -22,7 +22,12 @@ describe('asset commands', () => {
const { project, sceneId, spriteId } = makeSpriteScene();
expect(project.assets).toHaveLength(2);
expect(project.activeSceneId).toBe(sceneId);
expect(findAsset(project, spriteId)?.props).toEqual({ enabled: true, width: 100, height: 100, color: 0xe0553f });
expect(findAsset(project, spriteId)?.props).toEqual({
enabled: true,
width: 100,
height: 100,
color: 0xe0553f,
});
});
it('renames and sets properties with undo/redo', () => {
@@ -100,7 +105,9 @@ describe('scene object commands', () => {
expect(object.position).toEqual({ x: 5, y: 6, z: 0 });
expect(object.overrides).toEqual({});
expect(() => stack.execute(new PlaceAssetCommand(sceneId, sceneId, { x: 0, y: 0 }))).toThrow(/cannot be placed/);
expect(() => stack.execute(new PlaceAssetCommand(sceneId, sceneId, { x: 0, y: 0 }))).toThrow(
/cannot be placed/,
);
expect(sceneOf().objects).toHaveLength(1);
});
+5 -1
View File
@@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest';
import { DeleteAssetCommand } from '../../src/core/commands/assetCommands';
import { CompoundCommand } from '../../src/core/commands/CompoundCommand';
import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
import { MoveSceneObjectCommand, RemoveSceneObjectsCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands';
import {
MoveSceneObjectCommand,
RemoveSceneObjectsCommand,
SetParentCommand,
} from '../../src/core/commands/sceneObjectCommands';
import { worldPositionOf } from '../../src/core/graph';
import type { ContainerAsset } from '../../src/core/model/Asset';
import { subtreeRoots, treeOrder } from '../../src/core/model/hierarchy';
+17 -2
View File
@@ -6,7 +6,17 @@ import { isHandleEnabled } from '../../src/core/graph';
import { findAsset, findScene } from '../../src/core/model/Project';
import { schemaFor } from '../../src/core/properties/PropertySchema';
import { resolveSceneObjectProps } from '../../src/core/properties/resolve';
import { fakeHandle, graphEditor, makeBehavior, makeProject, makeSpriteScene, place, roundTrip, runNode, startWorld } from '../helpers';
import {
fakeHandle,
graphEditor,
makeBehavior,
makeProject,
makeSpriteScene,
place,
roundTrip,
runNode,
startWorld,
} from '../helpers';
/** A 100×100 wall at x = 200 and a 100×100 mover at the origin, both with box colliders, in a running world. */
function wallAndMover() {
@@ -17,7 +27,12 @@ function wallAndMover() {
f.stack.execute(new AddColliderCommand(f.sceneId, moverId));
f.stack.execute(wallCollider);
const { world, collisions } = startWorld(f, { collisions: true });
return { world, collisions, mover: world.findByObjectId(moverId)!, wall: world.findByObjectId(wallCollider.objectId)! };
return {
world,
collisions,
mover: world.findByObjectId(moverId)!,
wall: world.findByObjectId(wallCollider.objectId)!,
};
}
describe('the enabled switch', () => {
+53 -11
View File
@@ -1,12 +1,30 @@
import { describe, expect, it } from 'vitest';
import { CommandError } from '../../src/core/commands/Command';
import { AddNodeCommand, DisconnectCommand, RemoveNodeCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands';
import { AddVariableCommand, RemoveVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands';
import {
AddNodeCommand,
DisconnectCommand,
RemoveNodeCommand,
SetNodeParamCommand,
} from '../../src/core/commands/graphCommands';
import {
AddVariableCommand,
RemoveVariableCommand,
RenameVariableCommand,
} from '../../src/core/commands/variableCommands';
import { GraphInterpreter, allNodeDefs, findNode, portsCompatible } from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { findAsset } from '../../src/core/model/Project';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { type ProjectFixture, fakeContext, graphEditor, makeBehavior, makeProject, makeSpriteScene, place, roundTrip } from '../helpers';
import {
type ProjectFixture,
fakeContext,
graphEditor,
makeBehavior,
makeProject,
makeSpriteScene,
place,
roundTrip,
} from '../helpers';
/** Adds "On key held(ArrowRight) → Move(owner, dx = speed × dt)" to a project, built through commands like the editor would. */
function withMover<F extends ProjectFixture>(fixture: F) {
@@ -75,7 +93,10 @@ describe('GraphInterpreter', () => {
const start = edit.addNode('OnStart');
const self = edit.addNode('Self');
const move = edit.addNode('Move', { dx: 7, dy: -3 });
edit.wire([[start, 'exec', move, 'exec'], [self, 'object', move, 'object']]);
edit.wire([
[start, 'exec', move, 'exec'],
[self, 'object', move, 'object'],
]);
const ctx = fakeContext();
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
expect(ctx.self.x).toBe(7);
@@ -88,7 +109,11 @@ describe('GraphInterpreter', () => {
const self = edit.addNode('Self');
const clamp = edit.addNode('Clamp', { value: 5 });
const move = edit.addNode('Move', { dx: 4 });
edit.wire([[start, 'exec', move, 'exec'], [self, 'object', move, 'object'], [clamp, 'result', move, 'dy']]);
edit.wire([
[start, 'exec', move, 'exec'],
[self, 'object', move, 'object'],
[clamp, 'result', move, 'dy'],
]);
const ctx = fakeContext();
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
expect(ctx.self.x).toBe(4); // scale defaulted to 1, not 0
@@ -145,7 +170,10 @@ describe('GraphInterpreter', () => {
let previous = start;
sources.forEach((source, i) => {
const log = edit.addNode('Log');
edit.wire([[previous, 'exec', log, 'exec'], [source, ports[i]!, log, 'message']]);
edit.wire([
[previous, 'exec', log, 'exec'],
[source, ports[i]!, log, 'message'],
]);
previous = log;
});
const ctx = fakeContext();
@@ -198,14 +226,21 @@ describe('GraphInterpreter', () => {
const start = edit.addNode('OnStart');
const a = edit.addNode('Sequence');
const b = edit.addNode('Sequence');
edit.wire([[start, 'exec', a, 'exec'], [a, 'a', b, 'exec'], [b, 'a', a, 'exec']]);
edit.wire([
[start, 'exec', a, 'exec'],
[a, 'a', b, 'exec'],
[b, 'a', a, 'exec'],
]);
expect(() => new GraphInterpreter(behavior.graph).fire('OnStart', fakeContext())).toThrow(/step limit/);
});
});
describe('graph commands', () => {
it('rejects incompatible, self and duplicate connections', () => {
const { edit, nodes: { held, mul, move } } = withMover(makeProject());
const {
edit,
nodes: { held, mul, move },
} = withMover(makeProject());
expect(() => edit.connect(held, 'exec', mul, 'a')).toThrow(/Cannot connect/);
expect(() => edit.connect(mul, 'result', mul, 'a')).toThrow(/itself/);
expect(() => edit.connect(held, 'exec', move, 'exec')).toThrow(/Already/);
@@ -218,7 +253,8 @@ describe('graph commands', () => {
const before = behavior.graph.edges.length;
edit.connect(literal, 'value', nodes.move, 'dx');
expect(behavior.graph.edges.length).toBe(before); // one replaced, one added
const sourceOfDx = () => behavior.graph.edges.find((e) => e.to.node === nodes.move.id && e.to.port === 'dx')!.from.node;
const sourceOfDx = () =>
behavior.graph.edges.find((e) => e.to.node === nodes.move.id && e.to.port === 'dx')!.from.node;
expect(sourceOfDx()).toBe(literal.id);
stack.undo();
expect(sourceOfDx()).toBe(nodes.mul.id);
@@ -288,10 +324,16 @@ describe('behavior serialization', () => {
expect(loaded.graph).toEqual(behavior.graph);
expect(loaded.props).toEqual({ enabled: true, speed: 0 });
const raw = serializeProject(project) as { assets: { id: string; graph?: { nodes: unknown[]; edges: unknown[] } }[] };
const raw = serializeProject(project) as {
assets: { id: string; graph?: { nodes: unknown[]; edges: unknown[] } }[];
};
const rawGraph = raw.assets.find((a) => a.id === behavior.id)!.graph!;
rawGraph.nodes.push({ id: 'zzz', type: 'DoesNotExist', x: 0, y: 0, params: {} });
rawGraph.edges.push({ id: 'e-bad', from: { node: 'zzz', port: 'exec' }, to: { node: behavior.graph.nodes[0]!.id, port: 'exec' } });
rawGraph.edges.push({
id: 'e-bad',
from: { node: 'zzz', port: 'exec' },
to: { node: behavior.graph.nodes[0]!.id, port: 'exec' },
});
const cleaned = findAsset(deserializeProject(raw), behavior.id) as BehaviorAsset;
expect(cleaned.graph.nodes.length).toBe(behavior.graph.nodes.length);
expect(cleaned.graph.edges.length).toBe(behavior.graph.edges.length);
+44 -10
View File
@@ -1,11 +1,30 @@
import { describe, expect, it } from 'vitest';
import { DeleteAssetCommand } from '../../src/core/commands/assetCommands';
import { MoveSceneObjectCommand, RemoveSceneObjectCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands';
import {
MoveSceneObjectCommand,
RemoveSceneObjectCommand,
SetParentCommand,
} from '../../src/core/commands/sceneObjectCommands';
import { type Graph, GraphInterpreter, worldPositionOf } from '../../src/core/graph';
import { ancestorIdsOf, descendantsOf, findObject, localPositionFor, treeOrder, visibleTreeOrder } from '../../src/core/model/hierarchy';
import {
ancestorIdsOf,
descendantsOf,
findObject,
localPositionFor,
treeOrder,
visibleTreeOrder,
} from '../../src/core/model/hierarchy';
import { findScene } from '../../src/core/model/Project';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { fakeContext, fakeEntity, makeBehavior, makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
import {
fakeContext,
fakeEntity,
makeBehavior,
makeSpriteScene,
place,
roundTrip,
startWorld,
} from '../helpers';
/** Three boxes, all top level: a at (100,100,z1), b at (10,0), c at (0,5). */
function threeBoxes() {
@@ -58,7 +77,11 @@ describe('scene hierarchy', () => {
const { stack, sceneId, a, b, c, sceneOf } = threeBoxes();
// Storage order is a, b, c; parent c under a → c is shown right after a, before b.
stack.execute(new SetParentCommand(sceneId, c, a));
expect(treeOrder(sceneOf()).map((e) => [e.object.id, e.depth])).toEqual([[a, 0], [c, 1], [b, 0]]);
expect(treeOrder(sceneOf()).map((e) => [e.object.id, e.depth])).toEqual([
[a, 0],
[c, 1],
[b, 0],
]);
stack.execute(new SetParentCommand(sceneId, b, c));
expect(descendantsOf(sceneOf(), a).map((o) => o.id)).toEqual([c, b]);
});
@@ -68,12 +91,20 @@ describe('scene hierarchy', () => {
// a > c > b: two levels, so folding a hides both descendants.
stack.execute(new SetParentCommand(sceneId, c, a));
stack.execute(new SetParentCommand(sceneId, b, c));
const rows = (expanded: string[]) => visibleTreeOrder(sceneOf(), new Set(expanded)).map((e) => [e.object.id, e.depth, e.hasChildren]);
const rows = (expanded: string[]) =>
visibleTreeOrder(sceneOf(), new Set(expanded)).map((e) => [e.object.id, e.depth, e.hasChildren]);
// Nothing expanded (the default when a scene is opened) – only the top level is listed.
expect(rows([])).toEqual([[a, 0, true]]);
expect(rows([a])).toEqual([[a, 0, true], [c, 1, true]]);
expect(rows([a, c])).toEqual([[a, 0, true], [c, 1, true], [b, 2, false]]);
expect(rows([a])).toEqual([
[a, 0, true],
[c, 1, true],
]);
expect(rows([a, c])).toEqual([
[a, 0, true],
[c, 1, true],
[b, 2, false],
]);
// Expanding a nested object without its parent shows nothing extra: every ancestor must be open.
expect(rows([c])).toEqual([[a, 0, true]]);
});
@@ -165,7 +196,8 @@ describe('runtime hierarchy', () => {
const parent = { ...fakeEntity('p', 100), y: 20 };
const child = { ...fakeEntity('c', 5), y: 5, parent };
// On start → Log(world x of self) → Log(local x of self) → Log(world x of self's parent)
const graph: Graph = { nodes: [
const graph: Graph = {
nodes: [
{ id: 'start', type: 'OnStart', x: 0, y: 0, params: {} },
{ id: 'self', type: 'Self', x: 0, y: 0, params: {} },
{ id: 'pos', type: 'GetPosition', x: 0, y: 0, params: {} },
@@ -174,7 +206,8 @@ describe('runtime hierarchy', () => {
{ id: 'log1', type: 'Log', x: 0, y: 0, params: {} },
{ id: 'log2', type: 'Log', x: 0, y: 0, params: {} },
{ id: 'log3', type: 'Log', x: 0, y: 0, params: {} },
], edges: [
],
edges: [
{ id: 'e1', from: { node: 'start', port: 'exec' }, to: { node: 'log1', port: 'exec' } },
{ id: 'e2', from: { node: 'log1', port: 'exec' }, to: { node: 'log2', port: 'exec' } },
{ id: 'e3', from: { node: 'log2', port: 'exec' }, to: { node: 'log3', port: 'exec' } },
@@ -184,7 +217,8 @@ describe('runtime hierarchy', () => {
{ id: 'e7', from: { node: 'pos', port: 'worldX' }, to: { node: 'log1', port: 'message' } },
{ id: 'e8', from: { node: 'pos', port: 'x' }, to: { node: 'log2', port: 'message' } },
{ id: 'e9', from: { node: 'ppos', port: 'worldX' }, to: { node: 'log3', port: 'message' } },
] };
],
};
const ctx = fakeContext({ self: child, owner: parent, variables: {} });
new GraphInterpreter(graph).fire('OnStart', ctx);
expect(ctx.logs).toEqual(['105', '5', '100']);
+6 -3
View File
@@ -4,8 +4,7 @@
* The history tests pin that a rejected undo leaves the stacks usable.
*/
import { describe, expect, it } from 'vitest';
import type { Command } from '../../src/core/commands/Command';
import { CommandError } from '../../src/core/commands/Command';
import { type Command, CommandError } from '../../src/core/commands/Command';
import { CommandStack } from '../../src/core/commands/CommandStack';
import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
@@ -29,7 +28,11 @@ function containerWithOverride() {
describe('object holders', () => {
it('counts scenes and container assets alike', () => {
const { project } = containerWithOverride();
expect(objectHolders(project).map((h) => h.type).sort()).toEqual(['container', 'scene']);
expect(
objectHolders(project)
.map((h) => h.type)
.sort(),
).toEqual(['container', 'scene']);
});
it('finds usages inside container templates, not just in scenes', () => {
+618
View File
@@ -0,0 +1,618 @@
/**
* One table covering every built-in node that has a `run`, plus a guard that keeps it that way:
* adding a node without a case here fails `covers every built-in node`. Nodes without `run`
* (the events) are seeded by the interpreter, so they are listed with the test that drives them
* and checked in tests/core/behavior-events.test.ts.
*/
import { describe, expect, it } from 'vitest';
import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
import { type NodeContext, type RunResult, requireNodeDef, type Value } from '../../src/core/graph';
import type { PropertyBag } from '../../src/core/model/Properties';
import { fakeContext, type FakeContext, fakeEntity, fakeHandle, NO_POINTER } from '../helpers';
interface Case {
/** What the case demonstrates – becomes the failure message. */
name: string;
inputs?: Record<string, Value>;
params?: PropertyBag;
context?: Partial<NodeContext>;
/** Expected data outputs, compared exactly. */
outputs?: Record<string, Value>;
/** Expected exec continuation; `null` asserts the flow ends here. */
next?: string | string[] | null;
/** Assertions on what the node did to the context or to the handles it was given. */
after?(ctx: FakeContext): void;
}
const run = (type: string, c: Case): { result: RunResult; ctx: FakeContext } => {
const ctx = fakeContext(c.context);
return { result: requireNodeDef(type).run!(ctx, c.inputs ?? {}, c.params ?? {}), ctx };
};
/** A handle with a fixed position, for the geometry nodes. */
const at = (name: string, x: number, y: number) => ({ ...fakeEntity(name), x, y });
const pointer = (overrides: Partial<NodeContext['pointer']>): Partial<NodeContext> => ({
pointer: { ...NO_POINTER, ...overrides },
});
const CASES: Record<string, Case[]> = {
// --- flow ------------------------------------------------------------------------------------
Branch: [
{ name: 'true takes then', inputs: { condition: true }, next: 'then' },
{ name: 'false takes else', inputs: { condition: false }, next: 'else' },
{ name: 'a non-empty string is true', inputs: { condition: 'yes' }, next: 'then' },
{ name: '"false" as text is false', inputs: { condition: 'false' }, next: 'else' },
],
Sequence: [{ name: 'runs all three outputs in order', next: ['a', 'b', 'c'] }],
// --- variables -------------------------------------------------------------------------------
GetVariable: [
{ name: 'reads a variable', params: { variable: 'speed' }, outputs: { value: 100 } },
{ name: 'an unknown variable reads as null', params: { variable: 'nope' }, outputs: { value: null } },
{ name: 'no variable chosen reads as null', outputs: { value: null } },
],
SetVariable: [
{
name: 'writes a variable, keeping the declared type',
inputs: { value: '250' },
params: { variable: 'speed' },
next: 'exec',
after: (ctx) => expect(ctx.variables.speed).toBe(250),
},
{
name: 'no variable chosen writes nothing',
inputs: { value: 5 },
next: 'exec',
after: (ctx) => expect(ctx.variables).toEqual({ speed: 100 }),
},
],
// --- references and properties ----------------------------------------------------------------
Self: [{ name: 'outputs the behavior object', outputs: { object: fakeContext().self } }],
Owner: [
{ name: 'outputs the object the behavior is parented to', outputs: { object: fakeContext().owner } },
],
Parent: [
{ name: 'no object has no parent', inputs: { object: null }, outputs: { parent: null } },
{
name: 'a top-level object has no parent',
inputs: { object: fakeHandle({}) },
outputs: { parent: null },
},
],
ObjectByName: [
{
name: 'asks the context for the name',
params: { name: 'Player' },
context: { findByName: (name) => (name === 'Player' ? fakeEntity('Player') : null) },
outputs: { object: fakeEntity('Player') },
},
{ name: 'an unknown name is null', params: { name: 'Ghost' }, outputs: { object: null } },
],
NameOf: [
{ name: 'reads the name', inputs: { object: fakeEntity('Coin') }, outputs: { name: 'Coin' } },
{ name: 'no object is the empty name', inputs: { object: null }, outputs: { name: '' } },
],
IsAsset: [
{
name: 'matches the asset id',
inputs: { object: fakeEntity('Coin', 0, 'coin') },
params: { asset: 'coin' },
outputs: { result: true },
},
{
name: 'no asset chosen never matches',
inputs: { object: fakeEntity('Coin', 0, 'coin') },
outputs: { result: false },
},
],
IsEnabled: [
{ name: 'on by default', inputs: { object: fakeHandle({}) }, outputs: { result: true, own: true } },
{
name: 'own switch off',
inputs: { object: fakeHandle({ enabled: false }) },
outputs: { result: false, own: false },
},
{
name: 'own switch on but a parent is off – "own" still reports the own switch',
inputs: { object: fakeHandle({ enabled: true }, fakeHandle({ enabled: false })) },
outputs: { result: false, own: true },
},
{ name: 'no object is off', inputs: { object: null }, outputs: { result: false, own: false } },
],
GetProperty: [
{
name: 'reads a property',
inputs: { object: fakeHandle({ width: 42 }) },
params: { property: 'width' },
outputs: { value: 42 },
},
{
name: 'an absent property is null',
inputs: { object: fakeHandle({}) },
params: { property: 'width' },
outputs: { value: null },
},
],
GetPosition: [
{
name: 'local and world position of a top-level object are the same',
inputs: { object: at('A', 10, 20) },
outputs: { x: 10, y: 20, worldX: 10, worldY: 20 },
},
{
name: 'a child reports its local position and the summed world position',
inputs: { object: { ...at('child', 10, 20), parent: at('parent', 100, 200) } },
outputs: { x: 10, y: 20, worldX: 110, worldY: 220 },
},
{
name: 'no object is the origin',
inputs: { object: null },
outputs: { x: 0, y: 0, worldX: 0, worldY: 0 },
},
],
// --- literals and math -------------------------------------------------------------------------
Number: [
{ name: 'outputs its setting', params: { value: 7.5 }, outputs: { value: 7.5 } },
{ name: 'unset is 0', outputs: { value: 0 } },
],
Boolean: [
{ name: 'outputs its setting', params: { value: true }, outputs: { value: true } },
{ name: 'unset is false', outputs: { value: false } },
],
String: [
{ name: 'outputs its setting', params: { value: 'hello' }, outputs: { value: 'hello' } },
{ name: 'unset is empty', outputs: { value: '' } },
],
Add: [
{ name: 'adds', inputs: { a: 2, b: 3 }, outputs: { result: 5 } },
{ name: 'numeric text is coerced', inputs: { a: '2', b: 3 }, outputs: { result: 5 } },
],
Subtract: [{ name: 'subtracts', inputs: { a: 2, b: 3 }, outputs: { result: -1 } }],
Multiply: [{ name: 'multiplies', inputs: { a: 2, b: 3 }, outputs: { result: 6 } }],
Divide: [
{ name: 'divides', inputs: { a: 6, b: 4 }, outputs: { result: 1.5 } },
{ name: 'division by zero is 0, not Infinity', inputs: { a: 6, b: 0 }, outputs: { result: 0 } },
],
Min: [{ name: 'the smaller one', inputs: { a: 2, b: -3 }, outputs: { result: -3 } }],
Max: [{ name: 'the larger one', inputs: { a: 2, b: -3 }, outputs: { result: 2 } }],
Abs: [{ name: 'drops the sign', inputs: { value: -4 }, outputs: { result: 4 } }],
Sqrt: [
{ name: 'square root', inputs: { value: 9 }, outputs: { result: 3 } },
{ name: 'negative input is 0, not NaN', inputs: { value: -9 }, outputs: { result: 0 } },
],
Clamp: [
{
name: 'inside the range passes through',
inputs: { value: 0.4, min: 0, max: 1 },
outputs: { result: 0.4 },
},
{ name: 'above the range', inputs: { value: 5, min: 0, max: 1 }, outputs: { result: 1 } },
{ name: 'below the range', inputs: { value: -5, min: 0, max: 1 }, outputs: { result: 0 } },
],
Floor: [
{ name: 'rounds down', inputs: { value: 2.9 }, outputs: { result: 2 } },
{ name: 'rounds down for negatives too', inputs: { value: -2.1 }, outputs: { result: -3 } },
],
Ceil: [
{ name: 'rounds up', inputs: { value: 2.1 }, outputs: { result: 3 } },
{ name: 'a whole number stays put', inputs: { value: 3 }, outputs: { result: 3 } },
],
Round: [
{ name: 'rounds to nearest', inputs: { value: 2.4 }, outputs: { result: 2 } },
{ name: 'half rounds up', inputs: { value: 2.5 }, outputs: { result: 3 } },
{ name: 'negative half rounds towards zero', inputs: { value: -2.5 }, outputs: { result: -2 } },
],
Modulo: [
{ name: 'remainder', inputs: { a: 125, b: 60 }, outputs: { result: 5 } },
{ name: 'modulo zero is 0, not NaN', inputs: { a: 125, b: 0 }, outputs: { result: 0 } },
],
Random: [
// The spread is checked in "Random stays inside [min, max)" below; an empty range is the
// one case with a single right answer.
{ name: 'an empty range always yields min', inputs: { min: 3, max: 3 }, outputs: { result: 3 } },
],
Time: [
{
name: 'reports the clock and the frame length from the context',
context: { time: 12.5, dt: 0.016 },
outputs: { time: 12.5, dt: 0.016 },
},
],
// --- text and comparison -----------------------------------------------------------------------
ToText: [
{ name: 'formats with decimals', inputs: { value: 12.345, decimals: 1 }, outputs: { text: '12.3' } },
{ name: 'text passes through unformatted', inputs: { value: 'x', decimals: 2 }, outputs: { text: 'x' } },
{ name: 'decimals are clamped to 0…10', inputs: { value: 1, decimals: -5 }, outputs: { text: '1' } },
],
Join: [
{ name: 'sticks values together as text', inputs: { a: 'Coins: ', b: 3 }, outputs: { text: 'Coins: 3' } },
{
name: 'an object joins as its name',
inputs: { a: '', b: fakeEntity('Coin') },
outputs: { text: 'Coin' },
},
],
Equals: [
{ name: 'equal text', inputs: { a: 'a', b: 'a' }, outputs: { result: true } },
{ name: 'a number equals its text form', inputs: { a: 3, b: '3' }, outputs: { result: true } },
{ name: 'objects compare by identity, not by name', inputs: eqNamesakes(), outputs: { result: false } },
],
Compare: [
{ name: 'less than', inputs: { a: 1, b: 2 }, params: { op: '<' }, outputs: { result: true } },
{ name: 'greater or equal', inputs: { a: 2, b: 2 }, params: { op: '>=' }, outputs: { result: true } },
{ name: 'not equal', inputs: { a: 2, b: 2 }, params: { op: '!=' }, outputs: { result: false } },
{
name: 'no operator chosen falls back to the default "<"',
inputs: { a: 1, b: 2 },
outputs: { result: true },
},
],
And: [
{ name: 'both true', inputs: { a: true, b: true }, outputs: { result: true } },
{ name: 'one false', inputs: { a: true, b: false }, outputs: { result: false } },
{ name: 'an unconnected input reads as false', inputs: { a: true }, outputs: { result: false } },
],
Or: [
{ name: 'one true is enough', inputs: { a: false, b: true }, outputs: { result: true } },
{ name: 'both false', inputs: { a: false, b: false }, outputs: { result: false } },
],
Not: [
{ name: 'inverts', inputs: { value: true }, outputs: { result: false } },
{ name: 'inverts the other way', inputs: { value: false }, outputs: { result: true } },
{ name: '"0" as text counts as false', inputs: { value: '0' }, outputs: { result: true } },
],
// --- vectors and input --------------------------------------------------------------------------
Length: [
{ name: '3-4-5 triangle', inputs: { x: 3, y: 4 }, outputs: { result: 5 } },
{ name: 'the zero vector has length 0', inputs: { x: 0, y: 0 }, outputs: { result: 0 } },
],
Normalize: [
{
name: 'scales to length 1 and reports the old length',
inputs: { x: 0, y: -4 },
outputs: { x: 0, y: -1, length: 4 },
},
{
name: 'the zero vector stays zero instead of becoming NaN',
inputs: { x: 0, y: 0 },
outputs: { x: 0, y: 0, length: 0 },
},
],
Distance: [
{
name: 'distance between two objects',
inputs: { a: at('a', 0, 0), b: at('b', 3, 4) },
outputs: { result: 5 },
},
{ name: 'a missing object is distance 0', inputs: { a: at('a', 0, 0), b: null }, outputs: { result: 0 } },
],
AxisInput: [
{
name: 'one key gives a unit vector',
context: { isKeyDown: (key) => key === 'ArrowRight' },
outputs: { x: 1, y: 0, active: true },
},
{
name: 'diagonals are normalized, so they are not faster',
context: { isKeyDown: (key) => key === 'ArrowRight' || key === 'ArrowDown' },
// Not Math.SQRT1_2: the node divides by Math.hypot(1, 1), which lands one ulp lower.
outputs: { x: 1 / Math.hypot(1, 1), y: 1 / Math.hypot(1, 1), active: true },
},
{
name: 'opposite keys cancel out',
context: { isKeyDown: (key) => key === 'ArrowLeft' || key === 'ArrowRight' },
outputs: { x: 0, y: 0, active: false },
},
{ name: 'nothing held is inactive', outputs: { x: 0, y: 0, active: false } },
],
KeyIsDown: [
{
name: 'asks the context for the chosen key',
params: { key: 'a' },
context: { isKeyDown: (k) => k === 'a' },
outputs: { down: true },
},
{
name: 'another key is not down',
params: { key: 'b' },
context: { isKeyDown: (k) => k === 'a' },
outputs: { down: false },
},
{ name: 'no key chosen is never down', context: { isKeyDown: () => true }, outputs: { down: true } },
],
MousePosition: [
{
name: 'reports the pointer in world units',
context: pointer({ x: 40, y: -20, inside: true }),
outputs: { x: 40, y: -20, inside: true },
},
{
name: 'outside the camera frame the position is still reported, but "in view" is false',
context: pointer({ x: 5, y: 5, inside: false }),
outputs: { x: 5, y: 5, inside: false },
},
],
MouseButton: [
{
name: 'the frame the button goes down',
context: pointer({ down: true, pressed: true }),
outputs: { down: true, pressed: true, released: false },
},
{
name: 'a later frame while it is still held',
context: pointer({ down: true }),
outputs: { down: true, pressed: false, released: false },
},
{
name: 'the frame it goes up',
context: pointer({ released: true }),
outputs: { down: false, pressed: false, released: true },
},
],
IsHovered: [
{
name: 'the pointer is over the object',
inputs: { object: fakeEntity('Button') },
context: { ...pointer({ inside: true }), hitTest: () => true },
outputs: { result: true },
},
{
name: 'a hit outside the camera frame does not count',
inputs: { object: fakeEntity('Button') },
context: { ...pointer({ inside: false }), hitTest: () => true },
outputs: { result: false },
},
{
name: 'no object is never hovered',
inputs: { object: null },
context: { ...pointer({ inside: true }), hitTest: () => true },
outputs: { result: false },
},
],
IsTouching: [
{
name: 'asks the context',
inputs: { a: fakeEntity('a'), b: fakeEntity('b') },
context: { isTouching: () => true },
outputs: { result: true },
},
{
name: 'a missing object never touches',
inputs: { a: fakeEntity('a'), b: null },
context: { isTouching: () => true },
outputs: { result: false },
},
],
// --- actions --------------------------------------------------------------------------------------
Move: [
{
name: 'moves by (dx, dy) × scale × dt',
inputs: { object: at('m', 0, 0), dx: 1, dy: 0, scale: 100, dt: 0.5 },
next: 'exec',
outputs: { blockedX: false, blockedY: false },
},
{
name: 'no object is a no-op that still continues the flow',
inputs: { object: null, dx: 1, dy: 1 },
next: 'exec',
outputs: { blockedX: false, blockedY: false },
},
],
SetPosition: [
{
name: 'places the object and continues',
inputs: { object: at('p', 0, 0), x: 30, y: -40 },
next: 'exec',
},
{ name: 'no object is a no-op', inputs: { object: null, x: 1, y: 1 }, next: 'exec' },
],
SetProperty: [
{
name: 'writes a property, keeping the existing type',
inputs: { object: fakeHandle({ width: 100 }), value: '250' },
params: { property: 'width' },
next: 'exec',
},
{
name: 'no property name chosen writes nothing',
inputs: { object: fakeHandle({ width: 100 }), value: 5 },
next: 'exec',
},
],
SetEnabled: [
{
name: 'switches an object off',
inputs: { object: fakeHandle({ enabled: true }), enabled: false },
next: 'exec',
},
{ name: 'no object is a no-op', inputs: { object: null, enabled: false }, next: 'exec' },
],
SetText: [
{
name: 'writes the content of a text object',
inputs: { object: fakeHandle({ text: 'a' }), text: 12 },
next: 'exec',
},
],
Spawn: [
{
name: 'no asset chosen logs a hint and continues with no object',
next: 'exec',
outputs: { object: null },
after: (ctx) => expect(ctx.logs).toEqual(['Spawn: no asset selected']),
},
],
Destroy: [
{
name: 'no object is a no-op that still continues the flow',
inputs: { object: null },
next: 'exec',
},
],
Log: [
{
name: 'logs its message and continues',
inputs: { message: 'hi' },
next: 'exec',
after: (ctx) => expect(ctx.logs).toEqual(['hi']),
},
{
name: 'an object logs as its name',
inputs: { message: fakeEntity('Coin') },
next: 'exec',
after: (ctx) => expect(ctx.logs).toEqual(['Coin']),
},
],
StopGame: [{ name: 'ends play and the flow', next: null, after: (ctx) => expect(ctx.stopped).toBe(true) }],
RestartGame: [{ name: 'ends the flow – the world restarts before the next tick', next: null }],
};
/** Two distinct handles that share a name – `Equals` must still tell them apart. */
function eqNamesakes(): Record<string, Value> {
return { a: fakeEntity('Coin'), b: fakeEntity('Coin') };
}
/**
* Nodes without a `run`: the interpreter seeds their outputs when the BehaviorSystem fires them,
* so they are only observable through a running world. The value says which test drives it.
*/
const EVENT_NODES: Record<string, string> = {
OnStart: 'graph.test.ts',
OnUpdate: 'graph.test.ts',
OnKeyDown: 'behavior-events.test.ts',
OnKeyUp: 'behavior-events.test.ts',
OnKeyHeld: 'behavior-events.test.ts',
OnTimer: 'behavior-events.test.ts',
OnCollisionEnter: 'collider.test.ts',
OnCollisionStay: 'behavior-events.test.ts',
OnCollisionExit: 'collider.test.ts',
OnClick: 'nodes.test.ts',
};
describe('node catalog', () => {
it('covers every built-in node', () => {
const uncovered = BUILT_IN_NODES.filter((def) =>
def.run ? !CASES[def.type] : !EVENT_NODES[def.type],
).map((def) => def.type);
expect(uncovered).toEqual([]);
});
it('lists exactly the run-less nodes as events', () => {
const runless = BUILT_IN_NODES.filter((def) => !def.run).map((def) => def.type);
expect(Object.keys(EVENT_NODES).sort()).toEqual(runless.sort());
});
it('declares well-formed ports and settings', () => {
for (const def of BUILT_IN_NODES) {
const keys = [...def.inputs, ...def.outputs].map((p) => p.key);
expect(def.label, `${def.type} has a label`).not.toBe('');
expect(def.description, `${def.type} has a description`).not.toBe('');
// Inputs and outputs are addressed by key within their own side; a key may repeat across
// sides (Normalize reads x/y and writes x/y), never within one.
expect(new Set(def.inputs.map((p) => p.key)).size, `${def.type} input keys unique`).toBe(
def.inputs.length,
);
expect(new Set(def.outputs.map((p) => p.key)).size, `${def.type} output keys unique`).toBe(
def.outputs.length,
);
expect(
keys.every((k) => k !== ''),
`${def.type} has no empty port key`,
).toBe(true);
for (const param of def.params) {
expect(param.default, `${def.type}.${param.key} has a default`).toBeDefined();
}
}
});
});
for (const [type, cases] of Object.entries(CASES)) {
describe(type, () => {
for (const c of cases) {
it(c.name, () => {
const { result, ctx } = run(type, c);
if (c.outputs) expect(result.outputs ?? {}).toEqual(c.outputs);
if (c.next !== undefined) expect(result.next ?? null).toEqual(c.next);
c.after?.(ctx);
});
}
});
}
// Cases whose point is the mutation they cause – stated here rather than squeezed into the table.
describe('action nodes write through to the object they are given', () => {
it('Move applies dx/dy × scale × dt', () => {
const target = at('m', 0, 0);
requireNodeDef('Move').run!(fakeContext(), { object: target, dx: 1, dy: -2, scale: 100, dt: 0.5 }, {});
expect({ x: target.x, y: target.y }).toEqual({ x: 50, y: -100 });
});
it('SetPosition places the object absolutely', () => {
const target = at('p', 10, 10);
requireNodeDef('SetPosition').run!(fakeContext(), { object: target, x: 30, y: -40 }, {});
expect({ x: target.x, y: target.y }).toEqual({ x: 30, y: -40 });
});
it('SetProperty keeps the type the property already had', () => {
const target = fakeHandle({ width: 100 });
requireNodeDef('SetProperty').run!(
fakeContext(),
{ object: target, value: '250' },
{ property: 'width' },
);
expect(target.props.width).toBe(250);
});
it('SetProperty without a property name writes nothing', () => {
const target = fakeHandle({ width: 100 });
requireNodeDef('SetProperty').run!(fakeContext(), { object: target, value: 5 }, {});
expect(target.props).toEqual({ width: 100 });
});
it('SetEnabled switches the object off', () => {
const target = fakeHandle({ enabled: true });
requireNodeDef('SetEnabled').run!(fakeContext(), { object: target, enabled: false }, {});
expect(target.props.enabled).toBe(false);
});
it('SetText writes the text property, stringifying numbers', () => {
const target = fakeHandle({ text: 'a' });
requireNodeDef('SetText').run!(fakeContext(), { object: target, text: 12 }, {});
expect(target.props.text).toBe('12');
});
it('Destroy hands the object to the context', () => {
const removed: string[] = [];
const ctx = fakeContext({ destroy: (target) => removed.push(target.name) });
requireNodeDef('Destroy').run!(ctx, { object: fakeEntity('Coin') }, {});
expect(removed).toEqual(['Coin']);
});
it('RestartGame asks the context for a restart', () => {
let restarted = false;
const ctx = fakeContext({
restart: () => {
restarted = true;
},
});
requireNodeDef('RestartGame').run!(ctx, {}, {});
expect(restarted).toBe(true);
});
it('Random stays inside [min, max) over many draws', () => {
const ctx = fakeContext();
const draws = Array.from(
{ length: 200 },
() => requireNodeDef('Random').run!(ctx, { min: 5, max: 10 }, {}).outputs!.result as number,
);
expect(draws.every((n) => n >= 5 && n < 10)).toBe(true);
// Not a constant: a generator stuck on one value would pass the range check above.
expect(new Set(draws).size).toBeGreaterThan(1);
});
});
+15 -33
View File
@@ -1,39 +1,18 @@
import { describe, expect, it } from 'vitest';
import { type Graph, GraphInterpreter } from '../../src/core/graph';
import { fakeContext, fakeEntity, fakeHandle, graphEditor, makeBehavior, makeSpriteScene, place, runNode, startWorld } from '../helpers';
import {
fakeContext,
fakeEntity,
graphEditor,
makeBehavior,
makeSpriteScene,
place,
startWorld,
} from '../helpers';
// Node-by-node behaviour lives in node-catalog.test.ts; what is left here needs a running
// world (On click's hit test) or a whole flow (Spawn feeding its object downstream).
describe('game-flow nodes', () => {
it('formats numbers and joins text', () => {
expect(runNode('ToText', { value: 12.345, decimals: 1 })).toEqual({ text: '12.3' });
expect(runNode('ToText', { value: 'x', decimals: 0 })).toEqual({ text: 'x' });
expect(runNode('Join', { a: 'Coins: ', b: 3 })).toEqual({ text: 'Coins: 3' });
expect(runNode('Modulo', { a: 125, b: 60 })).toEqual({ result: 5 });
expect(runNode('Modulo', { a: 5, b: 0 })).toEqual({ result: 0 });
expect(runNode('Floor', { value: 2.9 })).toEqual({ result: 2 });
});
it('compares values and identifies assets', () => {
const coin = fakeHandle({});
coin.assetId = 'coin';
expect(runNode('IsAsset', { object: coin }, { asset: 'coin' })).toEqual({ result: true });
expect(runNode('IsAsset', { object: coin }, { asset: 'other' })).toEqual({ result: false });
expect(runNode('IsAsset', { object: null }, { asset: 'coin' })).toEqual({ result: false });
expect(runNode('Equals', { a: 'a', b: 'a' })).toEqual({ result: true });
expect(runNode('Equals', { a: coin, b: coin })).toEqual({ result: true });
expect(runNode('Equals', { a: coin, b: null })).toEqual({ result: false });
expect(runNode('NameOf', { object: coin })).toEqual({ name: 'h' });
});
it('switches objects on and off and asks for a restart', () => {
const target = fakeHandle({ enabled: true });
runNode('SetEnabled', { object: target, enabled: false });
expect(target.props.enabled).toBe(false);
let restarted = false;
runNode('RestartGame', {}, {}, fakeContext({ restart: () => { restarted = true; } }));
expect(restarted).toBe(true);
});
it('fires On click when the mouse is pressed on the owner', () => {
const f = makeSpriteScene('Button');
const behavior = makeBehavior(f, 'OnPress', ['clicks']);
@@ -101,7 +80,10 @@ describe('Spawn node', () => {
it('logs a hint and continues when no asset is selected or the asset is unknown', () => {
const ctx = fakeContext({ spawn: () => null });
const noAsset: Graph = { ...graph, nodes: graph.nodes.map((n) => (n.id === 'spawn' ? { ...n, params: {} } : n)) };
const noAsset: Graph = {
...graph,
nodes: graph.nodes.map((n) => (n.id === 'spawn' ? { ...n, params: {} } : n)),
};
new GraphInterpreter(noAsset).fire('OnStart', ctx);
expect(ctx.logs).toEqual(['Spawn: no asset selected', '']);
const ctx2 = fakeContext({ spawn: () => null });
+6 -1
View File
@@ -48,7 +48,12 @@ describe('resolve', () => {
const sprite = buildAsset('sprite', 'Box');
sprite.props.color = 0x00ff00;
const object = createSceneObject('o1', sprite.id, { x: 10, y: 20 });
expect(resolveSceneObjectProps(sprite, object)).toEqual({ enabled: true, width: 100, height: 100, color: 0x00ff00 });
expect(resolveSceneObjectProps(sprite, object)).toEqual({
enabled: true,
width: 100,
height: 100,
color: 0x00ff00,
});
object.overrides.color = 0x0000ff;
expect(resolveSceneObjectProps(sprite, object).color).toBe(0x0000ff);
+14 -3
View File
@@ -17,11 +17,22 @@ describe('serialize', () => {
name: 'Broken',
activeSceneId: 'missing',
assets: [
{ id: 's1', type: 'scene', name: 'Scene', props: { backgroundColor: '#000000', junk: 1 }, objects: [
{ id: 'o1', assetId: 'sp1', position: { x: '5', y: null }, overrides: { color: '#00ff00', width: -3, foo: 1 } },
{
id: 's1',
type: 'scene',
name: 'Scene',
props: { backgroundColor: '#000000', junk: 1 },
objects: [
{
id: 'o1',
assetId: 'sp1',
position: { x: '5', y: null },
overrides: { color: '#00ff00', width: -3, foo: 1 },
},
{ id: 'o2', assetId: 'nope', position: {} },
{ id: 'o3', assetId: 's1', position: {} },
] },
],
},
{ id: 'sp1', type: 'sprite', props: { width: 10 } },
{ id: 'x', type: 'texture' },
{ type: 'camera' },
+6 -1
View File
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest';
import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { ClearOverrideCommand, RemoveSceneObjectCommand, SetOverrideCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands';
import {
ClearOverrideCommand,
RemoveSceneObjectCommand,
SetOverrideCommand,
SetParentCommand,
} from '../../src/core/commands/sceneObjectCommands';
import { findScene } from '../../src/core/model/Project';
import { World } from '../../src/runtime/World';
import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
+70 -12
View File
@@ -3,7 +3,14 @@ import { CommandStack } from '../src/core/commands/CommandStack';
import { AddNodeCommand, ConnectCommand } from '../src/core/commands/graphCommands';
import { PlaceAssetCommand } from '../src/core/commands/sceneObjectCommands';
import { AddVariableCommand } from '../src/core/commands/variableCommands';
import { type EntityHandle, type GraphNode, type NodeContext, type PointerState, type Value, requireNodeDef } from '../src/core/graph';
import {
type EntityHandle,
type GraphNode,
type NodeContext,
type PointerState,
type Value,
requireNodeDef,
} from '../src/core/graph';
import type { BehaviorAsset, SceneAsset } from '../src/core/model/Asset';
import { findObject, worldPosition } from '../src/core/model/hierarchy';
import type { Position, PropertyBag, Vec2 } from '../src/core/model/Properties';
@@ -45,18 +52,34 @@ export function makeSpriteScene(spriteName = 'Box'): SceneFixture {
fixture.stack.execute(sprite);
const sceneOf = () => findScene(fixture.project, scene.asset.id)!;
const object = (id: string) => findObject(sceneOf(), id)!;
return { ...fixture, sceneId: scene.asset.id, spriteId: sprite.asset.id, sceneOf, object, world: (id) => worldPosition(sceneOf(), object(id)) };
return {
...fixture,
sceneId: scene.asset.id,
spriteId: sprite.asset.id,
sceneOf,
object,
world: (id) => worldPosition(sceneOf(), object(id)),
};
}
/** Places an asset into the fixture's scene at a world position (optionally below a parent) and returns the new object's id. */
export function place(fixture: SceneFixture, assetId: string, position: Vec2 | Position, parentId = ''): string {
export function place(
fixture: SceneFixture,
assetId: string,
position: Vec2 | Position,
parentId = '',
): string {
const command = new PlaceAssetCommand(fixture.sceneId, assetId, position, parentId);
fixture.stack.execute(command);
return command.objectId;
}
/** Adds a behavior asset declaring the given number variables. */
export function makeBehavior(fixture: ProjectFixture, name = 'B', variables: readonly string[] = []): BehaviorAsset {
export function makeBehavior(
fixture: ProjectFixture,
name = 'B',
variables: readonly string[] = [],
): BehaviorAsset {
const command = new CreateAssetCommand('behavior', name);
fixture.stack.execute(command);
for (const key of variables) fixture.stack.execute(new AddVariableCommand(command.asset.id, key, 'number'));
@@ -76,7 +99,9 @@ export interface GraphEditor {
export function graphEditor(fixture: ProjectFixture, behavior: BehaviorAsset): GraphEditor {
const { stack } = fixture;
const connect: GraphEditor['connect'] = (from, fromPort, to, toPort) => {
stack.execute(new ConnectCommand(behavior.id, { node: from.id, port: fromPort }, { node: to.id, port: toPort }));
stack.execute(
new ConnectCommand(behavior.id, { node: from.id, port: fromPort }, { node: to.id, port: toPort }),
);
};
return {
addNode(type, params = {}) {
@@ -91,7 +116,14 @@ export function graphEditor(fixture: ProjectFixture, behavior: BehaviorAsset): G
};
}
export const NO_POINTER: PointerState = { x: 0, y: 0, inside: false, down: false, pressed: false, released: false };
export const NO_POINTER: PointerState = {
x: 0,
y: 0,
inside: false,
down: false,
pressed: false,
released: false,
};
/** A top-level entity handle named after itself, with empty props. */
export function fakeEntity(name: string, x = 0, assetId = `asset-${name}`): EntityHandle {
@@ -139,7 +171,12 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
}
/** Runs one node definition in isolation and returns its data outputs. */
export function runNode(type: string, inputs: Record<string, Value>, params: PropertyBag = {}, ctx: NodeContext = fakeContext()): Record<string, Value> {
export function runNode(
type: string,
inputs: Record<string, Value>,
params: PropertyBag = {},
ctx: NodeContext = fakeContext(),
): Record<string, Value> {
return requireNodeDef(type).run!(ctx, inputs, params).outputs ?? {};
}
@@ -148,15 +185,36 @@ export interface WorldOptions {
behaviors?: boolean;
}
export interface StartedWorld {
world: World;
collisions: CollisionSystem | null;
/** Feed keys with `input.press('a')`; `frame()` clears the edges the way the editor does. */
input: Input;
/** One tick plus the end-of-frame bookkeeping the editor's play loop performs. */
frame(dt?: number): void;
}
/** Instantiates the fixture's scene, optionally with a CollisionSystem and a BehaviorSystem (fed by those collisions). */
export function startWorld(fixture: SceneFixture, options: WorldOptions & { collisions: true }): { world: World; collisions: CollisionSystem };
export function startWorld(fixture: SceneFixture, options?: WorldOptions): { world: World; collisions: CollisionSystem | null };
export function startWorld(fixture: SceneFixture, options: WorldOptions = {}): { world: World; collisions: CollisionSystem | null } {
export function startWorld(
fixture: SceneFixture,
options: WorldOptions & { collisions: true },
): StartedWorld & { collisions: CollisionSystem };
export function startWorld(fixture: SceneFixture, options?: WorldOptions): StartedWorld;
export function startWorld(fixture: SceneFixture, options: WorldOptions = {}): StartedWorld {
const world = World.instantiate(fixture.project, fixture.sceneId);
const collisions = options.collisions ? new CollisionSystem() : null;
const input = new Input();
if (collisions) world.addSystem(collisions);
if (options.behaviors) world.addSystem(new BehaviorSystem(fixture.project, new Input(), collisions));
return { world, collisions };
if (options.behaviors) world.addSystem(new BehaviorSystem(fixture.project, input, collisions));
return {
world,
collisions,
input,
frame(dt = 1 / 60) {
world.tick(dt);
input.endFrame();
},
};
}
/** The project as it comes back after being saved and loaded again. */
+9 -2
View File
@@ -3,7 +3,14 @@
* checked through the browser. They are plain geometry, so they are pinned here.
*/
import { describe, expect, it } from 'vitest';
import { cameraView, freeView, isInside, screenToWorld, worldBounds, worldToScreen } from '../../src/viewport/view';
import {
cameraView,
freeView,
isInside,
screenToWorld,
worldBounds,
worldToScreen,
} from '../../src/viewport/view';
describe('freeView', () => {
it('puts the view centre in the middle of the widget', () => {
@@ -19,7 +26,7 @@ describe('freeView', () => {
it('round-trips any point', () => {
const view = freeView(13, -7, 0.35, 640, 480);
const back = screenToWorld(view, ...Object.values(worldToScreen(view, 123, -456)) as [number, number]);
const back = screenToWorld(view, ...(Object.values(worldToScreen(view, 123, -456)) as [number, number]));
expect(back.x).toBeCloseTo(123, 9);
expect(back.y).toBeCloseTo(-456, 9);
});
+15 -3
View File
@@ -11,7 +11,11 @@ export async function hierarchy(t) {
const rows = async () => JSON.stringify(await rowIds());
const selectionBefore = (await state()).selection;
check('hierarchy starts collapsed – only top-level objects are listed', same(await rowIds(), ['o1', 'o2', 'o3', 'o5']), await rows());
check(
'hierarchy starts collapsed – only top-level objects are listed',
same(await rowIds(), ['o1', 'o2', 'o3', 'o5']),
await rows(),
);
check('a leaf row offers no chevron', (await evaluate(`__gm.hierarchyChevron('o3')`)) === null);
const chevron = await json(`__gm.hierarchyChevron('o2')`);
await click(chevron.x, chevron.y);
@@ -22,10 +26,18 @@ export async function hierarchy(t) {
);
await shot('01a-hierarchy-unfolded');
await click(chevron.x, chevron.y);
check('clicking it again folds the subtree away', same(await rowIds(), ['o1', 'o2', 'o3', 'o5']), await rows());
check(
'clicking it again folds the subtree away',
same(await rowIds(), ['o1', 'o2', 'o3', 'o5']),
await rows(),
);
await select({ kind: 'object', sceneId: 'scene1', objectId: 'oc1' });
await sleep(150);
check('selecting a nested object unfolds its ancestors', same(await rowIds(), ['o1', 'o4', 'oc1', 'o2', 'o3', 'o5']), await rows());
check(
'selecting a nested object unfolds its ancestors',
same(await rowIds(), ['o1', 'o4', 'oc1', 'o2', 'o3', 'o5']),
await rows(),
);
await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()');
await sleep(150);
check('Collapse all folds everything back', same(await rowIds(), ['o1', 'o2', 'o3', 'o5']), await rows());
+39 -5
View File
@@ -3,12 +3,34 @@
* the server, and undo/redo of all of it.
*/
export async function inspector(t) {
const { check, sleep, json, locate, shot, click, clickText, drag, press, typeText, undo, redo, state, object, api, sceneOf, projectId } = t;
const {
check,
sleep,
json,
locate,
shot,
click,
clickText,
drag,
press,
typeText,
undo,
redo,
state,
object,
api,
sceneOf,
projectId,
} = t;
// 1. Select the blue crate in the hierarchy.
await clickText('Blue crate');
let s = await state();
check('hierarchy click selects object', s.selection?.kind === 'object' && s.selection.objectId === 'o2', JSON.stringify(s.selection));
check(
'hierarchy click selects object',
s.selection?.kind === 'object' && s.selection.objectId === 'o2',
JSON.stringify(s.selection),
);
await shot('02-object-selected');
// 2. Rename via inspector: the name field sits right of the "Name" label.
@@ -19,7 +41,11 @@ export async function inspector(t) {
await shot('03-typing');
await press('Enter');
await sleep(200);
check('typing + Enter renamed the object', (await object('o2')).name === 'Renamed crate', JSON.stringify((await object('o2')).name));
check(
'typing + Enter renamed the object',
(await object('o2')).name === 'Renamed crate',
JSON.stringify((await object('o2')).name),
);
await shot('04-renamed');
// 3. Scrub the X field (right of the "X" label) by 100 px.
@@ -37,7 +63,11 @@ export async function inspector(t) {
await click(center.x, center.y + 250);
const xTyped = (await object('o2')).position.x;
s = await state();
check('typed X survives clicking into the viewport', xTyped === 300 && s.selection === null, `x=${xTyped} selection=${JSON.stringify(s.selection)}`);
check(
'typed X survives clicking into the viewport',
xTyped === 300 && s.selection === null,
`x=${xTyped} selection=${JSON.stringify(s.selection)}`,
);
// 4. Drag the red crate in the viewport. It is at world (-150,0); the view is centred
// on the origin, so compute its screen position from the viewport centre.
@@ -59,7 +89,11 @@ export async function inspector(t) {
await click(center.x, center.y + 200); // click empty viewport → deselect, drop focus
for (let i = 0; i < 4; i++) await undo();
const afterUndo = (await t.objects()).map((o) => ({ id: o.id, name: o.name, x: o.position.x }));
check('undo reverted drag, typed X, scrub and rename', afterUndo[0].x === -150 && afterUndo[1].x === 120 && afterUndo[1].name === 'Blue crate', JSON.stringify(afterUndo));
check(
'undo reverted drag, typed X, scrub and rename',
afterUndo[0].x === -150 && afterUndo[1].x === 120 && afterUndo[1].name === 'Blue crate',
JSON.stringify(afterUndo),
);
await redo();
check('redo restored the rename', (await object('o2')).name === 'Renamed crate');
await shot('07-undo-redo');
+89 -15
View File
@@ -7,7 +7,25 @@ const FIXTURE_IDS = ['o1', 'o2', 'o3', 'o4', 'o5', 'oc1', 'oc2'];
* camera.
*/
export async function play(t) {
const { check, sleep, evaluate, json, shot, clickText, press, holdKey, undo, state, object, objects, world, entity, select, mutate, stop } = t;
const {
check,
sleep,
evaluate,
json,
shot,
clickText,
press,
holdKey,
undo,
state,
object,
objects,
world,
entity,
select,
mutate,
stop,
} = t;
const gameInfo = () => json('__gm.gameInfo()');
const play = t.play;
@@ -20,40 +38,72 @@ export async function play(t) {
let s = await state();
check('play mode entered', s.mode === 'play', JSON.stringify(s));
let info = await gameInfo();
check('play renders the camera frame with both crates', info.rendering && info.visibleItems === 2, JSON.stringify(info));
check(
'play renders the camera frame with both crates',
info.rendering && info.visibleItems === 2,
JSON.stringify(info),
);
await shot('08-playing');
await holdKey('ArrowRight', 400);
const movedCrate = await entity('o1');
check('behavior graph moved its owner while ArrowRight was held', movedCrate.x > -150 + 30, `world x=${movedCrate.x}`);
check(
'behavior graph moved its owner while ArrowRight was held',
movedCrate.x > -150 + 30,
`world x=${movedCrate.x}`,
);
await holdKey('ArrowLeft', 250);
const afterLeft = await entity('o1');
check('ArrowLeft (axis x = −1) moved the owner back left', afterLeft.x < movedCrate.x, `world x=${afterLeft.x}`);
check(
'ArrowLeft (axis x = −1) moved the owner back left',
afterLeft.x < movedCrate.x,
`world x=${afterLeft.x}`,
);
await stop();
s = await state();
check('escape stopped play', s.mode === 'edit', JSON.stringify(s));
await shot('09-stopped');
const o2AfterPlay = (await object('o2')).position.x;
const o1AfterPlay = (await object('o1')).position.x;
check('play did not write back to the model', o2AfterPlay === xBeforePlay && o1AfterPlay === -150, `${o2AfterPlay} vs ${xBeforePlay}, o1=${o1AfterPlay}`);
check(
'play did not write back to the model',
o2AfterPlay === xBeforePlay && o1AfterPlay === -150,
`${o2AfterPlay} vs ${xBeforePlay}, o1=${o1AfterPlay}`,
);
// 7a. Editing while playing: Play opens the Game tab, the panels stay open and edit a runtime
// copy. Moving the blue crate (model hook + preview, as a scrub does) and deleting it with
// the Delete key change the world at once, undo brings it back, Esc restores the saved model.
await play();
check('play opens the Game tab', (await evaluate('__gm.tab()')) === 'game');
check('panels stay visible while playing', Boolean(await evaluate(`__gm.locate('INSPECTOR')`)) && (await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 7);
check(
'panels stay visible while playing',
Boolean(await evaluate(`__gm.locate('INSPECTOR')`)) &&
(await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 7,
);
await select({ kind: 'object', sceneId: 'scene1', objectId: 'o2' });
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 400`);
let liveO2 = await entity('o2');
check('an edit while playing moves the entity in the world', liveO2 && liveO2.x === 400, JSON.stringify(liveO2));
check(
'an edit while playing moves the entity in the world',
liveO2 && liveO2.x === 400,
JSON.stringify(liveO2),
);
await shot('08b-editing-while-playing');
await press('Delete');
await sleep(150);
const liveIds = (await world()).entities.map((e) => e.objectId);
check('Delete while playing removes the entity and its collider child from the world', !liveIds.includes('o2') && !liveIds.includes('oc2') && liveIds.length === 5, JSON.stringify(liveIds));
check(
'Delete while playing removes the entity and its collider child from the world',
!liveIds.includes('o2') && !liveIds.includes('oc2') && liveIds.length === 5,
JSON.stringify(liveIds),
);
await undo();
liveO2 = await entity('o2');
check('undo while playing brings the entity back into the world', liveO2 && liveO2.x === 400, JSON.stringify(liveO2));
check(
'undo while playing brings the entity back into the world',
liveO2 && liveO2.x === 400,
JSON.stringify(liveO2),
);
// 7a-ii. Spawning: the "Spawner" behavior places a Crate at (0, 150) on Space. The new object
// shows up in the runtime scene (hierarchy), in the world and in the Game view at once;
@@ -65,18 +115,38 @@ export async function play(t) {
const spawned = spawnedObjects.find((o) => !FIXTURE_IDS.includes(o.id));
check(
'Spawn node adds a crate object to the runtime scene at (0, 150)',
spawned && spawned.assetId === 'crate' && spawned.parentId === '' && spawned.position.x === 0 && spawned.position.y === 150 && spawnedObjects.length === 8,
spawned &&
spawned.assetId === 'crate' &&
spawned.parentId === '' &&
spawned.position.x === 0 &&
spawned.position.y === 150 &&
spawnedObjects.length === 8,
JSON.stringify(spawned),
);
const spawnWorld = await world();
check('the spawned entity lives in the world and its name reached the Log node', spawnWorld.entities.length === 8 && spawnWorld.log.includes('Crate'), JSON.stringify({ n: spawnWorld.entities.length, log: spawnWorld.log }));
check('hierarchy lists the spawned object', (await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 8);
check(
'the spawned entity lives in the world and its name reached the Log node',
spawnWorld.entities.length === 8 && spawnWorld.log.includes('Crate'),
JSON.stringify({ n: spawnWorld.entities.length, log: spawnWorld.log }),
);
check(
'hierarchy lists the spawned object',
(await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 8,
);
info = await gameInfo();
check('the Game view renders the spawned crate', info.visibleItems === visibleBeforeSpawn + 1, `${JSON.stringify(info)} before=${visibleBeforeSpawn}`);
check(
'the Game view renders the spawned crate',
info.visibleItems === visibleBeforeSpawn + 1,
`${JSON.stringify(info)} before=${visibleBeforeSpawn}`,
);
await shot('08c-spawned');
await stop();
const o2AfterEdit = (await object('o2')).position.x;
check('stop restores the saved model untouched by play-mode edits (spawned object gone)', o2AfterEdit === xBeforePlay && (await objects()).length === 7, `x=${o2AfterEdit}`);
check(
'stop restores the saved model untouched by play-mode edits (spawned object gone)',
o2AfterEdit === xBeforePlay && (await objects()).length === 7,
`x=${o2AfterEdit}`,
);
check('stop returns to the Scene tab', (await evaluate('__gm.tab()')) === 'scene');
// 7b. Culling: move the blue crate far outside the camera frame → only one item visible.
@@ -95,7 +165,11 @@ export async function play(t) {
await play();
info = await gameInfo();
s = await state();
check('play without camera renders nothing', s.mode === 'play' && !info.rendering && info.visibleItems === 0, JSON.stringify(info));
check(
'play without camera renders nothing',
s.mode === 'play' && !info.rendering && info.visibleItems === 0,
JSON.stringify(info),
);
check('play without camera shows a notice', s.notice.includes('no camera'), s.notice);
await shot('10-play-no-camera');
await stop();
+10 -2
View File
@@ -28,10 +28,18 @@ export async function assets(t) {
check('asset inspector shows a reset for the non-default height', Boolean(resetButton));
await click(resetButton.x, resetButton.y);
await sleep(100);
check('reset puts the asset property back to its default', (await asset('crate')).props.height === 100, `height=${(await asset('crate')).props.height}`);
check(
'reset puts the asset property back to its default',
(await asset('crate')).props.height === 100,
`height=${(await asset('crate')).props.height}`,
);
check('reset button hidden once the value is at default', !(await evaluate(`__gm.locate('↺')`)));
await shot('15-asset-reset');
await undo();
check('undo restores the asset value', (await asset('crate')).props.height === 60, `height=${(await asset('crate')).props.height}`);
check(
'undo restores the asset value',
(await asset('crate')).props.height === 60,
`height=${(await asset('crate')).props.height}`,
);
check('reset button visible again after undo', Boolean(await evaluate(`__gm.locate('↺')`)));
}
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff. Show more