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
+5049
-666
No files matched your search
@@ -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)}`, {
|
||||
revision,
|
||||
project: serializeProject(project),
|
||||
});
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user