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

+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);