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:
1 parent
f140c2fd77
commit
d54a620ebd
113 files changed
+4985
-602
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
Generated
+1276
File diff suppressed because it is too large.
Load diff
+12
-1
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ?? {};
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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)) } };
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)) {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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 -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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 + '…';
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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"))
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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
@@ -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
Reference in new issue
Block a user