Add ESLint and Prettier, and close the node test gaps

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

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

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

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

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

No files matched your search

+4 -1
View File
@@ -6,7 +6,10 @@ export class CommandStack {
private readonly undoStack: Command[] = [];
private readonly redoStack: Command[] = [];
constructor(private readonly project: Project, private readonly limit = 200) {}
constructor(
private readonly project: Project,
private readonly limit = 200,
) {}
execute(command: Command): void {
command.execute(this.project);
+4 -1
View File
@@ -7,7 +7,10 @@ import type { Command } from './Command';
* so the project is never left half-changed.
*/
export class CompoundCommand implements Command {
constructor(readonly label: string, private readonly commands: readonly Command[]) {}
constructor(
readonly label: string,
private readonly commands: readonly Command[],
) {}
execute(project: Project): void {
const done: Command[] = [];
+4 -1
View File
@@ -15,7 +15,10 @@ export abstract class SetBagValueCommand<T> implements Command {
readonly label: string;
private previous: PropertyValue | undefined;
protected constructor(protected readonly key: string, label: string) {
protected constructor(
protected readonly key: string,
label: string,
) {
this.label = label;
}
+42 -8
View File
@@ -55,9 +55,18 @@ interface SlotMemo<T> {
* with the template along – copies the user changed on their own keep their value. Returns
* the memos in application order; `restoreSlots` undoes them.
*/
function applyToTemplate<T>(project: Project, link: TemplateLink, slots: readonly Slot<T>[], values: readonly T[]): SlotMemo<T>[] {
function applyToTemplate<T>(
project: Project,
link: TemplateLink,
slots: readonly Slot<T>[],
values: readonly T[],
): SlotMemo<T>[] {
const before = slots.map((slot) => slot.read(link.template));
const memos: SlotMemo<T>[] = slots.map((slot, i) => ({ object: link.template, slot, previous: before[i]! }));
const memos: SlotMemo<T>[] = slots.map((slot, i) => ({
object: link.template,
slot,
previous: before[i]!,
}));
for (const { object } of instancesOf(project, link.template.id)) {
slots.forEach((slot, i) => {
if (slot.read(object) !== before[i]) return;
@@ -84,7 +93,11 @@ export class ApplyOverrideToAssetCommand implements Command {
private previousOverride: PropertyValue | undefined;
private templateMemos: SlotMemo<PropertyValue | undefined>[] = [];
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly key: string) {
constructor(
private readonly sceneId: string,
private readonly objectId: string,
private readonly key: string,
) {
this.label = `Apply ${key} to asset`;
}
@@ -116,7 +129,11 @@ export class ApplyOverrideToContainerCommand implements Command {
readonly label: string;
private memos: SlotMemo<PropertyValue | undefined>[] = [];
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly key: string) {
constructor(
private readonly sceneId: string,
private readonly objectId: string,
private readonly key: string,
) {
this.label = `Apply ${key} to container`;
}
@@ -136,7 +153,11 @@ export class RevertOverrideToTemplateCommand implements Command {
readonly label: string;
private previous: PropertyValue | undefined;
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly key: string) {
constructor(
private readonly sceneId: string,
private readonly objectId: string,
private readonly key: string,
) {
this.label = `Revert ${key}`;
}
@@ -160,7 +181,11 @@ export class ApplyPositionToContainerCommand implements Command {
readonly label: string;
private memos: SlotMemo<number>[] = [];
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly axis?: Axis) {
constructor(
private readonly sceneId: string,
private readonly objectId: string,
private readonly axis?: Axis,
) {
this.label = axis ? `Apply ${axis} to container` : 'Apply position to container';
}
@@ -168,7 +193,12 @@ export class ApplyPositionToContainerCommand implements Command {
const object = requireObject(project, this.sceneId, this.objectId);
const link = requireLink(project, this.sceneId, object);
const axes = this.axis ? [this.axis] : ALL_AXES;
this.memos = applyToTemplate(project, link, axes.map(positionSlot), axes.map((a) => object.position[a]));
this.memos = applyToTemplate(
project,
link,
axes.map(positionSlot),
axes.map((a) => object.position[a]),
);
}
undo(): void {
@@ -181,7 +211,11 @@ export class RevertPositionToTemplateCommand implements Command {
readonly label: string;
private previous: Position = { x: 0, y: 0, z: 0 };
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly axis?: Axis) {
constructor(
private readonly sceneId: string,
private readonly objectId: string,
private readonly axis?: Axis,
) {
this.label = axis ? `Revert ${axis}` : 'Revert position';
}
+14 -3
View File
@@ -104,7 +104,10 @@ export class RenameAssetCommand implements Command {
readonly label = 'Rename asset';
private previousName = '';
constructor(private readonly assetId: string, private readonly name: string) {}
constructor(
private readonly assetId: string,
private readonly name: string,
) {}
execute(project: Project): void {
const asset = requireAsset(project, this.assetId);
@@ -119,7 +122,11 @@ export class RenameAssetCommand implements Command {
/** The half of `SetBagValueCommand` both asset property edits share: the bag is the asset's own. */
abstract class AssetPropertyCommand extends SetBagValueCommand<Asset> {
protected constructor(private readonly assetId: string, key: string, label: string) {
protected constructor(
private readonly assetId: string,
key: string,
label: string,
) {
super(key, label);
}
@@ -134,7 +141,11 @@ abstract class AssetPropertyCommand extends SetBagValueCommand<Asset> {
/** Sets an asset property (coerced against its schema entry). */
export class SetAssetPropertyCommand extends AssetPropertyCommand {
constructor(assetId: string, key: string, private readonly value: PropertyValue) {
constructor(
assetId: string,
key: string,
private readonly value: PropertyValue,
) {
super(assetId, key, `Set ${key}`);
}
+9 -2
View File
@@ -18,7 +18,10 @@ export class AddColliderCommand implements Command {
readonly assetId = newId();
readonly objectId = newId();
constructor(private readonly sceneId: string, private readonly targetId: string) {}
constructor(
private readonly sceneId: string,
private readonly targetId: string,
) {}
execute(project: Project): void {
const scene = requireScene(project, this.sceneId);
@@ -26,7 +29,11 @@ export class AddColliderCommand implements Command {
const targetAsset = requireAsset(project, target.assetId);
if (targetAsset.type === 'collider') throw new CommandError('A collider cannot have a collider');
const asset = buildAsset('collider', nextAssetName(project, `${target.name || targetAsset.name} collider`), this.assetId);
const asset = buildAsset(
'collider',
nextAssetName(project, `${target.name || targetAsset.name} collider`),
this.assetId,
);
if (targetAsset.type === 'sprite') {
const props = resolveSceneObjectProps(targetAsset, target);
asset.props.width = Number(props.width);
+42 -9
View File
@@ -1,6 +1,13 @@
import { newId } from '../ids';
import type { ContainerAsset } from '../model/Asset';
import { findObject, localPositionFor, subtreeIds, subtreeRoots, treeOrder, worldPosition } from '../model/hierarchy';
import {
findObject,
localPositionFor,
subtreeIds,
subtreeRoots,
treeOrder,
worldPosition,
} from '../model/hierarchy';
import type { Position } from '../model/Properties';
import { findScene, type Project } from '../model/Project';
import { createSceneObject } from '../model/SceneObject';
@@ -25,7 +32,10 @@ export class CreateContainerCommand implements Command {
readonly objectId = newId();
private previous: { id: string; parentId: string; position: Position; templateId: string }[] = [];
constructor(private readonly sceneId: string, private readonly objectIds: readonly string[]) {}
constructor(
private readonly sceneId: string,
private readonly objectIds: readonly string[],
) {}
execute(project: Project): void {
const scene = requireScene(project, this.sceneId);
@@ -41,14 +51,25 @@ export class CreateContainerCommand implements Command {
};
// The subtrees that move into the container, in tree order (parents first).
const roots = new Set(subtreeRoots(scene, selected.map((o) => o.id)));
const roots = new Set(
subtreeRoots(
scene,
selected.map((o) => o.id),
),
);
const memberIds = subtreeIds(scene, roots);
const members = treeOrder(scene).map((e) => e.object).filter((o) => memberIds.has(o.id));
const members = treeOrder(scene)
.map((e) => e.object)
.filter((o) => memberIds.has(o.id));
const rootParents = new Set([...roots].map((id) => findObject(scene, id)!.parentId));
const containerParentId = rootParents.size === 1 ? [...rootParents][0]! : '';
// Template: same structure with private ids, roots relative to the centre.
const asset = buildAsset('container', nextAssetName(project, 'Container'), this.assetId) as ContainerAsset;
const asset = buildAsset(
'container',
nextAssetName(project, 'Container'),
this.assetId,
) as ContainerAsset;
const templateIds = new Map(members.map((o) => [o.id, newId()]));
for (const object of members) {
const isRoot = roots.has(object.id);
@@ -57,18 +78,30 @@ export class CreateContainerCommand implements Command {
id: templateIds.get(object.id)!,
assetId: object.assetId,
name: object.name,
position: isRoot ? { x: world.x - centre.x, y: world.y - centre.y, z: world.z } : { ...object.position },
position: isRoot
? { x: world.x - centre.x, y: world.y - centre.y, z: world.z }
: { ...object.position },
// A view-only offset belongs to the placed object, not to the template (see container.ts).
editorOffset: { x: 0, y: 0 },
overrides: { ...object.overrides },
parentId: isRoot ? '' : templateIds.get(object.parentId) ?? '',
parentId: isRoot ? '' : (templateIds.get(object.parentId) ?? ''),
templateId: '',
});
}
// Scene: the container takes the roots' place, the members become its instance.
this.previous = members.map((o) => ({ id: o.id, parentId: o.parentId, position: { ...o.position }, templateId: o.templateId }));
const container = createSceneObject(this.objectId, this.assetId, localPositionFor(scene, containerParentId, centre), containerParentId);
this.previous = members.map((o) => ({
id: o.id,
parentId: o.parentId,
position: { ...o.position },
templateId: o.templateId,
}));
const container = createSceneObject(
this.objectId,
this.assetId,
localPositionFor(scene, containerParentId, centre),
containerParentId,
);
for (const object of members) {
if (roots.has(object.id)) {
const world = worldPosition(scene, object);
+55 -10
View File
@@ -1,4 +1,11 @@
import { edgesInto, edgesOfNode, findNode, type GraphEdge, type GraphNode, portsCompatible } from '../graph/Graph';
import {
edgesInto,
edgesOfNode,
findNode,
type GraphEdge,
type GraphNode,
portsCompatible,
} from '../graph/Graph';
import { nodeDef } from '../graph/NodeRegistry';
import { newId } from '../ids';
import type { PropertyBag, PropertyValue } from '../model/Properties';
@@ -14,11 +21,23 @@ export class AddNodeCommand implements Command {
readonly label: string;
readonly node: GraphNode;
constructor(private readonly assetId: string, type: string, x: number, y: number, params: PropertyBag = {}) {
constructor(
private readonly assetId: string,
type: string,
x: number,
y: number,
params: PropertyBag = {},
) {
// An unknown type is rejected in `execute`, not here: constructors run outside the
// editor's error handling, so throwing here would crash instead of showing a notice.
const def = nodeDef(type);
this.node = { id: newId(), type, x: Math.round(x), y: Math.round(y), params: { ...defaultsOf(def?.params ?? []), ...params } };
this.node = {
id: newId(),
type,
x: Math.round(x),
y: Math.round(y),
params: { ...defaultsOf(def?.params ?? []), ...params },
};
this.label = def ? `Add ${def.label}` : `Add ${type}`;
}
@@ -38,7 +57,10 @@ export class RemoveNodeCommand implements Command {
readonly label = 'Remove node';
private removed?: { node: Removed<GraphNode>; edges: Removed<GraphEdge>[] };
constructor(private readonly assetId: string, private readonly nodeId: string) {}
constructor(
private readonly assetId: string,
private readonly nodeId: string,
) {}
execute(project: Project): void {
const graph = requireBehavior(project, this.assetId).graph;
@@ -65,7 +87,12 @@ export class MoveNodeCommand implements Command {
readonly label = 'Move node';
private previous = { x: 0, y: 0 };
constructor(private readonly assetId: string, private readonly nodeId: string, private readonly x: number, private readonly y: number) {}
constructor(
private readonly assetId: string,
private readonly nodeId: string,
private readonly x: number,
private readonly y: number,
) {}
execute(project: Project): void {
const node = requireNode(project, this.assetId, this.nodeId);
@@ -83,7 +110,12 @@ export class MoveNodeCommand implements Command {
/** Sets a node param or the inline value of an unconnected data input. */
export class SetNodeParamCommand extends SetBagValueCommand<GraphNode> {
constructor(private readonly assetId: string, private readonly nodeId: string, key: string, private readonly value: PropertyValue) {
constructor(
private readonly assetId: string,
private readonly nodeId: string,
key: string,
private readonly value: PropertyValue,
) {
super(key, `Set ${key}`);
}
@@ -111,7 +143,11 @@ export class ConnectCommand implements Command {
readonly edge: GraphEdge;
private replaced: Removed<GraphEdge>[] = [];
constructor(private readonly assetId: string, from: { node: string; port: string }, to: { node: string; port: string }) {
constructor(
private readonly assetId: string,
from: { node: string; port: string },
to: { node: string; port: string },
) {
this.edge = { id: newId(), from: { ...from }, to: { ...to } };
}
@@ -124,7 +160,8 @@ export class ConnectCommand implements Command {
const out = requireNodeDef(fromNode.type).outputs.find((p) => p.key === this.edge.from.port);
const inp = requireNodeDef(toNode.type).inputs.find((p) => p.key === this.edge.to.port);
if (!out || !inp) throw new CommandError('Port not found');
if (!portsCompatible(out.type, inp.type)) throw new CommandError(`Cannot connect ${out.type} to ${inp.type}`);
if (!portsCompatible(out.type, inp.type))
throw new CommandError(`Cannot connect ${out.type} to ${inp.type}`);
if (graph.edges.some((e) => sameEnds(e, this.edge))) throw new CommandError('Already connected');
const occupied = inp.type === 'exec' ? [] : edgesInto(graph, this.edge.to.node, this.edge.to.port);
this.replaced = removeWhere(graph.edges, (e) => occupied.includes(e));
@@ -143,7 +180,10 @@ export class DisconnectCommand implements Command {
readonly label = 'Disconnect';
private removed: Removed<GraphEdge>[] = [];
constructor(private readonly assetId: string, private readonly edgeId: string) {}
constructor(
private readonly assetId: string,
private readonly edgeId: string,
) {}
execute(project: Project): void {
const graph = requireBehavior(project, this.assetId).graph;
@@ -157,5 +197,10 @@ export class DisconnectCommand implements Command {
}
function sameEnds(a: GraphEdge, b: GraphEdge): boolean {
return a.from.node === b.from.node && a.from.port === b.from.port && a.to.node === b.to.node && a.to.port === b.to.port;
return (
a.from.node === b.from.node &&
a.from.port === b.from.port &&
a.to.node === b.to.node &&
a.to.port === b.to.port
);
}
+49 -10
View File
@@ -2,7 +2,13 @@ import { newId } from '../ids';
import { isPlaceableType } from '../model/AssetTypes';
import { instantiateContainer } from '../model/container';
import { isSelfOrAncestor, localPositionFor, removeSubtrees, worldPosition } from '../model/hierarchy';
import { type Position, type PropertyBag, type PropertyValue, toPosition, type Vec2 } from '../model/Properties';
import {
type Position,
type PropertyBag,
type PropertyValue,
toPosition,
type Vec2,
} from '../model/Properties';
import type { Project } from '../model/Project';
import { createSceneObject, type SceneObject } from '../model/SceneObject';
import { coerceValue } from '../properties/PropertySchema';
@@ -53,7 +59,10 @@ export class RemoveSceneObjectsCommand implements Command {
readonly label: string;
private removed: Removed<SceneObject>[] = [];
constructor(private readonly sceneId: string, private readonly objectIds: readonly string[]) {
constructor(
private readonly sceneId: string,
private readonly objectIds: readonly string[],
) {
this.label = objectIds.length === 1 ? 'Remove object' : 'Remove objects';
}
@@ -83,7 +92,11 @@ export class MoveSceneObjectCommand implements Command {
readonly label = 'Move object';
private previous: Position = { x: 0, y: 0, z: 0 };
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly position: Position) {}
constructor(
private readonly sceneId: string,
private readonly objectId: string,
private readonly position: Position,
) {}
execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId);
@@ -105,7 +118,11 @@ export class SetEditorOffsetCommand implements Command {
readonly label = 'Editor offset';
private previous: Vec2 = { x: 0, y: 0 };
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly offset: Vec2) {}
constructor(
private readonly sceneId: string,
private readonly objectId: string,
private readonly offset: Vec2,
) {}
execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId);
@@ -122,7 +139,11 @@ export class RenameSceneObjectCommand implements Command {
readonly label = 'Rename object';
private previous = '';
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly name: string) {}
constructor(
private readonly sceneId: string,
private readonly objectId: string,
private readonly name: string,
) {}
execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId);
@@ -137,7 +158,12 @@ export class RenameSceneObjectCommand implements Command {
/** The half of `SetBagValueCommand` both override edits share: the bag is the object's overrides. */
abstract class OverrideCommand extends SetBagValueCommand<SceneObject> {
protected constructor(private readonly sceneId: string, private readonly objectId: string, key: string, label: string) {
protected constructor(
private readonly sceneId: string,
private readonly objectId: string,
key: string,
label: string,
) {
super(key, label);
}
@@ -152,7 +178,12 @@ abstract class OverrideCommand extends SetBagValueCommand<SceneObject> {
/** Sets a per-object override of an asset property. */
export class SetOverrideCommand extends OverrideCommand {
constructor(sceneId: string, objectId: string, key: string, private readonly value: PropertyValue) {
constructor(
sceneId: string,
objectId: string,
key: string,
private readonly value: PropertyValue,
) {
super(sceneId, objectId, key, `Override ${key}`);
}
@@ -180,9 +211,16 @@ export class ClearOverrideCommand extends OverrideCommand {
*/
export class SetParentCommand implements Command {
readonly label = 'Set parent';
private previous: { parentId: string; position: Position } = { parentId: '', position: { x: 0, y: 0, z: 0 } };
private previous: { parentId: string; position: Position } = {
parentId: '',
position: { x: 0, y: 0, z: 0 },
};
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly parentId: string) {}
constructor(
private readonly sceneId: string,
private readonly objectId: string,
private readonly parentId: string,
) {}
execute(project: Project): void {
const scene = requireScene(project, this.sceneId);
@@ -190,7 +228,8 @@ export class SetParentCommand implements Command {
if (this.parentId) {
if (this.parentId === this.objectId) throw new CommandError('An object cannot be its own parent');
requireObject(project, this.sceneId, this.parentId);
if (isSelfOrAncestor(scene, this.objectId, this.parentId)) throw new CommandError('An object cannot be parented to one of its own children');
if (isSelfOrAncestor(scene, this.objectId, this.parentId))
throw new CommandError('An object cannot be parented to one of its own children');
}
this.previous = { parentId: object.parentId, position: { ...object.position } };
const world = worldPosition(scene, object);
+16 -4
View File
@@ -10,7 +10,11 @@ export class AddVariableCommand implements Command {
readonly label: string;
readonly def: PropertyDef;
constructor(private readonly assetId: string, key: string, kind: VariableKind) {
constructor(
private readonly assetId: string,
key: string,
kind: VariableKind,
) {
this.def = variableDef(key, kind);
this.label = `Add variable ${key}`;
}
@@ -18,7 +22,8 @@ export class AddVariableCommand implements Command {
execute(project: Project): void {
const asset = requireBehavior(project, this.assetId);
if (!isValidVariableName(this.def.key)) throw new CommandError(INVALID_NAME);
if (asset.variables.some((v) => v.key === this.def.key)) throw new CommandError(`Variable "${this.def.key}" already exists`);
if (asset.variables.some((v) => v.key === this.def.key))
throw new CommandError(`Variable "${this.def.key}" already exists`);
asset.variables.push(this.def);
asset.props[this.def.key] = this.def.default;
}
@@ -36,7 +41,10 @@ export class RemoveVariableCommand implements Command {
private removed?: { def: Removed<PropertyDef>; value: PropertyValue | undefined };
private overrides: { holderId: string; objectId: string; value: PropertyValue }[] = [];
constructor(private readonly assetId: string, private readonly key: string) {
constructor(
private readonly assetId: string,
private readonly key: string,
) {
this.label = `Remove variable ${key}`;
}
@@ -75,7 +83,11 @@ export class RemoveVariableCommand implements Command {
export class RenameVariableCommand implements Command {
readonly label: string;
constructor(private readonly assetId: string, private readonly from: string, private readonly to: string) {
constructor(
private readonly assetId: string,
private readonly from: string,
private readonly to: string,
) {
this.label = `Rename variable ${from}`;
}
+10 -3
View File
@@ -41,7 +41,12 @@ export class GraphInterpreter {
* Fires every event node of `type` whose params satisfy `filter`, seeding its outputs with
* `outputs` (e.g. `{ dt }`). Returns how many event nodes fired.
*/
fire(type: string, ctx: NodeContext, outputs: Record<string, Value> = {}, filter?: (node: GraphNode) => boolean): number {
fire(
type: string,
ctx: NodeContext,
outputs: Record<string, Value> = {},
filter?: (node: GraphNode) => boolean,
): number {
let fired = 0;
for (const node of this.eventNodes(type)) {
if (filter && !filter(node)) continue;
@@ -66,7 +71,8 @@ export class GraphInterpreter {
}
private execute(node: GraphNode, ctx: NodeContext): void {
if (++this.steps > MAX_STEPS_PER_FIRE) throw new Error('Behavior graph exceeded the step limit (infinite loop?)');
if (++this.steps > MAX_STEPS_PER_FIRE)
throw new Error('Behavior graph exceeded the step limit (infinite loop?)');
const def = requireNodeDef(node.type);
if (!def.run) return;
const inputs = this.collectInputs(node, ctx);
@@ -99,7 +105,8 @@ export class GraphInterpreter {
const def = requireNodeDef(source.type);
// Exec nodes that have not run in this pulse have no value yet.
if (isEventNode(def) || hasExecInput(def) || !def.run) return null;
if (++this.steps > MAX_STEPS_PER_FIRE) throw new Error('Behavior graph exceeded the step limit (data cycle?)');
if (++this.steps > MAX_STEPS_PER_FIRE)
throw new Error('Behavior graph exceeded the step limit (data cycle?)');
// Mark before evaluating so a data cycle reads null instead of recursing forever.
this.outputCache.set(nodeId, {});
const outputs = def.run(ctx, this.collectInputs(source, ctx), source.params).outputs ?? {};
+15 -3
View File
@@ -22,12 +22,20 @@ export interface EntityHandle {
/** World position of a handle: its local position plus those of all ancestors. */
export function worldPositionOf(handle: EntityHandle): { x: number; y: number; z: number } {
return sumAlongChain(handle, (h) => h.parent, (h) => h);
return sumAlongChain(
handle,
(h) => h.parent,
(h) => h,
);
}
/** Whether a handle and every ancestor of it are switched on (see `properties/enabled.ts`). */
export function isHandleEnabled(handle: EntityHandle): boolean {
return enabledAlongChain(handle, (h) => h.props, (h) => h.parent);
return enabledAlongChain(
handle,
(h) => h.props,
(h) => h.parent,
);
}
/** Where the mouse is (world units) and what it did this frame. */
@@ -83,7 +91,11 @@ export interface NodeContext {
* local to `parent` (world when none). Returns null when the asset does not exist or cannot
* be placed (a scene). Spawned behaviors run their `On start` on the next tick.
*/
spawn(assetId: string, position: { x: number; y: number; z: number }, parent: EntityHandle | null): EntityHandle | null;
spawn(
assetId: string,
position: { x: number; y: number; z: number },
parent: EntityHandle | null,
): EntityHandle | null;
destroy(target: EntityHandle): void;
stop(): void;
/** Ends the run and starts it again from the saved scene (everything spawned is lost). */
+7 -1
View File
@@ -88,7 +88,13 @@ export function inlineInputDef(port: PortDef): PropertyDef {
case 'any':
return { key: port.key, label: port.label, kind: 'text', default: asString(port.default ?? '') };
default:
return { key: port.key, label: port.label, kind: 'number', default: asNumber(port.default ?? 0), step: 1 };
return {
key: port.key,
label: port.label,
kind: 'number',
default: asNumber(port.default ?? 0),
step: 1,
};
}
}
+8 -1
View File
@@ -10,7 +10,14 @@ export const Move: NodeDef = {
"Moves an object by (dx, dy) × scale × dt. Connect dt to an event's dt for per-second movement; " +
'leave it at 1 for per-frame. With "Collide with solids" on, the object\'s solid colliders stop at ' +
'other solid colliders and slide along them; blocked x/y tell which axes hit.',
inputs: [exec(), obj('object'), num('dx'), num('dy'), withDefault(num('scale'), 1), withDefault(num('dt'), 1)],
inputs: [
exec(),
obj('object'),
num('dx'),
num('dy'),
withDefault(num('scale'), 1),
withDefault(num('dt'), 1),
],
outputs: [exec(), bool('blockedX', 'blocked x'), bool('blockedY', 'blocked y')],
params: [{ key: 'collide', label: 'Collide with solids', kind: 'boolean', default: false }],
run: (ctx, inputs, params) => {
+2 -1
View File
@@ -5,7 +5,8 @@ export const RestartGame: NodeDef = {
type: 'RestartGame',
label: 'Restart game',
category: 'action',
description: 'Starts the scene over from its saved state – everything spawned, moved or destroyed is reset.',
description:
'Starts the scene over from its saved state – everything spawned, moved or destroyed is reset.',
inputs: [exec()],
outputs: [],
params: [],
+3 -1
View File
@@ -28,7 +28,9 @@ export const Compare: NodeDef = {
},
],
run: (_ctx, inputs, params) => {
const compare = OPERATORS[String(params.op)] ?? OPERATORS['==']!;
// The fallback matches the param's declared default, so a node whose setting is missing
// (an old document, a hand-written graph) behaves like a freshly created one.
const compare = OPERATORS[String(params.op)] ?? OPERATORS['<']!;
return { outputs: { result: compare(asNumber(inputs.a), asNumber(inputs.b)) } };
},
};
+67 -10
View File
@@ -73,23 +73,80 @@ import { SetVariable } from './variable/SetVariable';
*/
export const BUILT_IN_NODES: readonly NodeDef[] = [
// events
OnStart, OnUpdate, OnKeyDown, OnKeyUp, OnKeyHeld, OnTimer,
OnCollisionEnter, OnCollisionStay, OnCollisionExit, OnClick,
OnStart,
OnUpdate,
OnKeyDown,
OnKeyUp,
OnKeyHeld,
OnTimer,
OnCollisionEnter,
OnCollisionStay,
OnCollisionExit,
OnClick,
// flow
Branch, Sequence,
Branch,
Sequence,
// variables
GetVariable, SetVariable,
GetVariable,
SetVariable,
// data: references and properties
Self, Owner, Parent, ObjectByName, NameOf, IsAsset, IsEnabled, GetProperty, GetPosition,
Self,
Owner,
Parent,
ObjectByName,
NameOf,
IsAsset,
IsEnabled,
GetProperty,
GetPosition,
// data: literals and math
NumberLiteral, BooleanLiteral, StringLiteral,
Add, Subtract, Multiply, Divide, Min, Max, Abs, Sqrt, Clamp, Floor, Ceil, Round, Modulo, Random, Time,
NumberLiteral,
BooleanLiteral,
StringLiteral,
Add,
Subtract,
Multiply,
Divide,
Min,
Max,
Abs,
Sqrt,
Clamp,
Floor,
Ceil,
Round,
Modulo,
Random,
Time,
// data: text and comparison
ToText, Join, Equals, Compare, And, Or, Not,
ToText,
Join,
Equals,
Compare,
And,
Or,
Not,
// data: vectors and input
Length, Distance, Normalize, AxisInput, KeyIsDown, MousePosition, MouseButton, IsHovered, IsTouching,
Length,
Distance,
Normalize,
AxisInput,
KeyIsDown,
MousePosition,
MouseButton,
IsHovered,
IsTouching,
// actions
Move, SetPosition, SetProperty, SetEnabled, SetText, Spawn, Destroy, Log, StopGame, RestartGame,
Move,
SetPosition,
SetProperty,
SetEnabled,
SetText,
Spawn,
Destroy,
Log,
StopGame,
RestartGame,
];
for (const def of BUILT_IN_NODES) registerNode(def);
+25 -4
View File
@@ -16,17 +16,34 @@ export const any = (key: string, label = key): PortDef => ({ key, label, type: '
export const withDefault = (port: PortDef, value: PropertyValue): PortDef => ({ ...port, default: value });
/** Outputs of the three collision events: the other body, its collider and the own collider. */
export const collisionOutputs = (): PortDef[] => [exec(), obj('other'), obj('otherCollider', 'other collider'), obj('collider')];
export const collisionOutputs = (): PortDef[] => [
exec(),
obj('other'),
obj('otherCollider', 'other collider'),
obj('collider'),
];
// --- params ----------------------------------------------------------------------------------
export const keyParam: PropertyDef = { key: 'key', label: 'Key', kind: 'key', default: 'ArrowRight' };
/** Options are empty on purpose: the graph inspector fills in the behavior's variables. */
export const variableParam: PropertyDef = { key: 'variable', label: 'Variable', kind: 'enum', default: '', options: [] };
export const variableParam: PropertyDef = {
key: 'variable',
label: 'Variable',
kind: 'enum',
default: '',
options: [],
};
/** Options are empty on purpose: the graph inspector fills in the project's placeable assets. */
export const assetParam: PropertyDef = { key: 'asset', label: 'Asset', kind: 'enum', default: '', options: [] };
export const assetParam: PropertyDef = {
key: 'asset',
label: 'Asset',
kind: 'enum',
default: '',
options: [],
};
// --- node factories --------------------------------------------------------------------------
@@ -59,7 +76,11 @@ export function unaryMath(type: string, description: string, fn: (value: number)
}
/** A data node combining the booleans `a` and `b`. */
export function binaryLogic(type: string, description: string, fn: (a: boolean, b: boolean) => boolean): NodeDef {
export function binaryLogic(
type: string,
description: string,
fn: (a: boolean, b: boolean) => boolean,
): NodeDef {
return {
type,
label: type,
+3 -1
View File
@@ -9,5 +9,7 @@ export const GetVariable: NodeDef = {
inputs: [],
outputs: [any('value')],
params: [variableParam],
run: (ctx, _inputs, params) => ({ outputs: { value: ctx.variables[String(params.variable ?? '')] ?? null } }),
run: (ctx, _inputs, params) => ({
outputs: { value: ctx.variables[String(params.variable ?? '')] ?? null },
}),
};
+2 -1
View File
@@ -65,7 +65,8 @@ export interface ContainerAsset extends AssetBase {
objects: SceneObject[];
}
export type Asset = SpriteAsset | CameraAsset | TextAsset | SceneAsset | BehaviorAsset | ColliderAsset | ContainerAsset;
export type Asset =
SpriteAsset | CameraAsset | TextAsset | SceneAsset | BehaviorAsset | ColliderAsset | ContainerAsset;
export function isSceneAsset(asset: Asset): asset is SceneAsset {
return asset.type === 'scene';
+9 -1
View File
@@ -1,5 +1,13 @@
/** All asset types. Scenes are assets too, but cannot be placed in scenes. */
export const ASSET_TYPES = ['scene', 'camera', 'sprite', 'text', 'behavior', 'collider', 'container'] as const;
export const ASSET_TYPES = [
'scene',
'camera',
'sprite',
'text',
'behavior',
'collider',
'container',
] as const;
export type AssetType = (typeof ASSET_TYPES)[number];
+5 -3
View File
@@ -1,5 +1,4 @@
import type { Asset, ContainerAsset, SceneAsset } from './Asset';
import { isSceneAsset } from './Asset';
import { type Asset, type ContainerAsset, isSceneAsset, type SceneAsset } from './Asset';
import type { SceneObject } from './SceneObject';
/** Written into every document. Reserved for format migrations – the loader does not branch on it yet. */
@@ -49,7 +48,10 @@ export interface SceneObjectRef {
}
/** Objects of all scenes and container templates that satisfy `predicate`, each with its holder. */
export function objectsWhere(project: Project, predicate: (object: SceneObject) => boolean): SceneObjectRef[] {
export function objectsWhere(
project: Project,
predicate: (object: SceneObject) => boolean,
): SceneObjectRef[] {
const result: SceneObjectRef[] = [];
for (const holder of objectHolders(project)) {
for (const object of holder.objects) if (predicate(object)) result.push({ holder, object });
+6 -1
View File
@@ -37,7 +37,12 @@ export interface SceneObject {
}
/** Creates a scene object; `z` defaults to 0 when the caller only has a 2D point. */
export function createSceneObject(id: string, assetId: string, position: Vec2 | Position, parentId = ''): SceneObject {
export function createSceneObject(
id: string,
assetId: string,
position: Vec2 | Position,
parentId = '',
): SceneObject {
return {
id,
assetId,
+14 -3
View File
@@ -16,7 +16,11 @@ export interface TemplateLink {
* asset holds the template this object was instantiated from. Undefined for everything else
* (no template id, or the link is stale).
*/
export function templateLinkOf(project: Project, tree: ObjectTree, object: SceneObject): TemplateLink | undefined {
export function templateLinkOf(
project: Project,
tree: ObjectTree,
object: SceneObject,
): TemplateLink | undefined {
if (!object.templateId) return undefined;
for (const containerObject of chainOf(tree, object).slice(1)) {
const asset = findAsset(project, containerObject.assetId);
@@ -46,12 +50,19 @@ const MAX_NESTING = 8;
* turn – unless the template already spells its content out (that is what a container built
* from a selection containing another container does).
*/
export function instantiateContainer(project: Project, asset: ContainerAsset, containerObjectId: string, depth = 0): SceneObject[] {
export function instantiateContainer(
project: Project,
asset: ContainerAsset,
containerObjectId: string,
depth = 0,
): SceneObject[] {
const ids = new Map(asset.objects.map((t) => [t.id, newId()]));
const result: SceneObject[] = [];
for (const template of asset.objects) {
const id = ids.get(template.id)!;
const parentId = template.parentId ? ids.get(template.parentId) ?? containerObjectId : containerObjectId;
const parentId = template.parentId
? (ids.get(template.parentId) ?? containerObjectId)
: containerObjectId;
result.push({
id,
assetId: template.assetId,
+16 -4
View File
@@ -31,7 +31,11 @@ function parentOf(tree: ObjectTree, object: SceneObject): SceneObject | undefine
/** All objects below `id`, depth-first, parents before children. */
export function descendantsOf(tree: ObjectTree, id: string): SceneObject[] {
return descendantsBelow(id, (parentId) => childrenOf(tree, parentId), (o) => o.id);
return descendantsBelow(
id,
(parentId) => childrenOf(tree, parentId),
(o) => o.id,
);
}
/** The ids of `rootIds` and of everything below them – what group operations act on. */
@@ -58,7 +62,11 @@ export function chainOf(tree: ObjectTree, object: SceneObject): SceneObject[] {
/** Ids of the ancestors of `id`, nearest parent first. Empty for a top-level object. */
export function ancestorIdsOf(tree: ObjectTree, id: string): string[] {
const object = findObject(tree, id);
return object ? chainOf(tree, object).slice(1).map((o) => o.id) : [];
return object
? chainOf(tree, object)
.slice(1)
.map((o) => o.id)
: [];
}
/** Whether `ancestorId` is `objectId` itself or one of its ancestors. */
@@ -80,7 +88,10 @@ function positionAlongChain(tree: ObjectTree, object: SceneObject, withEditorOff
return sumAlongChain(
object,
(o) => parentOf(tree, o),
(o) => (withEditorOffset ? { x: o.position.x + o.editorOffset.x, y: o.position.y + o.editorOffset.y, z: o.position.z } : o.position),
(o) =>
withEditorOffset
? { x: o.position.x + o.editorOffset.x, y: o.position.y + o.editorOffset.y, z: o.position.z }
: o.position,
);
}
@@ -128,7 +139,8 @@ export function treeOrder(tree: ObjectTree): TreeEntry[] {
visit('', 0);
// Objects whose parent is missing (should not happen after loading) are listed at the top level.
for (const object of tree.objects) {
if (object.parentId && !ids.has(object.parentId) && !result.some((e) => e.object === object)) result.push({ object, depth: 0 });
if (object.parentId && !ids.has(object.parentId) && !result.some((e) => e.object === object))
result.push({ object, depth: 0 });
}
return result;
}
+15 -3
View File
@@ -13,7 +13,11 @@ import type { Position } from './Properties';
*/
export function* parentChain<T>(start: T, parentOf: (item: T) => T | null | undefined): Generator<T> {
const seen = new Set<T>();
for (let current: T | null | undefined = start; current && !seen.has(current); current = parentOf(current)) {
for (
let current: T | null | undefined = start;
current && !seen.has(current);
current = parentOf(current)
) {
seen.add(current);
yield current;
}
@@ -31,7 +35,11 @@ export function hasParentCycle<T>(start: T, parentOf: (item: T) => T | null | un
* The local positions of `start` and all its ancestors summed up – the world position, as
* long as the hierarchy has no rotation or scale (it has none).
*/
export function sumAlongChain<T>(start: T, parentOf: (item: T) => T | null | undefined, positionOf: (item: T) => Position): Position {
export function sumAlongChain<T>(
start: T,
parentOf: (item: T) => T | null | undefined,
positionOf: (item: T) => Position,
): Position {
const total: Position = { x: 0, y: 0, z: 0 };
for (const item of parentChain(start, parentOf)) {
const position = positionOf(item);
@@ -47,7 +55,11 @@ export function sumAlongChain<T>(start: T, parentOf: (item: T) => T | null | und
* back into the key its own children are looked up by, which is what lets ids (scene objects)
* and object identity (entities) use the same walk.
*/
export function descendantsBelow<T, K>(rootKey: K, childrenOf: (key: K) => readonly T[], keyOf: (item: T) => K): T[] {
export function descendantsBelow<T, K>(
rootKey: K,
childrenOf: (key: K) => readonly T[],
keyOf: (item: T) => K,
): T[] {
const result: T[] = [];
const visit = (key: K) => {
for (const child of childrenOf(key)) {
+7 -2
View File
@@ -28,7 +28,9 @@ export type VariableKind = (typeof VARIABLE_KINDS)[number];
* same goes for everything parented below it. Assets default to on, so switching a single
* instance off is an ordinary override. Scenes have no such switch (they are not placed).
*/
export const COMMON_PROPS: PropertySchema = [{ key: 'enabled', label: 'Enabled', kind: 'boolean', default: true }];
export const COMMON_PROPS: PropertySchema = [
{ key: 'enabled', label: 'Enabled', kind: 'boolean', default: true },
];
const COMMON_KEYS = new Set(COMMON_PROPS.map((d) => d.key));
@@ -91,7 +93,10 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
/** Type schema with the common properties in front – the effective schema of an asset type. */
const FULL_SCHEMAS: Record<AssetType, PropertySchema> = Object.fromEntries(
(Object.keys(ASSET_SCHEMAS) as AssetType[]).map((type) => [type, type === 'scene' ? ASSET_SCHEMAS[type] : [...COMMON_PROPS, ...ASSET_SCHEMAS[type]]]),
(Object.keys(ASSET_SCHEMAS) as AssetType[]).map((type) => [
type,
type === 'scene' ? ASSET_SCHEMAS[type] : [...COMMON_PROPS, ...ASSET_SCHEMAS[type]],
]),
) as Record<AssetType, PropertySchema>;
/** Schema shared by every asset of a type (common properties first). */
+4 -1
View File
@@ -25,7 +25,10 @@ export function textSize(props: PropertyBag): { w: number; h: number } {
const size = Math.max(1, Number(props.fontSize ?? TEXT_DEFAULTS.fontSize));
const lines = String(props.text ?? TEXT_DEFAULTS.text).split('\n');
const longest = lines.reduce((max, line) => Math.max(max, line.length), 0);
return { w: Math.max(size * CHAR_WIDTH, longest * size * CHAR_WIDTH), h: lines.length * size * LINE_HEIGHT };
return {
w: Math.max(size * CHAR_WIDTH, longest * size * CHAR_WIDTH),
h: lines.length * size * LINE_HEIGHT,
};
}
/** Horizontal anchor of a text object: 0 = left edge on the position, 0.5 = centred, 1 = right. */
+4 -1
View File
@@ -17,7 +17,10 @@ export interface WorldRect {
*/
export function cameraFrame(props: PropertyBag): { w: number; h: number } {
const zoom = Math.max(0.01, Number(props.zoom ?? DEFAULTS.zoom));
return { w: Number(props.width ?? DEFAULTS.width) / zoom, h: Number(props.height ?? DEFAULTS.height) / zoom };
return {
w: Number(props.width ?? DEFAULTS.width) / zoom,
h: Number(props.height ?? DEFAULTS.height) / zoom,
};
}
export function rectAround(cx: number, cy: number, w: number, h: number): WorldRect {
+22 -4
View File
@@ -19,8 +19,20 @@ export type PlaneAxis = 'x' | 'y';
/** The shape described by collider properties, centred on `centre` (a world position). */
export function colliderShape(props: PropertyBag, centre: Vec2): ColliderShape {
if (props.shape === 'circle') return { kind: 'circle', x: centre.x, y: centre.y, r: Math.max(0, Number(props.radius ?? DEFAULTS.radius)) };
return { kind: 'box', x: centre.x, y: centre.y, w: Math.max(0, Number(props.width ?? DEFAULTS.width)), h: Math.max(0, Number(props.height ?? DEFAULTS.height)) };
if (props.shape === 'circle')
return {
kind: 'circle',
x: centre.x,
y: centre.y,
r: Math.max(0, Number(props.radius ?? DEFAULTS.radius)),
};
return {
kind: 'box',
x: centre.x,
y: centre.y,
w: Math.max(0, Number(props.width ?? DEFAULTS.width)),
h: Math.max(0, Number(props.height ?? DEFAULTS.height)),
};
}
/** Whether the collider only reports overlaps (true) or also blocks movement (false). */
@@ -36,7 +48,8 @@ export function colliderSize(props: PropertyBag): { w: number; h: number } {
/** Whether a point lies inside a shape (used for pointer hit tests). */
export function shapeContains(shape: ColliderShape, point: Vec2): boolean {
if (shape.kind === 'circle') return (point.x - shape.x) ** 2 + (point.y - shape.y) ** 2 <= shape.r * shape.r;
if (shape.kind === 'circle')
return (point.x - shape.x) ** 2 + (point.y - shape.y) ** 2 <= shape.r * shape.r;
return Math.abs(point.x - shape.x) * 2 <= shape.w && Math.abs(point.y - shape.y) * 2 <= shape.h;
}
@@ -86,7 +99,12 @@ export function axisSeparation(a: ColliderShape, b: ColliderShape, axis: PlaneAx
* `axis` so it just touches `obstacle` (0 when they do not overlap). Only valid when
* `mover` approached from the far side, i.e. it did not overlap before its move.
*/
export function axisCorrection(mover: ColliderShape, obstacle: ColliderShape, axis: PlaneAxis, dir: 1 | -1): number {
export function axisCorrection(
mover: ColliderShape,
obstacle: ColliderShape,
axis: PlaneAxis,
dir: 1 | -1,
): number {
if (!shapesOverlap(mover, obstacle)) return 0;
return Math.max(0, dir * (mover[axis] - obstacle[axis]) + axisSeparation(mover, obstacle, axis));
}
+5 -1
View File
@@ -16,7 +16,11 @@ function propsEnabled(props: PropertyBag): boolean {
* Whether `start` and every ancestor of it are switched on. Generic over the shape so the
* model (scene objects), the viewport (draw items) and the runtime (entities) share one rule.
*/
export function enabledAlongChain<T>(start: T, propsOf: (item: T) => PropertyBag, parentOf: (item: T) => T | null): boolean {
export function enabledAlongChain<T>(
start: T,
propsOf: (item: T) => PropertyBag,
parentOf: (item: T) => T | null,
): boolean {
for (const item of parentChain(start, parentOf)) if (!propsEnabled(propsOf(item))) return false;
return true;
}
+19 -7
View File
@@ -1,5 +1,10 @@
import type { Project } from '../model/Project';
import { type LoadedProject, type ProjectStore, type ProjectSummary, StaleRevisionError } from './ProjectStore';
import {
type LoadedProject,
type ProjectStore,
type ProjectSummary,
StaleRevisionError,
} from './ProjectStore';
import { deserializeProject, serializeProject } from './serialize';
/**
@@ -14,15 +19,22 @@ export class HttpProjectStore implements ProjectStore {
}
async load(id: string): Promise<LoadedProject> {
const body = await this.request<{ revision: number; project: unknown }>('GET', `projects/${encodeURIComponent(id)}`);
const body = await this.request<{ revision: number; project: unknown }>(
'GET',
`projects/${encodeURIComponent(id)}`,
);
return { project: deserializeProject(body.project, id), revision: body.revision };
}
async save(project: Project, revision: number): Promise<number> {
const body = await this.request<{ revision: number }>('PUT', `projects/${encodeURIComponent(project.id)}`, {
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 {
+37 -7
View File
@@ -6,7 +6,17 @@ import type { PropertyBag, PropertyValue } from '../model/Properties';
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
import { hasParentCycle } from '../model/walk';
import { coerceValue, COMMON_PROPS, isCommonProp, type PropertyDef, type PropertySchema, schemaFor, VARIABLE_KINDS, variableDef, type VariableKind } from '../properties/PropertySchema';
import {
coerceValue,
COMMON_PROPS,
isCommonProp,
type PropertyDef,
type PropertySchema,
schemaFor,
VARIABLE_KINDS,
variableDef,
type VariableKind,
} from '../properties/PropertySchema';
/**
* Turns untrusted JSON (server file, import) into a well-formed Project. Unknown keys are
@@ -44,7 +54,14 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
if (type === 'scene' || type === 'container') {
assets.push({ id, type, name, props, objects: normalizeObjects(a.objects, schemas) });
} else if (type === 'behavior') {
assets.push({ id, type, name, props, variables: variables.get(id) ?? [], graph: normalizeGraph(a.graph) });
assets.push({
id,
type,
name,
props,
variables: variables.get(id) ?? [],
graph: normalizeGraph(a.graph),
});
} else {
assets.push({ id, type, name, props });
}
@@ -107,12 +124,14 @@ function normalizeVariables(raw: unknown): PropertyDef[] {
for (const item of Array.isArray(raw) ? raw : []) {
const v = asRecord(item);
const key = typeof v.key === 'string' ? v.key : '';
const kind = (VARIABLE_KINDS as readonly string[]).includes(String(v.kind)) ? (v.kind as VariableKind) : 'number';
const kind = (VARIABLE_KINDS as readonly string[]).includes(String(v.kind))
? (v.kind as VariableKind)
: 'number';
// Common property keys are reserved – a variable of that name would shadow the switch.
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || seen.has(key) || isCommonProp(key)) continue;
seen.add(key);
const def = variableDef(key, kind);
def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue) as never;
def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue);
defs.push(def);
}
return defs;
@@ -130,7 +149,8 @@ function normalizeGraph(raw: unknown): Graph {
nodeIds.add(id);
const params: PropertyBag = {};
for (const [key, value] of Object.entries(asRecord(n.params))) {
if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') params[key] = value;
if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean')
params[key] = value;
}
const node: GraphNode = { id, type, x: toNumber(n.x), y: toNumber(n.y), params };
graph.nodes.push(node);
@@ -142,10 +162,20 @@ function normalizeGraph(raw: unknown): Graph {
const to = asRecord(e.to);
const id = typeof e.id === 'string' ? e.id : '';
if (!id || edgeIds.has(id)) continue;
if (typeof from.node !== 'string' || typeof from.port !== 'string' || typeof to.node !== 'string' || typeof to.port !== 'string') continue;
if (
typeof from.node !== 'string' ||
typeof from.port !== 'string' ||
typeof to.node !== 'string' ||
typeof to.port !== 'string'
)
continue;
if (!nodeIds.has(from.node) || !nodeIds.has(to.node)) continue;
edgeIds.add(id);
const edge: GraphEdge = { id, from: { node: from.node, port: from.port }, to: { node: to.node, port: to.port } };
const edge: GraphEdge = {
id,
from: { node: from.node, port: from.port },
to: { node: to.node, port: to.port },
};
graph.edges.push(edge);
}
return graph;
+31 -4
View File
@@ -18,7 +18,17 @@ import type { EditorState } from './state';
/** Facts about the running world for the check scripts. */
export interface WorldSnapshot {
entities: { objectId: string; assetId: string; name: string; x: number; y: number; worldX: number; worldY: number; parent: string; props: Record<string, unknown> }[];
entities: {
objectId: string;
assetId: string;
name: string;
x: number;
y: number;
worldX: number;
worldY: number;
parent: string;
props: Record<string, unknown>;
}[];
log: string[];
}
@@ -37,7 +47,10 @@ export class PlaySession {
private behaviors: BehaviorSystem | null = null;
private readonly input = new Input();
constructor(private readonly state: EditorState, private readonly host: PlayHost) {}
constructor(
private readonly state: EditorState,
private readonly host: PlayHost,
) {}
get isPlaying(): boolean {
return this.world !== null;
@@ -76,7 +89,11 @@ export class PlaySession {
world.addSystem(this.behaviors);
this.host.gameView.setWorld(world);
this.host.showGame();
this.state.setNotice(world.firstOfType('camera') ? 'Playing · edits are not saved · Esc stops' : 'This scene has no camera – nothing is rendered · Esc stops');
this.state.setNotice(
world.firstOfType('camera')
? 'Playing · edits are not saved · Esc stops'
: 'This scene has no camera – nothing is rendered · Esc stops',
);
if (world.stopRequested) this.stop();
}
@@ -125,7 +142,17 @@ export class PlaySession {
return {
entities: world.entities.map((e) => {
const w = worldPositionOf(e);
return { objectId: e.objectId, assetId: e.assetId, name: e.name, x: e.x, y: e.y, worldX: w.x, worldY: w.y, parent: e.parent?.objectId ?? '', props: { ...e.props } };
return {
objectId: e.objectId,
assetId: e.assetId,
name: e.name,
x: e.x,
y: e.y,
worldX: w.x,
worldY: w.y,
parent: e.parent?.objectId ?? '',
props: { ...e.props },
};
}),
log: [...world.log],
};
+18 -7
View File
@@ -13,7 +13,11 @@ export class ProjectSession {
* `beforeSwitch` runs before a different project is loaded or the current one is deleted –
* a run in progress has to end first, since it holds a copy of the project being replaced.
*/
constructor(private readonly state: EditorState, private readonly store: ProjectStore, private readonly beforeSwitch: () => void) {}
constructor(
private readonly state: EditorState,
private readonly store: ProjectStore,
private readonly beforeSwitch: () => void,
) {}
/** Opens the project named in the URL, else the first one on the server, else a new one. */
async boot(): Promise<void> {
@@ -25,7 +29,9 @@ export class ProjectSession {
}
await this.openFirstOrCreate();
} catch (error) {
this.state.setNotice(`Could not load project: ${error instanceof Error ? error.message : String(error)}`);
this.state.setNotice(
`Could not load project: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
@@ -69,10 +75,14 @@ export class ProjectSession {
label: 'Delete this project…',
danger: true,
onSelect: () =>
new Dialog(`Delete project "${this.state.project.name}"?`, 'All assets and scenes are removed permanently.', [
{ label: 'Cancel' },
{ label: 'Delete', danger: true, onClick: () => void this.deleteCurrent() },
]).openCentered(),
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(),
},
];
new Menu(items).openBelow(anchor);
@@ -85,7 +95,8 @@ export class ProjectSession {
label: scene.id === this.state.project.activeSceneId ? `● ${scene.name}` : scene.name,
onSelect: () => this.state.execute(new SetActiveSceneCommand(scene.id)),
}));
if (items.length === 0) items.push({ label: 'No scenes – create one in Assets', onSelect: () => {}, disabled: true });
if (items.length === 0)
items.push({ label: 'No scenes – create one in Assets', onSelect: () => {}, disabled: true });
new Menu(items).openBelow(anchor);
}
}
+5 -1
View File
@@ -7,7 +7,11 @@ import type { EditorState } from './state';
* and never overlap; a change that arrives while a save is in flight triggers another save
* afterwards.
*/
export function attachAutosave(state: EditorState, store: ProjectStore, delayMs = 1000): { flush: () => Promise<void> } {
export function attachAutosave(
state: EditorState,
store: ProjectStore,
delayMs = 1000,
): { flush: () => Promise<void> } {
let timer = 0;
let saving: Promise<void> | null = null;
let pending = false;
+5 -1
View File
@@ -24,6 +24,9 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
for (const child of node.children) walk(child);
};
walk(app.stage);
// TypeScript does not track the assignment inside `walk`, so it believes `found` is still
// `null` here – hence both the disable and the cast below.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!found) return null;
const node = found as Text;
const p = node.getGlobalPosition();
@@ -43,7 +46,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
gameScreen: (id: string) => gameView.objectScreen(id),
graph: {
current: () => graphEditor.currentAssetId,
portAt: (nodeId: string, port: string, isOutput: boolean) => graphEditor.portGlobal(nodeId, port, isOutput),
portAt: (nodeId: string, port: string, isOutput: boolean) =>
graphEditor.portGlobal(nodeId, port, isOutput),
nodeAt: (nodeId: string) => graphEditor.nodeGlobal(nodeId),
center: () => centreOf(graphEditor),
},
+92 -19
View File
@@ -1,10 +1,22 @@
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import { AddNodeCommand, ConnectCommand, DisconnectCommand, MoveNodeCommand, RemoveNodeCommand } from '../../core/commands/graphCommands';
import { edgesInto, edgesOutOf, type GraphEdge, type NodeDef, nodeDef, portsCompatible } from '../../core/graph';
import {
AddNodeCommand,
ConnectCommand,
DisconnectCommand,
MoveNodeCommand,
RemoveNodeCommand,
} from '../../core/commands/graphCommands';
import {
edgesInto,
edgesOutOf,
type GraphEdge,
type NodeDef,
nodeDef,
portsCompatible,
} from '../../core/graph';
import type { BehaviorAsset } from '../../core/model/Asset';
import { findAsset } from '../../core/model/Project';
import type { Trace } from '../../runtime/systems/BehaviorSystem';
import { TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
import { clamp, drawGrid, Menu, type Size, Theme, textStyle, Widget, zoomFactor } from '../../ui';
import type { EditorState } from '../state';
import { NodePalette } from './NodePalette';
@@ -47,7 +59,12 @@ export class GraphEditor extends Widget {
private pan = { x: 0, y: 0 };
private zoom = 1;
private panning: { startX: number; startY: number; panX: number; panY: number } | null = null;
private dragging: { nodeId: string; start: { x: number; y: number }; pointer: { x: number; y: number }; moved: boolean } | null = null;
private dragging: {
nodeId: string;
start: { x: number; y: number };
pointer: { x: number; y: number };
moved: boolean;
} | null = null;
private connecting: { from: PortAnchor; pointer: { x: number; y: number } } | null = null;
/** Fit the view once the editor has a real size (it may be hidden behind the Scene tab). */
private needsFit = true;
@@ -78,7 +95,10 @@ export class GraphEditor extends Widget {
});
state.on('selection', () => this.followSelection());
state.on('load', () => { this.assetId = null; this.followSelection(); });
state.on('load', () => {
this.assetId = null;
this.followSelection();
});
state.on('project', () => this.sync());
state.on('mode', () => this.redraw());
}
@@ -183,11 +203,13 @@ export class GraphEditor extends Widget {
if (e.target !== this) return;
const local = this.toLocal(e.global);
if (e.button === 2) {
if (this.assetId && this.state.mode === 'edit') this.openPalette(e.global.x, e.global.y, this.screenToGraph(local.x, local.y));
if (this.assetId && this.state.mode === 'edit')
this.openPalette(e.global.x, e.global.y, this.screenToGraph(local.x, local.y));
return;
}
if (e.button === 0 || e.button === 1) {
if (e.button === 0 && this.assetId && this.state.mode === 'edit') this.state.select({ kind: 'asset', assetId: this.assetId });
if (e.button === 0 && this.assetId && this.state.mode === 'edit')
this.state.select({ kind: 'asset', assetId: this.assetId });
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
}
}
@@ -205,13 +227,21 @@ export class GraphEditor extends Widget {
if (e.button !== 0) return;
if (hit) {
const p = this.toLocal(e.global);
this.connecting = { from: { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput }, pointer: this.screenToGraph(p.x, p.y) };
this.connecting = {
from: { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput },
pointer: this.screenToGraph(p.x, p.y),
};
this.redraw();
return;
}
this.state.select({ kind: 'node', assetId: this.assetId, nodeId: view.node.id });
const p = this.toLocal(e.global);
this.dragging = { nodeId: view.node.id, start: { x: view.node.x, y: view.node.y }, pointer: this.screenToGraph(p.x, p.y), moved: false };
this.dragging = {
nodeId: view.node.id,
start: { x: view.node.x, y: view.node.y },
pointer: this.screenToGraph(p.x, p.y),
moved: false,
};
this.nodeLayer.addChild(view); // raise
}
@@ -261,7 +291,13 @@ export class GraphEditor extends Widget {
if (target && target.isOutput !== connecting.from.isOutput) {
const out = connecting.from.isOutput ? connecting.from : target;
const inp = connecting.from.isOutput ? target : connecting.from;
this.state.execute(new ConnectCommand(this.assetId, { node: out.nodeId, port: out.port }, { node: inp.nodeId, port: inp.port }));
this.state.execute(
new ConnectCommand(
this.assetId,
{ node: out.nodeId, port: out.port },
{ node: inp.nodeId, port: inp.port },
),
);
}
}
this.redraw();
@@ -282,7 +318,8 @@ export class GraphEditor extends Widget {
new NodePalette((def: NodeDef) => {
if (!this.assetId) return;
const command = new AddNodeCommand(this.assetId, def.type, at.x, at.y);
if (this.state.execute(command)) this.state.select({ kind: 'node', assetId: this.assetId, nodeId: command.node.id });
if (this.state.execute(command))
this.state.select({ kind: 'node', assetId: this.assetId, nodeId: command.node.id });
}).open(globalX, globalY);
}
@@ -290,15 +327,37 @@ export class GraphEditor extends Widget {
const assetId = this.assetId;
if (!assetId) return;
this.state.select({ kind: 'node', assetId, nodeId: view.node.id });
new Menu([{ label: 'Delete node', danger: true, onSelect: () => this.state.execute(new RemoveNodeCommand(assetId, view.node.id)) }]).open(globalX, globalY);
new Menu([
{
label: 'Delete node',
danger: true,
onSelect: () => this.state.execute(new RemoveNodeCommand(assetId, view.node.id)),
},
]).open(globalX, globalY);
}
private openPortMenu(view: NodeView, port: string, isOutput: boolean, globalX: number, globalY: number): void {
private openPortMenu(
view: NodeView,
port: string,
isOutput: boolean,
globalX: number,
globalY: number,
): void {
const asset = this.asset();
if (!asset) return;
const edges = isOutput ? edgesOutOf(asset.graph, view.node.id, port) : edgesInto(asset.graph, view.node.id, port);
const edges = isOutput
? edgesOutOf(asset.graph, view.node.id, port)
: edgesInto(asset.graph, view.node.id, port);
if (edges.length === 0) return;
new Menu([{ label: edges.length === 1 ? 'Disconnect' : `Disconnect ${edges.length} links`, danger: true, onSelect: () => { for (const edge of edges) this.state.execute(new DisconnectCommand(asset.id, edge.id)); } }]).open(globalX, globalY);
new Menu([
{
label: edges.length === 1 ? 'Disconnect' : `Disconnect ${edges.length} links`,
danger: true,
onSelect: () => {
for (const edge of edges) this.state.execute(new DisconnectCommand(asset.id, edge.id));
},
},
]).open(globalX, globalY);
}
// --- drawing -----------------------------------------------------------------------------
@@ -320,7 +379,10 @@ export class GraphEditor extends Widget {
return;
}
const playing = this.state.mode === 'play';
this.hint.text = asset.graph.nodes.length === 0 ? `${asset.name} · right-click to add a node` : `${asset.name}${playing ? ' · live' : ''}`;
this.hint.text =
asset.graph.nodes.length === 0
? `${asset.name} · right-click to add a node`
: `${asset.name}${playing ? ' · live' : ''}`;
const trace = playing ? this.traceProvider(asset.id) : null;
const now = this.timeProvider();
@@ -362,7 +424,11 @@ export class GraphEditor extends Widget {
const inType = to.def.inputs.find((p) => p.key === edge.to.port)?.type ?? 'any';
const valid = portsCompatible(type, inType);
bezier(this.edges, from.x + a.x, from.y + a.y, to.x + b.x, to.y + b.y);
this.edges.stroke({ width: hot ? 4 : type === 'exec' ? 2.5 : 2, color: hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger, alpha: type === 'exec' || hot ? 1 : 0.85 });
this.edges.stroke({
width: hot ? 4 : type === 'exec' ? 2.5 : 2,
color: hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger,
alpha: type === 'exec' || hot ? 1 : 0.85,
});
}
private syncGrid(): void {
@@ -371,7 +437,14 @@ export class GraphEditor extends Widget {
if (step < MIN_GRID_STEP) return;
// `pan` is where the graph origin sits on screen – but the graph has no origin worth
// marking, so no axis lines.
drawGrid(this.grid, { originX: this.pan.x, originY: this.pan.y, step, w: this.w, h: this.h, alpha: 0.04 });
drawGrid(this.grid, {
originX: this.pan.x,
originY: this.pan.y,
step,
w: this.w,
h: this.h,
alpha: 0.04,
});
}
// --- test hooks -----------------------------------------------------------------------------
+12 -2
View File
@@ -47,8 +47,18 @@ export class NodePalette extends Popup {
private refresh(): void {
const query = this.search.value.trim().toLowerCase();
this.filtered = allNodeDefs()
.filter((d) => !query || d.label.toLowerCase().includes(query) || d.type.toLowerCase().includes(query) || d.description.toLowerCase().includes(query))
.sort((a, b) => CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category) || a.label.localeCompare(b.label));
.filter(
(d) =>
!query ||
d.label.toLowerCase().includes(query) ||
d.type.toLowerCase().includes(query) ||
d.description.toLowerCase().includes(query),
)
.sort(
(a, b) =>
CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category) ||
a.label.localeCompare(b.label),
);
this.list.setItems(this.filtered.map((d) => ({ id: d.type, label: d.label, hint: d.category })));
this.scroll.relayoutContent();
}
+112 -29
View File
@@ -1,7 +1,20 @@
import { CanvasTextMetrics, Graphics, Rectangle, Text } from 'pixi.js';
import { edgesInto, type Graph, type GraphNode, inlineInputs, inlineValue, type NodeCategory, type NodeDef, type PortDef, type PortType } from '../../core/graph';
import {
asString,
edgesInto,
type Graph,
type GraphNode,
inlineInputs,
inlineValue,
type NodeCategory,
type NodeDef,
type PortDef,
type PortType,
type Value,
} from '../../core/graph';
import type { BehaviorAsset } from '../../core/model/Asset';
import { findAsset, type Project } from '../../core/model/Project';
import type { PropertyValue } from '../../core/model/Properties';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Theme, textStyle, type TextStyleToken, Widget } from '../../ui';
import { keyLabel } from '../inspector/fieldFactory';
@@ -22,23 +35,34 @@ const PAD = 6;
export function categoryColor(category: NodeCategory): number {
const c = Theme.colors;
switch (category) {
case 'event': return c.categoryEvent;
case 'flow': return c.categoryFlow;
case 'data': return c.categoryData;
case 'variable': return c.categoryVariable;
case 'action': return c.categoryAction;
case 'event':
return c.categoryEvent;
case 'flow':
return c.categoryFlow;
case 'data':
return c.categoryData;
case 'variable':
return c.categoryVariable;
case 'action':
return c.categoryAction;
}
}
export function portColor(type: PortType): number {
const c = Theme.colors;
switch (type) {
case 'exec': return c.portExec;
case 'number': return c.portNumber;
case 'boolean': return c.portBoolean;
case 'string': return c.portString;
case 'object': return c.portObject;
case 'any': return c.portAny;
case 'exec':
return c.portExec;
case 'number':
return c.portNumber;
case 'boolean':
return c.portBoolean;
case 'string':
return c.portString;
case 'object':
return c.portObject;
case 'any':
return c.portAny;
}
}
@@ -62,7 +86,10 @@ export class NodeView extends Widget {
/** Data inputs that currently have a connection (their inline value is hidden). */
private connected = new Set<string>();
constructor(readonly node: GraphNode, readonly def: NodeDef) {
constructor(
readonly node: GraphNode,
readonly def: NodeDef,
) {
super();
this.cursor = 'grab';
this.addChild(this.box, this.ports);
@@ -77,9 +104,16 @@ export class NodeView extends Widget {
/** Re-reads params and connections; rebuilds the text lines. `project` resolves asset references (Spawn, Is asset). */
sync(node: GraphNode, graph: Graph, asset: BehaviorAsset | null, project: Project | null): void {
this.paramLines = this.def.params.map((p) => `${p.label}: ${formatParam(p, node.params[p.key] ?? p.default, asset, project)}`);
this.connected = new Set(inlineInputs(this.def).filter((p) => edgesInto(graph, node.id, p.key).length > 0).map((p) => p.key));
const height = TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + this.rowCount() * ROW_HEIGHT + PAD;
this.paramLines = this.def.params.map(
(p) => `${p.label}: ${formatParam(p, node.params[p.key] ?? p.default, asset, project)}`,
);
this.connected = new Set(
inlineInputs(this.def)
.filter((p) => edgesInto(graph, node.id, p.key).length > 0)
.map((p) => p.key),
);
const height =
TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + this.rowCount() * ROW_HEIGHT + PAD;
this.rebuildTexts();
this.resize(NODE_WIDTH, height);
this.position.set(node.x, node.y);
@@ -103,8 +137,12 @@ export class NodeView extends Widget {
/** Every port with its local centre – inputs on the left edge, outputs on the right. */
private *portCentres(): Iterable<{ port: PortDef; isOutput: boolean; x: number; y: number }> {
for (const [list, isOutput] of [[this.def.inputs, false], [this.def.outputs, true]] as const) {
for (let i = 0; i < list.length; i++) yield { port: list[i]!, isOutput, x: isOutput ? this.w : 0, y: this.rowY(i) };
for (const [list, isOutput] of [
[this.def.inputs, false],
[this.def.outputs, true],
] as const) {
for (let i = 0; i < list.length; i++)
yield { port: list[i]!, isOutput, x: isOutput ? this.w : 0, y: this.rowY(i) };
}
}
@@ -119,7 +157,14 @@ export class NodeView extends Widget {
private rebuildTexts(): void {
for (const t of this.texts) t.destroy();
this.texts.length = 0;
const add = (text: string, style: TextStyleToken, x: number, y: number, align: 'left' | 'right' = 'left', maxWidth = NODE_WIDTH - PAD * 2) => {
const add = (
text: string,
style: TextStyleToken,
x: number,
y: number,
align: 'left' | 'right' = 'left',
maxWidth = NODE_WIDTH - PAD * 2,
) => {
const node = new Text({ text: fit(text, style, maxWidth), style: textStyle(style) });
node.position.set(align === 'left' ? x : x - node.width, y);
this.addChild(node);
@@ -141,9 +186,24 @@ export class NodeView extends Widget {
const value = inlineValue(this.node, input);
if (value !== null && value !== '') label += ` ${formatValue(value)}`;
}
add(label, input.type === 'exec' ? 'body' : 'muted', PAD + PORT_RADIUS + 4, rowTop, 'left', NODE_WIDTH / 2 + 20);
add(
label,
input.type === 'exec' ? 'body' : 'muted',
PAD + PORT_RADIUS + 4,
rowTop,
'left',
NODE_WIDTH / 2 + 20,
);
}
if (output) add(output.label, output.type === 'exec' ? 'body' : 'muted', this.w - PAD - PORT_RADIUS - 4, rowTop, 'right', NODE_WIDTH / 2 - 10);
if (output)
add(
output.label,
output.type === 'exec' ? 'body' : 'muted',
this.w - PAD - PORT_RADIUS - 4,
rowTop,
'right',
NODE_WIDTH / 2 - 10,
);
}
}
@@ -153,16 +213,27 @@ export class NodeView extends Widget {
this.box.roundRect(2, 3, this.w, this.h, Theme.radius).fill({ color: 0x000000, alpha: 0.35 });
this.box.roundRect(0, 0, this.w, this.h, Theme.radius).fill(c.node);
this.box.roundRect(0, 0, this.w, TITLE_HEIGHT, Theme.radius).fill(categoryColor(this.def.category));
this.box.rect(0, TITLE_HEIGHT - Theme.radius, this.w, Theme.radius).fill(categoryColor(this.def.category));
this.box
.rect(0, TITLE_HEIGHT - Theme.radius, this.w, Theme.radius)
.fill(categoryColor(this.def.category));
const border = this.hot ? c.hot : this.selected ? c.nodeSelected : c.nodeBorder;
this.box.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: this.selected || this.hot ? 2 : 1, color: border });
this.box
.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius)
.stroke({ width: this.selected || this.hot ? 2 : 1, color: border });
this.ports.clear();
const r = PORT_RADIUS;
for (const { port, x, y } of this.portCentres()) {
if (port.type === 'exec') {
// A pentagon pointing right: the Blueprint-style exec pin.
this.ports.moveTo(x - r, y - r).lineTo(x + 1, y - r).lineTo(x + r + 1, y).lineTo(x + 1, y + r).lineTo(x - r, y + r).closePath().fill(portColor('exec'));
this.ports
.moveTo(x - r, y - r)
.lineTo(x + 1, y - r)
.lineTo(x + r + 1, y)
.lineTo(x + 1, y + r)
.lineTo(x - r, y + r)
.closePath()
.fill(portColor('exec'));
} else {
this.ports.circle(x, y, r).fill(portColor(port.type));
this.ports.circle(x, y, r).stroke({ width: 1, color: c.node });
@@ -172,21 +243,32 @@ export class NodeView extends Widget {
}
/** The value of a setting as shown on the node; references that no longer resolve get a "?". */
function formatParam(param: PropertyDef, value: unknown, asset: BehaviorAsset | null, project: Project | null): string {
function formatParam(
param: PropertyDef,
value: PropertyValue | undefined,
asset: BehaviorAsset | null,
project: Project | null,
): string {
if (value === undefined || value === '') return '—';
if (param.kind === 'key') return keyLabel(String(value));
if (param.kind === 'enum' && param.key === 'asset') {
const target = project ? findAsset(project, String(value)) : undefined;
return target ? target.name : `${value}?`;
}
if (param.kind === 'enum' && param.key === 'variable' && asset && !asset.variables.some((v) => v.key === value)) return `${value}?`;
if (
param.kind === 'enum' &&
param.key === 'variable' &&
asset &&
!asset.variables.some((v) => v.key === value)
)
return `${value}?`;
return formatValue(value);
}
function formatValue(value: unknown): string {
function formatValue(value: Value): string {
if (typeof value === 'number') return Number.isInteger(value) ? String(value) : value.toFixed(2);
if (typeof value === 'boolean') return value ? 'true' : 'false';
return String(value);
return asString(value);
}
/** Truncates with an ellipsis so the text fits `maxWidth`. */
@@ -194,6 +276,7 @@ function fit(text: string, style: TextStyleToken, maxWidth: number): string {
const s = textStyle(style);
if (CanvasTextMetrics.measureText(text, s).width <= maxWidth) return text;
let cut = text;
while (cut.length > 1 && CanvasTextMetrics.measureText(cut + '…', s).width > maxWidth) cut = cut.slice(0, -1);
while (cut.length > 1 && CanvasTextMetrics.measureText(cut + '…', s).width > maxWidth)
cut = cut.slice(0, -1);
return cut + '…';
}
+17 -5
View File
@@ -4,7 +4,12 @@ import { type EditorState, sameSelection, type Selection } from '../state';
import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection';
import type { InspectorHost } from './host';
import { buildNodeSection, nodeSignature, refreshNodeSection } from './nodeSection';
import { buildObjectSection, buildObjectsSection, objectSignature, refreshObjectSection } from './objectSection';
import {
buildObjectSection,
buildObjectsSection,
objectSignature,
refreshObjectSection,
} from './objectSection';
/**
* Shows the properties of the current selection. The rows are generated from the
@@ -33,7 +38,12 @@ export class Inspector extends Panel {
}
private emptyHost(): InspectorHost {
return { state: this.state, body: new VStack({ padding: Theme.padding, gap: Theme.gap }), fields: new Map(), nameField: null };
return {
state: this.state,
body: new VStack({ padding: Theme.padding, gap: Theme.gap }),
fields: new Map(),
nameField: null,
};
}
/**
@@ -80,9 +90,11 @@ 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.objectIds.length > 1 || refreshObjectSection(this.host, s.sceneId, s.objectId);
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();
}
}
+59 -14
View File
@@ -1,22 +1,50 @@
import { RenameAssetCommand, ResetAssetPropertyCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands';
import {
RenameAssetCommand,
ResetAssetPropertyCommand,
SetAssetPropertyCommand,
} from '../../core/commands/assetCommands';
import { nextVariableName } from '../../core/commands/naming';
import { AddVariableCommand, RemoveVariableCommand, RenameVariableCommand } from '../../core/commands/variableCommands';
import {
AddVariableCommand,
RemoveVariableCommand,
RenameVariableCommand,
} from '../../core/commands/variableCommands';
import type { Asset, BehaviorAsset } from '../../core/model/Asset';
import { findAsset, type Project } from '../../core/model/Project';
import { COMMON_PROPS, type PropertyDef, schemaForAsset, VARIABLE_KINDS, type VariableKind } from '../../core/properties/PropertySchema';
import {
COMMON_PROPS,
type PropertyDef,
schemaForAsset,
VARIABLE_KINDS,
type VariableKind,
} from '../../core/properties/PropertySchema';
import { isModified, resolveAssetProps } from '../../core/properties/resolve';
import { Button, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui';
import { createField, createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory';
import { addNote, addTitle, type InspectorHost } from './host';
import { previewingHandlers } from './previewHandlers';
const VARIABLE_KIND_LABELS: Record<VariableKind, string> = { number: 'Number', boolean: 'Boolean', text: 'Text' };
const VARIABLE_KIND_MARKS: Record<VariableKind, string> = { number: '#', boolean: '✓', text: 'Aa' };
const VARIABLE_KIND_LABELS: Record<VariableKind, string> = {
number: 'Number',
boolean: 'Boolean',
text: 'Text',
};
// Keyed by the wide `PropertyDef['kind']` on purpose: `variables` is a `PropertyDef[]`, so a
// document could carry a kind that is not a VariableKind – that renders as '?' rather than blank.
const VARIABLE_KIND_MARKS: Partial<Record<PropertyDef['kind'], string>> = {
number: '#',
boolean: '✓',
text: 'Aa',
};
/** Structure-defining facts of an asset selection: the schema keys and kinds. */
export function assetSignature(project: Project, assetId: string): string {
const asset = findAsset(project, assetId);
return asset ? schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',') : 'missing';
return asset
? schemaForAsset(asset)
.map((d) => `${d.key}:${d.kind}`)
.join(',')
: 'missing';
}
/** Name, properties (asset values marked when they differ from the default) and, for behaviors, the variables. */
@@ -58,11 +86,21 @@ export function refreshAssetSection(host: InspectorHost, assetId: string): boole
function buildProps(host: InspectorHost, asset: Asset, schema: readonly PropertyDef[]): void {
const values = resolveAssetProps(asset);
for (const def of schema) {
const row = createFieldRow(def, values[def.key] ?? def.default, assetHandlers(host, asset.id, def), () => {
const 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)) }];
});
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)),
},
];
},
);
row.setModified(isModified(asset, def.key));
host.fields.set(def.key, row);
host.body.add(row.row);
@@ -90,7 +128,8 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
label: VARIABLE_KIND_LABELS[kind],
onSelect: () => {
const current = findAsset(host.state.project, asset.id);
if (current?.type === 'behavior') host.state.execute(new AddVariableCommand(asset.id, nextVariableName(current), kind));
if (current?.type === 'behavior')
host.state.execute(new AddVariableCommand(asset.id, nextVariableName(current), kind));
},
}));
new Menu(items).openBelow(add);
@@ -103,7 +142,7 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
for (const def of asset.variables) {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
const mark = new Label(VARIABLE_KIND_MARKS[def.kind as VariableKind] ?? '?', { style: 'small', align: 'center' });
const mark = new Label(VARIABLE_KIND_MARKS[def.kind] ?? '?', { style: 'small', align: 'center' });
mark.minWidth = 18;
row.add(mark);
row.add(
@@ -117,7 +156,13 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
const field = createField(def, values[def.key] ?? def.default, assetHandlers(host, asset.id, def));
row.add(field.widget, field.grow ? 1 : 0);
if (!field.grow) row.add(new Spacer(), 1);
row.add(new Button('×', { variant: 'ghost', width: Theme.controlHeight, onClick: () => host.state.execute(new RemoveVariableCommand(asset.id, def.key)) }));
row.add(
new Button('×', {
variant: 'ghost',
width: Theme.controlHeight,
onClick: () => host.state.execute(new RemoveVariableCommand(asset.id, def.key)),
}),
);
host.fields.set(def.key, { row, setValue: field.setValue, setModified: () => {} });
host.body.add(row);
}
+30 -5
View File
@@ -1,6 +1,19 @@
import type { PropertyValue } from '../../core/model/Properties';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, Checkbox, ColorField, HStack, Label, Menu, NumberField, Spacer, TextField, Theme, ui, type Widget } from '../../ui';
import {
Button,
Checkbox,
ColorField,
HStack,
Label,
Menu,
NumberField,
Spacer,
TextField,
Theme,
ui,
type Widget,
} from '../../ui';
export interface FieldHandlers {
/** Live value while dragging/typing – apply as a preview, no undo entry. */
@@ -54,7 +67,13 @@ export function labelledRow(text: string): { row: HStack; label: Label } {
* its source. Which of them exist is asked from `reset`/`apply` on every refresh: no entry
* means no button, one entry means a direct button, several mean a menu (↺ ▾ / ↑ ▾).
*/
export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers: FieldHandlers, reset?: FieldActions, apply?: FieldActions): FieldRow {
export function createFieldRow(
def: PropertyDef,
value: PropertyValue,
handlers: FieldHandlers,
reset?: FieldActions,
apply?: FieldActions,
): FieldRow {
const { row, label } = labelledRow(def.label);
const field = createField(def, value, handlers);
row.add(field.widget, field.grow ? 1 : 0);
@@ -141,10 +160,13 @@ export function createField(def: PropertyDef, value: PropertyValue, handlers: Fi
return { widget: field, grow: false, setValue: (v) => field.setChecked(Boolean(v)) };
}
case 'enum': {
const labelOf = (v: PropertyValue) => def.options.find((o) => o.value === String(v))?.label ?? (String(v) || '—');
const labelOf = (v: PropertyValue) =>
def.options.find((o) => o.value === String(v))?.label ?? (String(v) || '—');
const button = new Button(`${labelOf(value)} ▾`);
button.onClick = () => {
new Menu(def.options.map((o) => ({ label: o.label, onSelect: () => handlers.onChange(o.value) }))).openBelow(button);
new Menu(
def.options.map((o) => ({ label: o.label, onSelect: () => handlers.onChange(o.value) })),
).openBelow(button);
};
return { widget: button, grow: true, setValue: (v) => button.setText(`${labelOf(v)} ▾`) };
}
@@ -160,7 +182,10 @@ class KeyCaptureButton extends Button {
private key: string;
private capturing = false;
constructor(key: string, private readonly onCapture: (key: string) => void) {
constructor(
key: string,
private readonly onCapture: (key: string) => void,
) {
super(keyLabel(key));
this.key = key;
this.onClick = () => {
+31 -6
View File
@@ -16,7 +16,11 @@ export function nodeSignature(project: Project, assetId: string, nodeId: string)
const node = asset.graph.nodes.find((n) => n.id === nodeId);
if (!node) return 'missing';
const def = nodeDef(node.type);
const connected = def ? inlineInputs(def).filter((p) => edgesInto(asset.graph, node.id, p.key).length > 0).map((p) => p.key) : [];
const connected = def
? inlineInputs(def)
.filter((p) => edgesInto(asset.graph, node.id, p.key).length > 0)
.map((p) => p.key)
: [];
return `${node.type}|${connected.join(',')}|${asset.variables.map((v) => v.key).join(',')}`;
}
@@ -35,7 +39,11 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
if (def.params.length > 0) addTitle(host, 'SETTINGS');
for (const param of def.params) {
const effective = resolveParamDef(host.state.project, param, asset);
const row = createFieldRow(effective, node.params[param.key] ?? effective.default, paramHandlers(host, assetId, nodeId, param.key));
const row = createFieldRow(
effective,
node.params[param.key] ?? effective.default,
paramHandlers(host, assetId, nodeId, param.key),
);
host.fields.set(`param:${param.key}`, row);
host.body.add(row.row);
}
@@ -44,13 +52,22 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
if (free.length > 0) addTitle(host, 'INPUTS (unconnected)');
for (const port of free) {
const inline = inlineInputDef(port);
const row = createFieldRow(inline, node.params[port.key] ?? inline.default, paramHandlers(host, assetId, nodeId, port.key));
const row = createFieldRow(
inline,
node.params[port.key] ?? inline.default,
paramHandlers(host, assetId, nodeId, port.key),
);
host.fields.set(`input:${port.key}`, row);
host.body.add(row.row);
}
addGap(host);
host.body.add(new Button('Delete node', { variant: 'danger', onClick: () => host.state.execute(new RemoveNodeCommand(assetId, nodeId)) }));
host.body.add(
new Button('Delete node', {
variant: 'danger',
onClick: () => host.state.execute(new RemoveNodeCommand(assetId, nodeId)),
}),
);
}
/** Pushes the node's current params into the rows. False when the node is gone (rebuild). */
@@ -60,7 +77,10 @@ export function refreshNodeSection(host: InspectorHost, assetId: string, nodeId:
if (!node || !def) return false;
for (const [key, row] of host.fields) {
const [scope, name] = key.split(':') as ['param' | 'input', string];
const fallback = scope === 'param' ? def.params.find((p) => p.key === name)?.default : inlineInputDef(def.inputs.find((p) => p.key === name)!).default;
const fallback =
scope === 'param'
? def.params.find((p) => p.key === name)?.default
: inlineInputDef(def.inputs.find((p) => p.key === name)!).default;
row.setValue(node.params[name] ?? fallback ?? '');
}
return true;
@@ -76,7 +96,12 @@ function resolveParamDef(project: Project, param: PropertyDef, asset: Asset): Pr
return { ...param, options: asset.variables.map((v) => ({ value: v.key, label: v.key })) };
}
if (param.key === 'asset') {
return { ...param, options: project.assets.filter((a) => isPlaceableType(a.type)).map((a) => ({ value: a.id, label: `${a.name} (${a.type})` })) };
return {
...param,
options: project.assets
.filter((a) => isPlaceableType(a.type))
.map((a) => ({ value: a.id, label: `${a.name} (${a.type})` })),
};
}
return param;
}
+173 -29
View File
@@ -20,8 +20,17 @@ import { findObject, isSelfOrAncestor, treeOrder } from '../../core/model/hierar
import type { Axis, PropertyValue } from '../../core/model/Properties';
import { findAsset, findScene, type Project } from '../../core/model/Project';
import type { SceneObject } from '../../core/model/SceneObject';
import { COMMON_PROPS, isCommonProp, type PropertyDef, schemaForAsset } from '../../core/properties/PropertySchema';
import { deviatesFromAsset, deviatesFromTemplate, resolveSceneObjectProps } from '../../core/properties/resolve';
import {
COMMON_PROPS,
isCommonProp,
type PropertyDef,
schemaForAsset,
} from '../../core/properties/PropertySchema';
import {
deviatesFromAsset,
deviatesFromTemplate,
resolveSceneObjectProps,
} from '../../core/properties/resolve';
import { Button, Menu, type MenuItem, TextField } from '../../ui';
import { addCollider, createContainer, removeObjects } from '../actions';
import { createFieldRow, type FieldActions, type FieldRow, labelledRow } from './fieldFactory';
@@ -34,13 +43,23 @@ const OFFSET_AXES = ['x', 'y'] as const;
const offsetKey = (axis: 'x' | 'y') => `editorOffset${axis.toUpperCase()}`;
/** Structure-defining facts of an object selection: parent, template link and schema. */
export function objectSignature(project: Project, sceneId: string, objectIds: readonly string[], objectId: string): string {
export function objectSignature(
project: Project,
sceneId: string,
objectIds: readonly string[],
objectId: string,
): string {
if (objectIds.length > 1) return `multi|${objectIds.join(',')}`;
const scene = findScene(project, sceneId);
const object = scene && findObject(scene, objectId);
const asset = object && findAsset(project, object.assetId);
if (!object || !asset) return 'missing';
return `${object.parentId}|${object.templateId}|` + schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',');
return (
`${object.parentId}|${object.templateId}|` +
schemaForAsset(asset)
.map((d) => `${d.key}:${d.kind}`)
.join(',')
);
}
/** Several objects: what they are, and the actions that apply to all of them at once. */
@@ -57,11 +76,18 @@ export function buildObjectsSection(host: InspectorHost, sceneId: string, object
addNote(host, 'Ctrl-click adds or removes objects.', 'small');
addNote(host, 'Dragging one in the scene moves all of them.', 'small');
addGap(host);
host.body.add(new Button('Create container', { onClick: () => createContainer(host.state, sceneId, objectIds) }));
host.body.add(
new Button('Create container', { onClick: () => createContainer(host.state, sceneId, objectIds) }),
);
addNote(host, 'Groups the selection into a reusable container', 'small');
addNote(host, 'asset placed at its centre (Ctrl+G).', 'small');
addGap(host);
host.body.add(new Button('Remove from scene', { variant: 'danger', onClick: () => removeObjects(host.state, sceneId, objectIds) }));
host.body.add(
new Button('Remove from scene', {
variant: 'danger',
onClick: () => removeObjects(host.state, sceneId, objectIds),
}),
);
}
/** One object: name, asset, parent, container link, the switch, position, editor offset, properties, collider button. */
@@ -81,7 +107,13 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
host.body.add(nameRow.row);
const assetRow = labelledRow('Asset');
assetRow.row.add(new Button(asset.name, { variant: 'ghost', onClick: () => host.state.select({ kind: 'asset', assetId: asset.id }) }), 1);
assetRow.row.add(
new Button(asset.name, {
variant: 'ghost',
onClick: () => host.state.select({ kind: 'asset', assetId: asset.id }),
}),
1,
);
host.body.add(assetRow.row);
host.body.add(parentRow(host, scene, object));
@@ -90,12 +122,19 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
const link = templateLinkOf(host.state.project, scene, object);
if (link) {
const linkRow = labelledRow('Part of');
linkRow.row.add(new Button(link.asset.name, { variant: 'ghost', onClick: () => host.state.select({ kind: 'asset', assetId: link.asset.id }) }), 1);
linkRow.row.add(
new Button(link.asset.name, {
variant: 'ghost',
onClick: () => host.state.select({ kind: 'asset', assetId: link.asset.id }),
}),
1,
);
host.body.add(linkRow.row);
}
// The master switch, right where it is looked for: above everything type-specific.
for (const def of COMMON_PROPS) propertyRow(host, sceneId, objectId, def, resolveSceneObjectProps(asset, object));
for (const def of COMMON_PROPS)
propertyRow(host, sceneId, objectId, def, resolveSceneObjectProps(asset, object));
addTitle(host, object.parentId ? 'POSITION (LOCAL TO PARENT)' : 'POSITION');
for (const axis of POSITION_AXES) {
@@ -107,8 +146,26 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
read: (o) => o.position,
write: (o, v) => (o.position = v),
command: (v) => new MoveSceneObjectCommand(sceneId, objectId, v),
reset: () => (moved() ? [{ label: 'Reset to container', onSelect: () => host.state.execute(new RevertPositionToTemplateCommand(sceneId, objectId, axis)) }] : []),
apply: () => (moved() ? [{ label: 'Apply to container', onSelect: () => host.state.execute(new ApplyPositionToContainerCommand(sceneId, objectId, axis)) }] : []),
reset: () =>
moved()
? [
{
label: 'Reset to container',
onSelect: () =>
host.state.execute(new RevertPositionToTemplateCommand(sceneId, objectId, axis)),
},
]
: [],
apply: () =>
moved()
? [
{
label: 'Apply to container',
onSelect: () =>
host.state.execute(new ApplyPositionToContainerCommand(sceneId, objectId, axis)),
},
]
: [],
});
}
@@ -126,7 +183,15 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
reset: () => {
const current = currentObject(host.state, sceneId, objectId);
if (!current || current.editorOffset[axis] === 0) return [];
return [{ label: 'Reset to 0', onSelect: () => host.state.execute(new SetEditorOffsetCommand(sceneId, objectId, { ...current.editorOffset, [axis]: 0 })) }];
return [
{
label: 'Reset to 0',
onSelect: () =>
host.state.execute(
new SetEditorOffsetCommand(sceneId, objectId, { ...current.editorOffset, [axis]: 0 }),
),
},
];
},
});
}
@@ -135,7 +200,8 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
const schema = schemaForAsset(asset);
const values = resolveSceneObjectProps(asset, object);
const own = schema.filter((def) => !isCommonProp(def.key));
if (own.length > 0 || asset.type === 'behavior') addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES');
if (own.length > 0 || asset.type === 'behavior')
addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES');
if (own.length === 0 && asset.type === 'behavior') addNote(host, 'The behavior declares no variables.');
for (const def of own) propertyRow(host, sceneId, objectId, def, values);
markObjectRows(host, object, asset, link);
@@ -149,7 +215,12 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
if (asset.type !== 'collider') {
addGap(host);
host.body.add(new Button('+ Add collider', { variant: 'ghost', onClick: () => addCollider(host.state, sceneId, objectId) }));
host.body.add(
new Button('+ Add collider', {
variant: 'ghost',
onClick: () => addCollider(host.state, sceneId, objectId),
}),
);
}
}
@@ -184,7 +255,13 @@ interface ComponentSpec<V extends object> {
* The row edits one axis, the command carries the whole vector: the other components come
* from the target after the preview was taken back, so they are the pre-preview ones.
*/
function componentRow<V extends object>(host: InspectorHost, sceneId: string, objectId: string, value: number, spec: ComponentSpec<V>): void {
function componentRow<V extends object>(
host: InspectorHost,
sceneId: string,
objectId: string,
value: number,
spec: ComponentSpec<V>,
): void {
const withAxis = (vector: V, next: PropertyValue): V => ({ ...vector, [spec.axis]: Number(next) });
const row = createFieldRow(
{ key: spec.key, label: spec.label, kind: 'number', default: 0, step: 1 },
@@ -214,7 +291,13 @@ function componentRow<V extends object>(host: InspectorHost, sceneId: string, ob
* override is what makes the object follow the asset again: the template is only read when
* the container is placed, never at draw time.
*/
function propertyRow(host: InspectorHost, sceneId: string, objectId: string, def: PropertyDef, values: Record<string, PropertyValue>): FieldRow {
function propertyRow(
host: InspectorHost,
sceneId: string,
objectId: string,
def: PropertyDef,
values: Record<string, PropertyValue>,
): FieldRow {
const bases = () => deviations(host.state, sceneId, objectId, def.key);
const row = createFieldRow(
def,
@@ -229,15 +312,46 @@ function propertyRow(host: InspectorHost, sceneId: string, objectId: string, def
() => {
const { template, asset } = bases();
return [
...(template ? [{ label: 'Reset to container', onSelect: () => host.state.execute(new RevertOverrideToTemplateCommand(sceneId, objectId, def.key)) }] : []),
...(asset ? [{ label: 'Reset to asset', onSelect: () => host.state.execute(new ClearOverrideCommand(sceneId, objectId, def.key)) }] : []),
...(template
? [
{
label: 'Reset to container',
onSelect: () =>
host.state.execute(new RevertOverrideToTemplateCommand(sceneId, objectId, def.key)),
},
]
: []),
...(asset
? [
{
label: 'Reset to asset',
onSelect: () => host.state.execute(new ClearOverrideCommand(sceneId, objectId, def.key)),
},
]
: []),
];
},
() => {
const { template, asset } = bases();
return [
...(template ? [{ label: 'Apply to container', onSelect: () => host.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)) }] : []),
...(asset ? [{ label: 'Apply to asset', onSelect: () => host.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)) }] : []),
...(template
? [
{
label: 'Apply to container',
onSelect: () =>
host.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)),
},
]
: []),
...(asset
? [
{
label: 'Apply to asset',
onSelect: () =>
host.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)),
},
]
: []),
];
},
);
@@ -256,11 +370,16 @@ function parentRow(host: InspectorHost, scene: SceneAsset, object: SceneObject)
const parent = findObject(scene, object.parentId);
const button = new Button(`${parent ? nameOf(parent) : 'None'} ▾`);
button.onClick = () => {
const items: MenuItem[] = [{ label: 'None', onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, '')) }];
const items: MenuItem[] = [
{ label: 'None', onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, '')) },
];
for (const { object: candidate, depth } of treeOrder(scene)) {
if (isSelfOrAncestor(scene, object.id, candidate.id)) continue;
const name = `${' '.repeat(depth)}${nameOf(candidate)}`;
items.push({ label: candidate.id === object.parentId ? `● ${name}` : name, onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, candidate.id)) });
items.push({
label: candidate.id === object.parentId ? `● ${name}` : name,
onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, candidate.id)),
});
}
new Menu(items).openBelow(button);
};
@@ -273,11 +392,22 @@ function parentRow(host: InspectorHost, scene: SceneAsset, object: SceneObject)
* the template (position per axis, overrides), for any other object one from the asset. The
* editor offset has one base only – no offset at all.
*/
function markObjectRows(host: InspectorHost, object: SceneObject, asset: Asset, link: TemplateLink | undefined): void {
for (const axis of POSITION_AXES) host.fields.get(axis)?.setModified(Boolean(link) && object.position[axis] !== link!.template.position[axis]);
for (const axis of OFFSET_AXES) host.fields.get(offsetKey(axis))?.setModified(object.editorOffset[axis] !== 0);
function markObjectRows(
host: InspectorHost,
object: SceneObject,
asset: Asset,
link: TemplateLink | undefined,
): void {
for (const axis of POSITION_AXES)
host.fields
.get(axis)
?.setModified(Boolean(link) && object.position[axis] !== link!.template.position[axis]);
for (const axis of OFFSET_AXES)
host.fields.get(offsetKey(axis))?.setModified(object.editorOffset[axis] !== 0);
for (const def of schemaForAsset(asset)) {
const deviates = link ? deviatesFromTemplate(object, link.template, def.key) : deviatesFromAsset(object, asset, def.key);
const deviates = link
? deviatesFromTemplate(object, link.template, def.key)
: deviatesFromAsset(object, asset, def.key);
host.fields.get(def.key)?.setModified(deviates);
}
}
@@ -287,7 +417,12 @@ function markObjectRows(host: InspectorHost, object: SceneObject, asset: Asset,
* differs: the template of the container the object belongs to, and the asset behind it.
* Evaluated on demand so a menu never acts on a stale snapshot.
*/
function deviations(state: InspectorHost['state'], sceneId: string, objectId: string, key: string): { template: boolean; asset: boolean } {
function deviations(
state: InspectorHost['state'],
sceneId: string,
objectId: string,
key: string,
): { template: boolean; asset: boolean } {
const scene = findScene(state.project, sceneId);
const object = scene && findObject(scene, objectId);
const asset = object && findAsset(state.project, object.assetId);
@@ -300,14 +435,23 @@ function deviations(state: InspectorHost['state'], sceneId: string, objectId: st
}
/** Whether the object sits somewhere else on `axis` than its container template says. */
function positionMoved(state: InspectorHost['state'], sceneId: string, objectId: string, axis: Axis): boolean {
function positionMoved(
state: InspectorHost['state'],
sceneId: string,
objectId: string,
axis: Axis,
): boolean {
const scene = findScene(state.project, sceneId);
const object = scene && findObject(scene, objectId);
const link = scene && object && templateLinkOf(state.project, scene, object);
return Boolean(link) && object!.position[axis] !== link!.template.position[axis];
}
function currentObject(state: InspectorHost['state'], sceneId: string, objectId: string): SceneObject | undefined {
function currentObject(
state: InspectorHost['state'],
sceneId: string,
objectId: string,
): SceneObject | undefined {
const scene = findScene(state.project, sceneId);
return scene && findObject(scene, objectId);
}
+4 -1
View File
@@ -27,7 +27,10 @@ export interface PreviewedValue<Target, Model = PropertyValue> {
* value from before the preview is written back first – including "the key did not exist",
* via `remove` – and only then is the command executed.
*/
export function previewingHandlers<Target, Model = PropertyValue>(host: InspectorHost, spec: PreviewedValue<Target, Model>): FieldHandlers {
export function previewingHandlers<Target, Model = PropertyValue>(
host: InspectorHost,
spec: PreviewedValue<Target, Model>,
): FieldHandlers {
const toModel = spec.toModel ?? ((input: PropertyValue) => input as unknown as Model);
let previewed = false;
let before: Model | undefined;
+21 -4
View File
@@ -13,12 +13,21 @@ export class AssetPanel extends Panel {
private readonly list = new ListView();
private readonly scroll = new ScrollContainer();
constructor(private readonly state: EditorState, private readonly placementPoint: () => Vec2) {
constructor(
private readonly state: EditorState,
private readonly placementPoint: () => Vec2,
) {
super({ title: 'Assets', padding: 0, gap: 0 });
this.minWidth = 160;
const add = new Button('+', { variant: 'ghost', width: 28 });
add.onClick = () => new Menu(ASSET_TYPES.map((type) => ({ label: ASSET_TYPE_LABELS[type], onSelect: () => this.createAsset(type) }))).openBelow(add);
add.onClick = () =>
new Menu(
ASSET_TYPES.map((type) => ({
label: ASSET_TYPE_LABELS[type],
onSelect: () => this.createAsset(type),
})),
).openBelow(add);
this.header.add(add);
this.list.emptyText = 'No assets – press +';
@@ -81,9 +90,17 @@ export class AssetPanel extends Panel {
if (!asset) return;
const items: MenuItem[] = [];
if (asset.type === 'scene') {
items.push({ label: 'Set as active scene', onSelect: () => this.state.execute(new SetActiveSceneCommand(id)), disabled: this.state.project.activeSceneId === id });
items.push({
label: 'Set as active scene',
onSelect: () => this.state.execute(new SetActiveSceneCommand(id)),
disabled: this.state.project.activeSceneId === id,
});
} else {
items.push({ label: 'Place in scene', onSelect: () => this.place(id), disabled: !this.state.activeScene });
items.push({
label: 'Place in scene',
onSelect: () => this.place(id),
disabled: !this.state.activeScene,
});
}
items.push({ label: 'Delete…', danger: true, onSelect: () => confirmDeleteAsset(this.state, id) });
new Menu(items).open(x, y);
+27 -6
View File
@@ -49,7 +49,10 @@ export class HierarchyPanel extends Panel {
state.on('project', () => this.refresh());
// A different project brings different objects – start folded again.
state.on('load', () => { this.expanded.clear(); this.refresh(); });
state.on('load', () => {
this.expanded.clear();
this.refresh();
});
state.on('selection', () => (this.revealSelection() ? this.refresh() : this.syncSelection()));
this.refresh();
}
@@ -88,13 +91,31 @@ export class HierarchyPanel extends Panel {
const selected = state.selectedObjectIds.includes(id) ? state.selectedObjectIds : [id];
const many = selected.length > 1;
new Menu([
{ label: 'Select asset', onSelect: () => object && state.select({ kind: 'asset', assetId: object.assetId }) },
{ label: 'Add collider', disabled: !type || type === 'collider' || many, onSelect: () => addCollider(state, scene.id, id) },
{ label: many ? `Create container from ${selected.length} objects` : 'Create container', onSelect: () => createContainer(state, scene.id, selected) },
{ label: 'Unparent (move to top level)', disabled: !object?.parentId || many, onSelect: () => state.execute(new SetParentCommand(scene.id, id, '')) },
{
label: 'Select asset',
onSelect: () => object && state.select({ kind: 'asset', assetId: object.assetId }),
},
{
label: 'Add collider',
disabled: !type || type === 'collider' || many,
onSelect: () => addCollider(state, scene.id, id),
},
{
label: many ? `Create container from ${selected.length} objects` : 'Create container',
onSelect: () => createContainer(state, scene.id, selected),
},
{
label: 'Unparent (move to top level)',
disabled: !object?.parentId || many,
onSelect: () => state.execute(new SetParentCommand(scene.id, id, '')),
},
{ label: 'Expand all', onSelect: () => this.expandAll() },
{ label: 'Collapse all', onSelect: () => this.collapseAll() },
{ label: many ? `Remove ${selected.length} objects from scene` : 'Remove from scene', danger: true, onSelect: () => removeObjects(state, scene.id, selected) },
{
label: many ? `Remove ${selected.length} objects from scene` : 'Remove from scene',
danger: true,
onSelect: () => removeObjects(state, scene.id, selected),
},
]).open(x, y);
}
+13 -3
View File
@@ -34,7 +34,10 @@ export class Toolbar extends HStack {
private readonly notice: Label;
private readonly fps: Label;
constructor(private readonly state: EditorState, actions: ToolbarActions) {
constructor(
private readonly state: EditorState,
actions: ToolbarActions,
) {
super({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
this.minHeight = Theme.controlHeight + Theme.padding * 2;
this.addChildAt(this.bg, 0);
@@ -53,7 +56,10 @@ export class Toolbar extends HStack {
this.add(new Spacer(Theme.padding));
this.add(new Label('Scene', { style: 'muted' }));
this.sceneButton = new Button('—', { width: 160, onClick: () => actions.openSceneMenu(this.sceneButton) });
this.sceneButton = new Button('—', {
width: 160,
onClick: () => actions.openSceneMenu(this.sceneButton),
});
this.add(this.sceneButton);
this.add(new Spacer(Theme.padding));
@@ -63,7 +69,11 @@ export class Toolbar extends HStack {
this.add(this.redoButton);
this.add(new Spacer(), 1);
this.playButton = new Button('▶ Play', { variant: 'primary', width: 110, onClick: () => actions.togglePlay() });
this.playButton = new Button('▶ Play', {
variant: 'primary',
width: 110,
onClick: () => actions.togglePlay(),
});
this.add(this.playButton);
this.add(new Spacer(), 1);
+15 -4
View File
@@ -194,7 +194,10 @@ export class EditorState {
select(selection: Selection): void {
// Test hooks pass the short form `{ kind: 'object', sceneId, objectId }` without `objectIds`.
if (selection?.kind === 'object' && !selection.objectIds) selection = objectSelection(selection.sceneId, [selection.objectId]);
// The type says `objectIds` is always there – untyped callers are exactly why this guard exists.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (selection?.kind === 'object' && !selection.objectIds)
selection = objectSelection(selection.sceneId, [selection.objectId]);
if (sameSelection(this.selection, selection)) return;
this.selection = selection;
this.emit('selection');
@@ -204,7 +207,9 @@ export class EditorState {
toggleObject(sceneId: string, objectId: string): void {
const s = this.selection;
const current = s?.kind === 'object' && s.sceneId === sceneId ? s.objectIds : [];
const next = current.includes(objectId) ? current.filter((id) => id !== objectId) : [...current, objectId];
const next = current.includes(objectId)
? current.filter((id) => id !== objectId)
: [...current, objectId];
this.select(objectSelection(sceneId, next, current.includes(objectId) ? undefined : objectId));
}
@@ -227,7 +232,8 @@ export class EditorState {
const asset = findAsset(this.project, s.assetId);
// Fall back to the behavior asset so the graph stays open when its node vanished (undo).
if (!asset || asset.type !== 'behavior') this.select(null);
else if (!asset.graph.nodes.some((n) => n.id === s.nodeId)) this.select({ kind: 'asset', assetId: s.assetId });
else if (!asset.graph.nodes.some((n) => n.id === s.nodeId))
this.select({ kind: 'asset', assetId: s.assetId });
}
}
@@ -262,7 +268,12 @@ export function sameSelection(a: Selection, b: Selection): boolean {
if (!a || !b || a.kind !== b.kind) return false;
if (a.kind === 'asset' && b.kind === 'asset') return a.assetId === b.assetId;
if (a.kind === 'object' && b.kind === 'object') {
return a.sceneId === b.sceneId && a.objectId === b.objectId && a.objectIds.length === b.objectIds.length && a.objectIds.every((id, i) => id === b.objectIds[i]);
return (
a.sceneId === b.sceneId &&
a.objectId === b.objectId &&
a.objectIds.length === b.objectIds.length &&
a.objectIds.every((id, i) => id === b.objectIds[i])
);
}
if (a.kind === 'node' && b.kind === 'node') return a.assetId === b.assetId && a.nodeId === b.nodeId;
return false;
+4 -1
View File
@@ -35,7 +35,10 @@ async function main(): Promise<void> {
});
}
window.addEventListener('error', (event) => console.log('[uncaught]', event.error?.stack ?? event.message));
window.addEventListener('error', (event) => {
const error: unknown = event.error;
console.error('[uncaught]', error instanceof Error ? error.stack : event.message);
});
main().catch((error) => {
console.error(error);
+43 -30
View File
@@ -13,7 +13,7 @@ import {
ScrollContainer,
TextField,
Theme,
VStack,
type VStack,
toHex,
} from './ui';
@@ -33,24 +33,32 @@ export function mountPlayground(app: Application, root: VStack): void {
// Column 1: inputs
const inputs = new Panel({ title: 'Inputs' });
inputs.content.add(new Label('Text field'));
inputs.content.add(new TextField('Crate', {
placeholder: 'Name',
onInput: (v) => log(`text input: ${v}`),
onChange: (v) => log(`text change: ${v}`),
}));
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, {
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(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 Label('Color field'));
inputs.content.add(new ColorField(0xe0553f, {
onInput: (c) => log(`color input: ${toHex(c)}`),
onChange: (c) => log(`color change: ${toHex(c)}`),
}));
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.', [
{ label: 'Cancel' },
{ label: 'Delete', danger: true, onClick: () => log('dialog: deleted') },
]).openCentered(),
}));
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) => ({
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.setItems(
Array.from({ length: 40 }, (_, i) => ({
id: `item-${i}`,
label: `Item ${i + 1}${i % 7 === 0 ? ' with a rather long name that must truncate' : ''}`,
hint: i % 3 === 0 ? 'sprite' : i % 3 === 1 ? 'camera' : 'scene',
swatch: i % 3 === 0 ? (0x3fa0e0 + i * 0x040201) & 0xffffff : undefined,
depth: i % 5 === 4 ? 1 : 0,
})),
);
list.onSelect = (id) => log(`list select: ${id}`);
list.onActivate = (id) => log(`list activate: ${id}`);
list.onContextMenu = (id, x, y) =>
+52 -12
View File
@@ -33,7 +33,14 @@ export class World {
* Mouse state in world units, fed by the game view (the only place with the camera
* transform). `pressed`/`released` last for one tick, like the keyboard edges.
*/
readonly pointer: PointerState = { x: 0, y: 0, inside: false, down: false, pressed: false, released: false };
readonly pointer: PointerState = {
x: 0,
y: 0,
inside: false,
down: false,
pressed: false,
released: false,
};
/** Messages logged by behaviors (Log node), newest last. */
readonly log: string[] = [];
private readonly systems: System[] = [];
@@ -41,7 +48,10 @@ export class World {
/** Set when `spawn` added an object; reported (and cleared) by the next `syncToModel`. */
private structureChanged = false;
private constructor(readonly project: Project, readonly sceneId: string) {}
private constructor(
readonly project: Project,
readonly sceneId: string,
) {}
/** Builds a world from the given scene. Throws when the scene does not exist. */
static instantiate(project: Project, sceneId: string): World {
@@ -75,11 +85,12 @@ export class World {
if (!asset || !isPlaceableType(asset.type)) continue;
keep.add(this.materialize(object, asset));
}
for (let i = this.entities.length - 1; i >= 0; i--) if (!keep.has(this.entities[i]!)) this.entities.splice(i, 1);
for (let i = this.entities.length - 1; i >= 0; i--)
if (!keep.has(this.entities[i]!)) this.entities.splice(i, 1);
// Link parents once every entity exists (storage order is not guaranteed to be parent-first).
for (const entity of this.entities) {
const object = findObject(scene, entity.objectId);
entity.parent = object?.parentId ? this.findByObjectId(object.parentId) ?? null : null;
entity.parent = object?.parentId ? (this.findByObjectId(object.parentId) ?? null) : null;
}
}
@@ -87,7 +98,18 @@ export class World {
private materialize(object: SceneObject, asset: Asset): Entity {
let entity = this.findByObjectId(object.id);
if (!entity) {
entity = { id: `e-${object.id}`, objectId: object.id, assetId: asset.id, type: asset.type, name: '', x: 0, y: 0, z: 0, props: {}, parent: null };
entity = {
id: `e-${object.id}`,
objectId: object.id,
assetId: asset.id,
type: asset.type,
name: '',
x: 0,
y: 0,
z: 0,
props: {},
parent: null,
};
this.entities.push(entity);
}
entity.assetId = asset.id;
@@ -109,14 +131,21 @@ export class World {
* during this tick already). Position is local to `parent`, world when there is none.
* Returns null for unknown or unplaceable assets.
*/
spawn(assetId: string, position: { x: number; y: number; z: number }, parent: Entity | null): Entity | null {
spawn(
assetId: string,
position: { x: number; y: number; z: number },
parent: Entity | null,
): Entity | null {
const scene = findScene(this.project, this.sceneId);
const asset = findAsset(this.project, assetId);
if (!scene || !asset || !isPlaceableType(asset.type)) return null;
const parentEntity = parent && this.entities.includes(parent) ? parent : null;
const object = createSceneObject(newId(), asset.id, position, parentEntity?.objectId ?? '');
// A container brings its content along (parented below it, in template order).
const created = [object, ...(asset.type === 'container' ? instantiateContainer(this.project, asset, object.id) : [])];
const created = [
object,
...(asset.type === 'container' ? instantiateContainer(this.project, asset, object.id) : []),
];
scene.objects.push(...created);
const entity = this.materialize(object, asset);
entity.parent = parentEntity;
@@ -152,9 +181,11 @@ export class World {
const asset = object && findAsset(this.project, entity.assetId);
if (!object || !asset) continue;
const p = object.position;
if (p.x !== entity.x || p.y !== entity.y || p.z !== entity.z) object.position = { x: entity.x, y: entity.y, z: entity.z };
if (p.x !== entity.x || p.y !== entity.y || p.z !== entity.z)
object.position = { x: entity.x, y: entity.y, z: entity.z };
const base = resolveSceneObjectProps(asset, object);
for (const [key, value] of Object.entries(entity.props)) if (key in base && base[key] !== value) object.overrides[key] = value;
for (const [key, value] of Object.entries(entity.props))
if (key in base && base[key] !== value) object.overrides[key] = value;
}
return spawned || scene.objects.length !== before;
}
@@ -210,8 +241,13 @@ export class World {
const point = { x, y };
const parts = [entity, ...this.descendantsOf(entity)].filter((e) => isHandleEnabled(e));
const colliders = parts.filter((e) => e.type === 'collider');
if (colliders.length > 0) return colliders.some((c) => shapeContains(colliderShape(c.props, worldPositionOf(c)), point));
return parts.some((e) => (e.type === 'sprite' || e.type === 'text') && footprintContains(e.type, e.props, worldPositionOf(e), point));
if (colliders.length > 0)
return colliders.some((c) => shapeContains(colliderShape(c.props, worldPositionOf(c)), point));
return parts.some(
(e) =>
(e.type === 'sprite' || e.type === 'text') &&
footprintContains(e.type, e.props, worldPositionOf(e), point),
);
}
childrenOf(entity: Entity): Entity[] {
@@ -220,7 +256,11 @@ export class World {
/** Everything parented below an entity, at any depth. */
descendantsOf(entity: Entity): Entity[] {
return descendantsBelow(entity, (parent) => this.childrenOf(parent), (child) => child);
return descendantsBelow(
entity,
(parent) => this.childrenOf(parent),
(child) => child,
);
}
findByObjectId(objectId: string): Entity | undefined {
+52 -28
View File
@@ -1,4 +1,11 @@
import { type EntityHandle, GraphInterpreter, isHandleEnabled, type NodeContext, type TraceListener } from '../../core/graph';
import {
type EntityHandle,
GraphInterpreter,
isHandleEnabled,
type NodeContext,
type TraceListener,
} from '../../core/graph';
import type { PropertyBag } from '../../core/model/Properties';
import { findAsset, type Project } from '../../core/model/Project';
import type { Entity } from '../Entity';
import type { World } from '../World';
@@ -56,7 +63,12 @@ export class BehaviorSystem implements System {
private syncInstances(world: World): void {
this.instances = this.instances.filter((i) => world.entities.includes(i.entity));
for (const entity of world.entities) {
if (entity.type !== 'behavior' || !isHandleEnabled(entity) || this.instances.some((i) => i.entity === entity)) continue;
if (
entity.type !== 'behavior' ||
!isHandleEnabled(entity) ||
this.instances.some((i) => i.entity === entity)
)
continue;
const asset = findAsset(this.project, entity.assetId);
if (!asset || asset.type !== 'behavior') continue;
const trace: Trace = { edges: new Map(), nodes: new Map() };
@@ -73,34 +85,38 @@ 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) => {
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);
for (const node of i.eventNodes('OnTimer')) {
const state = i.nodeState.get(node.id) ?? { elapsed: 0 };
i.nodeState.set(node.id, state);
const interval = Math.max(0.01, Number(node.params.interval ?? 1));
state.elapsed = (state.elapsed ?? 0) + dt;
while (state.elapsed >= interval) {
state.elapsed -= interval;
i.fire('OnTimer', ctx, {}, (n) => n.id === node.id);
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);
for (const node of i.eventNodes('OnTimer')) {
const state = i.nodeState.get(node.id) ?? { elapsed: 0 };
i.nodeState.set(node.id, state);
const interval = Math.max(0.01, Number(node.params.interval ?? 1));
state.elapsed = (state.elapsed ?? 0) + dt;
while (state.elapsed >= interval) {
state.elapsed -= interval;
i.fire('OnTimer', ctx, {}, (n) => n.id === node.id);
}
}
}
this.fireCollisions(instance, i, ctx);
// With an owner the click has to land on it; a behavior without one has no shape to
// be hit, so it listens to the whole game view (anywhere inside the camera frame).
const pointer = world.pointer;
const owner = instance.entity.parent;
if (pointer.pressed && (owner ? world.hitTest(owner, pointer.x, pointer.y) : pointer.inside)) {
i.fire('OnClick', ctx, { x: pointer.x, y: pointer.y });
}
i.fire('OnUpdate', ctx, { dt });
}, dt);
this.fireCollisions(instance, i, ctx);
// With an owner the click has to land on it; a behavior without one has no shape to
// be hit, so it listens to the whole game view (anywhere inside the camera frame).
const pointer = world.pointer;
const owner = instance.entity.parent;
if (pointer.pressed && (owner ? world.hitTest(owner, pointer.x, pointer.y) : pointer.inside)) {
i.fire('OnClick', ctx, { x: pointer.x, y: pointer.y });
}
i.fire('OnUpdate', ctx, { dt });
},
dt,
);
}
}
@@ -140,14 +156,20 @@ export class BehaviorSystem implements System {
}
/** Runs graph code for one instance; an exception stops play and is logged, never thrown. */
private run(instance: Instance, body: (interpreter: GraphInterpreter, ctx: NodeContext) => void, dt: number): void {
private run(
instance: Instance,
body: (interpreter: GraphInterpreter, ctx: NodeContext) => void,
dt: number,
): void {
const world = this.world;
if (!world) return;
const ctx = this.context(instance.entity, world, dt);
try {
body(instance.interpreter, ctx);
} catch (error) {
world.log.push(`Error in behavior "${instance.entity.name}": ${error instanceof Error ? error.message : String(error)}`);
world.log.push(
`Error in behavior "${instance.entity.name}": ${error instanceof Error ? error.message : String(error)}`,
);
world.stopRequested = true;
}
}
@@ -184,6 +206,8 @@ export class BehaviorSystem implements System {
},
log: (message) => {
world.log.push(message);
// The `Log` node's whole purpose: the author wants this in the browser console.
// eslint-disable-next-line no-console
console.log(`[behavior ${entity.name}]`, message);
},
spawn: (assetId, position, parent) => world.spawn(assetId, position, resolve(parent) ?? null),
+45 -8
View File
@@ -1,6 +1,13 @@
import { isHandleEnabled, worldPositionOf } from '../../core/graph';
import { parentChain } from '../../core/model/walk';
import { axisCorrection, colliderShape, colliderSize, isTrigger, type PlaneAxis, shapesOverlap } from '../../core/properties/collider';
import {
axisCorrection,
colliderShape,
colliderSize,
isTrigger,
type PlaneAxis,
shapesOverlap,
} from '../../core/properties/collider';
import type { Entity } from '../Entity';
import type { World } from '../World';
import type { System } from './System';
@@ -79,22 +86,52 @@ export class CollisionSystem implements System {
* are split into sub-steps so nothing jumps across a thin wall. Triggers, the entity's own
* colliders and colliders that travel with it (descendants) never block.
*/
moveAndSlide(world: World, entity: Entity, dx: number, dy: number): { blockedX: boolean; blockedY: boolean } {
moveAndSlide(
world: World,
entity: Entity,
dx: number,
dy: number,
): { blockedX: boolean; blockedY: boolean } {
const own = collidersOf(world, entity).filter((c) => !isTrigger(c.props) && isHandleEnabled(c));
const obstacles = own.length === 0 ? [] : world.entities.filter((c) => c.type === 'collider' && !isTrigger(c.props) && isHandleEnabled(c) && !travelsWith(c, entity));
return { blockedX: this.slideAxis(entity, own, obstacles, 'x', dx), blockedY: this.slideAxis(entity, own, obstacles, 'y', dy) };
const obstacles =
own.length === 0
? []
: world.entities.filter(
(c) =>
c.type === 'collider' && !isTrigger(c.props) && isHandleEnabled(c) && !travelsWith(c, entity),
);
return {
blockedX: this.slideAxis(entity, own, obstacles, 'x', dx),
blockedY: this.slideAxis(entity, own, obstacles, 'y', dy),
};
}
private slideAxis(entity: Entity, own: Entity[], obstacles: Entity[], axis: PlaneAxis, delta: number): boolean {
private slideAxis(
entity: Entity,
own: Entity[],
obstacles: Entity[],
axis: PlaneAxis,
delta: number,
): boolean {
if (delta === 0 || own.length === 0) return false;
// Long steps are split so a fast mover cannot jump across a thin obstacle in one frame.
const steps = Math.min(MAX_STEPS, Math.max(1, Math.ceil(Math.abs(delta) / stepLimit(own, obstacles, axis))));
for (let i = 0; i < steps; i++) if (this.stepAxis(entity, own, obstacles, axis, delta / steps)) return true;
const steps = Math.min(
MAX_STEPS,
Math.max(1, Math.ceil(Math.abs(delta) / stepLimit(own, obstacles, axis))),
);
for (let i = 0; i < steps; i++)
if (this.stepAxis(entity, own, obstacles, axis, delta / steps)) return true;
return false;
}
/** One indivisible move along `axis`; returns whether an obstacle stopped it. */
private stepAxis(entity: Entity, own: Entity[], obstacles: Entity[], axis: PlaneAxis, delta: number): boolean {
private stepAxis(
entity: Entity,
own: Entity[],
obstacles: Entity[],
axis: PlaneAxis,
delta: number,
): boolean {
const dir: 1 | -1 = delta > 0 ? 1 : -1;
// How deep each pair already sits in the other before the step. A step may never deepen
// that, but resolving it is not our job (no jolt, and leaving a wall is always possible).
+18 -7
View File
@@ -8,18 +8,29 @@ export class Input {
private readonly released = new Set<string>();
private readonly onKeyDown = (e: KeyboardEvent) => {
if ((e.target as HTMLElement | null)?.tagName === 'INPUT') return;
if (!this.down.has(e.key)) this.pressed.add(e.key); // ignore auto-repeat
this.down.add(e.key);
this.press(e.key);
if (e.key.startsWith('Arrow') || e.key === ' ') e.preventDefault();
};
private readonly onKeyUp = (e: KeyboardEvent) => {
if (this.down.delete(e.key)) this.released.add(e.key);
};
private readonly onKeyUp = (e: KeyboardEvent) => this.release(e.key);
/** Losing the window releases every key, so nothing stays "held" behind the user's back. */
private readonly onBlur = () => {
private readonly onBlur = () => this.releaseAll();
// The state machine below is what the DOM handlers above drive – and what tests drive
// directly, since a headless run has no window to dispatch key events on.
press(key: string): void {
if (!this.down.has(key)) this.pressed.add(key); // ignore auto-repeat
this.down.add(key);
}
release(key: string): void {
if (this.down.delete(key)) this.released.add(key);
}
releaseAll(): void {
for (const key of this.down) this.released.add(key);
this.down.clear();
};
}
attach(): void {
window.addEventListener('keydown', this.onKeyDown);
+1 -3
View File
@@ -62,9 +62,7 @@ export class FocusManager {
const list = collectFocusables(this.root);
if (list.length === 0) return this.focus(null);
const index = this.focusedWidget ? list.indexOf(this.focusedWidget) : -1;
const next = backwards
? (index <= 0 ? list.length : index) - 1
: (index + 1) % list.length;
const next = backwards ? (index <= 0 ? list.length : index) - 1 : (index + 1) % list.length;
this.focus(list[next] ?? null);
}
+6 -1
View File
@@ -66,7 +66,12 @@ export class KeyboardBridge {
}
/** Start editing: mirror the field's value into the input and grab DOM focus. */
attach(target: TextEditTarget, value: string, selectionStart = value.length, selectionEnd = selectionStart): void {
attach(
target: TextEditTarget,
value: string,
selectionStart = value.length,
selectionEnd = selectionStart,
): void {
this.suppressBlur = true;
this.target = target;
this.input.value = value;
+2 -6
View File
@@ -1,4 +1,4 @@
import type { Application, Container, FederatedPointerEvent } from 'pixi.js';
import type { Application, Container } from 'pixi.js';
import { FocusManager } from './FocusManager';
import { KeyboardBridge } from './KeyboardBridge';
@@ -21,11 +21,7 @@ export function initUI(app: Application, root: Container, overlay: Container): U
context = { app, focus: new FocusManager(root), keyboard: new KeyboardBridge(), overlay };
// Capture phase on the stage runs before any widget handler: a press outside the focused
// widget blurs it (committing text edits) before the press can change selection.
app.stage.addEventListener(
'pointerdown',
(e) => context?.focus.blurIfOutside((e as FederatedPointerEvent).target as Container),
{ capture: true },
);
app.stage.addEventListener('pointerdown', (e) => context?.focus.blurIfOutside(e.target), { capture: true });
// Clicking the canvas must not steal DOM focus from the keyboard bridge input.
app.canvas.addEventListener('mousedown', (e) => e.preventDefault());
app.canvas.addEventListener('contextmenu', (e) => e.preventDefault());
+1 -1
View File
@@ -46,7 +46,7 @@ export function hsvToRgb({ h, s, v }: HSV): number {
const c = v * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = v - c;
let r = 0, g = 0, b = 0;
let r: number, g: number, b: number;
if (h < 60) [r, g, b] = [c, x, 0];
else if (h < 120) [r, g, b] = [x, c, 0];
else if (h < 180) [r, g, b] = [0, c, x];
+4 -1
View File
@@ -17,7 +17,10 @@ export abstract class Stack extends Widget {
padding: number;
align: NonNullable<StackOptions['align']>;
constructor(protected readonly axis: 'x' | 'y', options: StackOptions = {}) {
constructor(
protected readonly axis: 'x' | 'y',
options: StackOptions = {},
) {
super();
this.gap = options.gap ?? 0;
this.padding = options.padding ?? 0;
+19 -5
View File
@@ -34,7 +34,9 @@ export class Button extends Widget {
this.addChild(this.bg, this.labelText);
// Leaving while pressed cancels the click: a release elsewhere must not activate.
this.trackHover(() => { this.pressed = false; });
this.trackHover(() => {
this.pressed = false;
});
this.on('pointerdown', (e: FederatedPointerEvent) => {
if (e.button !== 0) return;
this.pressed = true;
@@ -89,10 +91,22 @@ export class Button extends Widget {
const flat = this.variant === 'default' || this.variant === 'ghost';
let fill: number | null = c.control;
let border: number | null = c.border;
if (this.variant === 'primary') { fill = c.accent; border = null; }
if (this.variant === 'danger') { fill = c.danger; border = null; }
if (this.variant === 'ghost') { fill = null; border = null; }
if (this.toggled) { fill = c.accent; border = null; }
if (this.variant === 'primary') {
fill = c.accent;
border = null;
}
if (this.variant === 'danger') {
fill = c.danger;
border = null;
}
if (this.variant === 'ghost') {
fill = null;
border = null;
}
if (this.toggled) {
fill = c.accent;
border = null;
}
if (this.hovered && !this.toggled && flat) fill = c.controlHover;
if (this.pressed && flat) fill = c.controlActive;
const stroke = this.focused ? c.accent : border;
+5 -1
View File
@@ -54,7 +54,11 @@ export class Checkbox extends Widget {
y,
});
if (this.checked) {
this.box.moveTo(4, y + 8.5).lineTo(7, y + 11.5).lineTo(12.5, y + 5).stroke({ width: 2, color: c.accentText });
this.box
.moveTo(4, y + 8.5)
.lineTo(7, y + 11.5)
.lineTo(12.5, y + 5)
.stroke({ width: 2, color: c.accentText });
}
}
}
+3 -1
View File
@@ -54,7 +54,9 @@ export class ColorPicker extends Popup {
this.applyPointer(local.x, local.y);
});
// The press starts on the body; its release bubbles up to the popup either way.
this.onPointerRelease(() => { this.dragging = null; });
this.onPointerRelease(() => {
this.dragging = null;
});
this.content.add(this.body);
this.renderSv();
}
+3 -1
View File
@@ -22,7 +22,9 @@ export class Dialog extends Popup {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.add(new Spacer(), 1); // pushes the buttons to the right
for (const b of buttons) {
const button = new Button(b.label, { variant: b.primary ? 'primary' : b.danger ? 'danger' : 'default' });
const button = new Button(b.label, {
variant: b.primary ? 'primary' : b.danger ? 'danger' : 'default',
});
button.onClick = this.closeThen(b.onClick);
row.add(button);
}
+28 -6
View File
@@ -20,7 +20,11 @@ export class ListRow extends Widget {
private selected = false;
private listFocused = false;
constructor(readonly item: ListItem, height: number, private readonly chevronSlot: boolean) {
constructor(
readonly item: ListItem,
height: number,
private readonly chevronSlot: boolean,
) {
super();
this.cursor = 'pointer';
this.minHeight = height;
@@ -59,12 +63,17 @@ export class ListRow extends Widget {
const depth = this.item.depth ?? 0;
const indent = this.indent;
this.bg.clear();
if (this.selected) this.bg.rect(0, 0, this.w, this.h).fill(this.listFocused ? c.selection : c.controlActive);
if (this.selected)
this.bg.rect(0, 0, this.w, this.h).fill(this.listFocused ? c.selection : c.controlActive);
else if (this.hovered) this.bg.rect(0, 0, this.w, this.h).fill(c.controlHover);
// Tree guide: a short "└" mark in front of nested rows.
if (depth > 0) {
const gx = indent - 8;
this.bg.moveTo(gx, 0).lineTo(gx, this.h / 2).lineTo(indent - 2, this.h / 2).stroke({ width: 1, color: c.border });
this.bg
.moveTo(gx, 0)
.lineTo(gx, this.h / 2)
.lineTo(indent - 2, this.h / 2)
.stroke({ width: 1, color: c.border });
}
let x = indent;
if (this.chevronSlot) {
@@ -86,13 +95,26 @@ export class ListRow extends Widget {
const hintW = this.hint.text ? this.hint.width + Theme.padding : 0;
this.labelText.text = truncate(this.item.label, this.labelText.style, this.w - x - hintW - Theme.padding);
this.labelText.position.set(x, centreY(this.h, this.labelText.height));
this.hint.position.set(Math.round(this.w - Theme.padding - this.hint.width), centreY(this.h, this.hint.height));
this.hint.position.set(
Math.round(this.w - Theme.padding - this.hint.width),
centreY(this.h, this.hint.height),
);
}
/** ▸ when collapsed, ▾ when expanded – drawn, not glyphs, so it stays crisp at any size. */
private drawChevron(x: number, y: number, expanded: boolean): void {
const color = this.selected ? Theme.colors.text : Theme.colors.textMuted;
if (expanded) this.bg.moveTo(x - 3, y - 2).lineTo(x + 5, y - 2).lineTo(x + 1, y + 3).fill(color);
else this.bg.moveTo(x - 1, y - 4).lineTo(x + 4, y).lineTo(x - 1, y + 4).fill(color);
if (expanded)
this.bg
.moveTo(x - 3, y - 2)
.lineTo(x + 5, y - 2)
.lineTo(x + 1, y + 3)
.fill(color);
else
this.bg
.moveTo(x - 1, y - 4)
.lineTo(x + 4, y)
.lineTo(x - 1, y + 4)
.fill(color);
}
}
+2
View File
@@ -124,6 +124,8 @@ export class NumberField extends TextField {
/** Clamps to min/max; NaN/Infinity keep the current value (0 before the first assignment). */
private clampValue(value: number): number {
// `?? 0` is not dead despite the type: the constructor calls this before assigning `numeric`.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!Number.isFinite(value)) return this.numeric ?? 0;
return clamp(value, this.min, this.max);
}
+6 -2
View File
@@ -39,7 +39,9 @@ export class ScrollContainer extends Widget {
this.scrollTo(this.dragScrollStart + (e.global.y - this.dragStart) * ratio);
});
// The press starts on the scrollbar graphics; its release bubbles up to us either way.
this.onPointerRelease(() => { this.draggingBar = false; });
this.onPointerRelease(() => {
this.draggingBar = false;
});
}
/** Replaces the scrolled widget. The previous content is detached, not destroyed. */
@@ -99,6 +101,8 @@ export class ScrollContainer extends Widget {
const x = this.w - trackW - 2;
const barH = Math.max(20, (this.h / this.contentHeight) * this.h);
const barY = (this.scrollY / this.maxScroll) * (this.h - barH);
this.scrollbar.roundRect(x, barY, trackW, barH, trackW / 2).fill({ color: Theme.colors.textMuted, alpha: 0.6 });
this.scrollbar
.roundRect(x, barY, trackW, barH, trackW / 2)
.fill({ color: Theme.colors.textMuted, alpha: 0.6 });
}
}
+22 -5
View File
@@ -61,7 +61,14 @@ export class TextField extends Widget implements TextEditTarget {
this.minWidth = options.width ?? 80;
this.textNode = new Text({ text: value, style: textStyle(options.style ?? 'body') });
this.placeholderNode = new Text({ text: this.placeholder, style: textStyle('muted') });
this.addChild(this.bg, this.selectionShape, this.placeholderNode, this.textNode, this.caret, this.clipShape);
this.addChild(
this.bg,
this.selectionShape,
this.placeholderNode,
this.textNode,
this.caret,
this.clipShape,
);
this.mask = this.clipShape;
this.trackHover();
@@ -71,7 +78,9 @@ export class TextField extends Widget implements TextEditTarget {
const index = this.indexAt(this.toLocal(e.global).x);
this.setSelection(this.selStart, index, true);
});
this.onPointerRelease(() => { this.dragSelecting = false; });
this.onPointerRelease(() => {
this.dragSelecting = false;
});
}
/** Current text, including an edit in progress that has not been committed yet. */
@@ -216,7 +225,10 @@ export class TextField extends Widget implements TextEditTarget {
let bestDist = Infinity;
for (let i = 0; i <= this.valueText.length; i++) {
const dist = Math.abs(this.textWidth(this.valueText.slice(0, i)) - x);
if (dist < bestDist) { bestDist = dist; best = i; }
if (dist < bestDist) {
bestDist = dist;
best = i;
}
}
return best;
}
@@ -248,7 +260,10 @@ export class TextField extends Widget implements TextEditTarget {
fill: this.hovered && !this.editing ? c.controlHover : c.control,
border: this.focused ? c.accent : c.border,
});
this.clipShape.clear().rect(1, 1, this.w - 2, this.h - 2).fill(0xffffff);
this.clipShape
.clear()
.rect(1, 1, this.w - 2, this.h - 2)
.fill(0xffffff);
this.textNode.text = this.valueText;
this.placeholderNode.text = this.valueText ? '' : this.placeholder;
@@ -271,7 +286,9 @@ export class TextField extends Widget implements TextEditTarget {
if (hi > lo) {
const x1 = textX + this.textWidth(this.valueText.slice(0, lo));
const x2 = textX + this.textWidth(this.valueText.slice(0, hi));
this.selectionShape.rect(x1, textY, x2 - x1, this.textNode.height).fill({ color: c.accent, alpha: 0.45 });
this.selectionShape
.rect(x1, textY, x2 - x1, this.textNode.height)
.fill({ color: c.accent, alpha: 0.45 });
}
if (this.caretVisible) {
this.caret.rect(Math.round(textX + caretX), textY, 1, this.textNode.height).fill(c.text);
+42 -9
View File
@@ -116,7 +116,11 @@ export class SceneViewport extends WorldViewport {
if (e.button === 0 && e.target === this) {
// Empty space: a click deselects, a drag becomes a rubber band (decided on release).
const local = this.toLocal(e.global);
this.marquee = { start: { x: local.x, y: local.y }, current: { x: local.x, y: local.y }, active: false };
this.marquee = {
start: { x: local.x, y: local.y },
current: { x: local.x, y: local.y },
active: false,
};
e.stopPropagation();
}
}
@@ -137,13 +141,22 @@ export class SceneViewport extends WorldViewport {
// A world-space delta is a local-space delta as well (no rotation/scale in the hierarchy).
for (const item of this.dragging.items) {
const object = findObject(scene, item.objectId);
if (object) object.position = { x: Math.round(item.start.x + dx), y: Math.round(item.start.y + dy), z: item.start.z };
if (object)
object.position = {
x: Math.round(item.start.x + dx),
y: Math.round(item.start.y + dy),
z: item.start.z,
};
}
this.state.preview();
} else if (this.marquee) {
const local = this.toLocal(e.global);
this.marquee.current = { x: local.x, y: local.y };
if (!this.marquee.active && Math.hypot(local.x - this.marquee.start.x, local.y - this.marquee.start.y) < MARQUEE_THRESHOLD) return;
if (
!this.marquee.active &&
Math.hypot(local.x - this.marquee.start.x, local.y - this.marquee.start.y) < MARQUEE_THRESHOLD
)
return;
this.marquee.active = true;
this.redraw();
}
@@ -198,7 +211,8 @@ export class SceneViewport extends WorldViewport {
return;
}
// Clicking a selected object keeps the selection so the whole group can be dragged.
if (!this.state.selectedObjectIds.includes(objectId)) this.state.select(objectSelection(scene.id, [objectId]));
if (!this.state.selectedObjectIds.includes(objectId))
this.state.select(objectSelection(scene.id, [objectId]));
// Only the tops of the selected subtrees move; their descendants follow through the hierarchy.
const items = subtreeRoots(scene, this.state.selectedObjectIds)
.map((id) => findObject(scene, id))
@@ -219,7 +233,9 @@ export class SceneViewport extends WorldViewport {
const maxX = Math.max(a.x, b.x);
const minY = Math.min(a.y, b.y);
const maxY = Math.max(a.y, b.y);
const ids = this.lastItems.filter((i) => i.enabled && i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY).map((i) => i.id);
const ids = this.lastItems
.filter((i) => i.enabled && i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY)
.map((i) => i.id);
this.state.select(objectSelection(scene.id, ids));
}
@@ -249,7 +265,15 @@ export class SceneViewport extends WorldViewport {
const step = GRID_STEP * this.view.zoom;
if (step < MIN_GRID_STEP) return;
const origin = worldToScreen(this.currentView(), 0, 0);
drawGrid(this.grid, { originX: origin.x, originY: origin.y, step, w: this.w, h: this.h, alpha: 0.05, axisAlpha: 0.15 });
drawGrid(this.grid, {
originX: origin.x,
originY: origin.y,
step,
w: this.w,
h: this.h,
alpha: 0.05,
axisAlpha: 0.15,
});
}
/**
@@ -287,7 +311,8 @@ export class SceneViewport extends WorldViewport {
label.visible = true;
label.text = item.name;
label.scale.set(1 / scale);
if (item.type === 'camera' || item.type === 'collider') label.position.set(item.x - size.w / 2, item.y - size.h / 2 - 16 / scale);
if (item.type === 'camera' || item.type === 'collider')
label.position.set(item.x - size.w / 2, item.y - size.h / 2 - 16 / scale);
else label.position.set(item.x + size.w / 2 + 4 / scale, item.y - 8 / scale);
}
@@ -305,7 +330,12 @@ export class SceneViewport extends WorldViewport {
const sh = h * view.scale;
this.gizmos.rect(tl.x - 2, tl.y - 2, sw + 4, sh + 4).stroke({ width: 2, color: Theme.colors.gizmo });
if (id !== s.objectId) continue;
for (const [cx, cy] of [[tl.x, tl.y], [tl.x + sw, tl.y], [tl.x, tl.y + sh], [tl.x + sw, tl.y + sh]] as const) {
for (const [cx, cy] of [
[tl.x, tl.y],
[tl.x + sw, tl.y],
[tl.x, tl.y + sh],
[tl.x + sw, tl.y + sh],
] as const) {
this.gizmos.rect(cx - 3, cy - 3, 6, 6).fill(Theme.colors.gizmo);
}
}
@@ -316,7 +346,10 @@ export class SceneViewport extends WorldViewport {
const y = Math.min(m.start.y, m.current.y);
const w = Math.abs(m.current.x - m.start.x);
const h = Math.abs(m.current.y - m.start.y);
this.gizmos.rect(x, y, w, h).fill({ color: Theme.colors.marquee, alpha: 0.12 }).stroke({ width: 1, color: Theme.colors.marquee });
this.gizmos
.rect(x, y, w, h)
.fill({ color: Theme.colors.marquee, alpha: 0.12 })
.stroke({ width: 1, color: Theme.colors.marquee });
}
}
}
+12 -4
View File
@@ -55,7 +55,9 @@ export abstract class WorldViewport extends Widget {
state.on('selection', () => this.redraw());
state.on('mode', () => this.redraw());
// Every tick moves things: redraw the live view when it is on screen.
state.on('runtime', () => { if (this.visible) this.redraw(); });
state.on('runtime', () => {
if (this.visible) this.redraw();
});
}
override measure(): Size {
@@ -143,7 +145,10 @@ export abstract class WorldViewport extends Widget {
if (this.paintsBackground()) this.bg.rect(rect.x, rect.y, rect.w, rect.h).fill(background);
this.contentMask.rect(rect.x, rect.y, rect.w, rect.h).fill(0xffffff);
this.world.position.set(rect.x + rect.w / 2 - view.x * view.scale, rect.y + rect.h / 2 - view.y * view.scale);
this.world.position.set(
rect.x + rect.w / 2 - view.x * view.scale,
rect.y + rect.h / 2 - view.y * view.scale,
);
this.world.scale.set(view.scale);
const bounds = worldBounds(view);
@@ -214,7 +219,10 @@ export abstract class WorldViewport extends Widget {
return;
}
if (!text) {
text = new Text({ text: '', style: new TextStyle({ fontFamily: Theme.font, fontSize: 32, fill: 0xffffff }) });
text = new Text({
text: '',
style: new TextStyle({ fontFamily: Theme.font, fontSize: 32, fill: 0xffffff }),
});
text.eventMode = 'none';
this.texts.set(item.id, text);
this.objects.addChild(text);
@@ -227,7 +235,7 @@ export abstract class WorldViewport extends Widget {
const color = Number(item.props.color ?? 0xffffff);
if (text.style.fill !== color) text.style.fill = color;
text.anchor.set(textAnchor(item.props), 0.5);
const resolution = Math.min(4, Math.max(1, Math.round(scale * 2) / 2 * 2));
const resolution = Math.min(4, Math.max(1, (Math.round(scale * 2) / 2) * 2));
if (text.resolution !== resolution) text.resolution = resolution;
}
}
+15 -2
View File
@@ -52,7 +52,12 @@ export function collectSceneItems(state: EditorState, fallbackBackground: number
});
}
const byId = new Map(items.map((i) => [i.id, i]));
for (const item of items) item.enabled = enabledAlongChain(item, (i) => i.props, (i) => byId.get(i.parentId) ?? null);
for (const item of items)
item.enabled = enabledAlongChain(
item,
(i) => i.props,
(i) => byId.get(i.parentId) ?? null,
);
return { items, background: Number(scene.props.backgroundColor ?? fallbackBackground) };
}
@@ -60,6 +65,14 @@ export function collectSceneItems(state: EditorState, fallbackBackground: number
export function collectWorldItems(world: World): DrawList {
const items = world.entities
.filter((e) => !isSymbolType(e.type) && isHandleEnabled(e))
.map((e) => ({ id: e.objectId, type: e.type, name: e.name, ...worldPositionOf(e), props: e.props, parentId: e.parent?.objectId ?? '', enabled: true }));
.map((e) => ({
id: e.objectId,
type: e.type,
name: e.name,
...worldPositionOf(e),
props: e.props,
parentId: e.parent?.objectId ?? '',
enabled: true,
}));
return { items, background: world.backgroundColor };
}
+46 -9
View File
@@ -3,7 +3,7 @@
* separate Pixi Text), and the editor symbols for cameras, behaviors, containers and
* colliders. Everything here draws in world units at the item's origin.
*/
import { Graphics, Rectangle } from 'pixi.js';
import { type Graphics, Rectangle } from 'pixi.js';
import { BEHAVIOR_SYMBOL, CONTAINER_SYMBOL, textAnchor } from '../core/properties/bounds';
import { type ColliderShape, colliderShape, isTrigger } from '../core/properties/collider';
import { Theme } from '../ui';
@@ -16,7 +16,12 @@ const COLLIDER_GRAB_BAND = 6;
* Draws `item` into its node. `scale` is the current view scale (pixels per world unit), so
* strokes and hit bands can stay constant on screen.
*/
export function drawItem(node: Graphics, item: DrawItem, size: { w: number; h: number }, scale: number): void {
export function drawItem(
node: Graphics,
item: DrawItem,
size: { w: number; h: number },
scale: number,
): void {
node.clear();
node.hitArea = null;
switch (item.type) {
@@ -24,7 +29,9 @@ export function drawItem(node: Graphics, item: DrawItem, size: { w: number; h: n
node.rect(-size.w / 2, -size.h / 2, size.w, size.h).fill(Number(item.props.color ?? 0xffffff));
return;
case 'camera':
node.rect(-size.w / 2, -size.h / 2, size.w, size.h).stroke({ width: 2 / scale, color: Theme.colors.textMuted });
node
.rect(-size.w / 2, -size.h / 2, size.w, size.h)
.stroke({ width: 2 / scale, color: Theme.colors.textMuted });
node.rect(-12, -8, 24, 16).fill(Theme.colors.textMuted);
node.moveTo(12, 0).lineTo(20, -6).lineTo(20, 6).closePath().fill(Theme.colors.textMuted);
return;
@@ -32,7 +39,12 @@ export function drawItem(node: Graphics, item: DrawItem, size: { w: number; h: n
// A diamond with a small "circuit" mark: the logic symbol.
const r = BEHAVIOR_SYMBOL / 2;
node.moveTo(0, -r).lineTo(r, 0).lineTo(0, r).lineTo(-r, 0).closePath().fill(Theme.colors.behavior);
node.moveTo(-6, -3).lineTo(-1, -3).lineTo(-1, 3).lineTo(6, 3).stroke({ width: 2, color: Theme.colors.accentText });
node
.moveTo(-6, -3)
.lineTo(-1, -3)
.lineTo(-1, 3)
.lineTo(6, 3)
.stroke({ width: 2, color: Theme.colors.accentText });
node.circle(-6, -3, 2).fill(Theme.colors.accentText);
node.circle(6, 3, 2).fill(Theme.colors.accentText);
return;
@@ -41,12 +53,26 @@ export function drawItem(node: Graphics, item: DrawItem, size: { w: number; h: n
// A square with four small boxes inside: the group symbol. Its content is drawn as
// ordinary objects (they are ordinary objects, parented below the container).
const r = CONTAINER_SYMBOL / 2;
node.roundRect(-r, -r, CONTAINER_SYMBOL, CONTAINER_SYMBOL, 3).fill({ color: Theme.colors.container, alpha: 0.25 }).stroke({ width: 2, color: Theme.colors.container });
for (const [bx, by] of [[-7, -7], [1, -7], [-7, 1], [1, 1]] as const) node.rect(bx, by, 6, 6).fill(Theme.colors.container);
node
.roundRect(-r, -r, CONTAINER_SYMBOL, CONTAINER_SYMBOL, 3)
.fill({ color: Theme.colors.container, alpha: 0.25 })
.stroke({ width: 2, color: Theme.colors.container });
for (const [bx, by] of [
[-7, -7],
[1, -7],
[-7, 1],
[1, 1],
] as const)
node.rect(bx, by, 6, 6).fill(Theme.colors.container);
return;
}
case 'text':
node.hitArea = new Rectangle(-size.w / 2 - (textAnchor(item.props) - 0.5) * size.w, -size.h / 2, size.w, size.h);
node.hitArea = new Rectangle(
-size.w / 2 - (textAnchor(item.props) - 0.5) * size.w,
-size.h / 2,
size.w,
size.h,
);
return;
case 'collider': {
// Outline plus a faint fill so the shape reads as "area". Colliders usually sit right on
@@ -66,7 +92,10 @@ export function drawItem(node: Graphics, item: DrawItem, size: { w: number; h: n
}
/** Hit area covering only the outline band of a collider shape (local coordinates, world units). */
function colliderOutlineHitArea(shape: ColliderShape, band: number): { contains(x: number, y: number): boolean } {
function colliderOutlineHitArea(
shape: ColliderShape,
band: number,
): { contains(x: number, y: number): boolean } {
if (shape.kind === 'circle') return { contains: (x, y) => Math.abs(Math.hypot(x, y) - shape.r) <= band };
const hw = shape.w / 2;
const hh = shape.h / 2;
@@ -80,7 +109,15 @@ function colliderOutlineHitArea(shape: ColliderShape, band: number): { contains(
}
/** Adds a dashed line path to `g` (Pixi has no dash style); the caller strokes it. */
export function dashedLine(g: Graphics, x1: number, y1: number, x2: number, y2: number, dash: number, gap: number): void {
export function dashedLine(
g: Graphics,
x1: number,
y1: number,
x2: number,
y2: number,
dash: number,
gap: number,
): void {
const length = Math.hypot(x2 - x1, y2 - y1);
if (length === 0) return;
const ux = (x2 - x1) / length;