Add ESLint and Prettier, and close the node test gaps
Tooling: - Prettier as the single formatting authority (110 columns, single quotes, measured against the existing style); Markdown excluded because table-cell padding turns the README glossary into 1000-character lines. - Type-aware ESLint ending in eslint-config-prettier, so a rule always means "this is a bug", never "this looks different". - .editorconfig and an `npm run check` that runs typecheck, lint, format and tests in one go. Findings fixed on the first run: three more duplicate imports, four String(unknown) calls (parameter types narrowed instead of casts), two dead initialisers, two redundant casts, a console.log for a real error. Two warnings were false here and are disabled with a reason in place. Tests 132 -> 269: - node-catalog.test.ts covers every node with a run(), with a guard that fails when a new node has no row. - behavior-events.test.ts covers the ten event nodes through a running World, checking timing rather than existence. - Input's state machine (press/release/releaseAll) is reachable without DOM events now; the DOM handlers are thin adapters. Compare fell back to == without a configured operator although the schema default is <; aligned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f140c2fd77
commit
d54a620ebd
113 files changed
+5049
-666
No files matched your search
+52
-12
@@ -33,7 +33,14 @@ export class World {
|
||||
* Mouse state in world units, fed by the game view (the only place with the camera
|
||||
* transform). `pressed`/`released` last for one tick, like the keyboard edges.
|
||||
*/
|
||||
readonly pointer: PointerState = { x: 0, y: 0, inside: false, down: false, pressed: false, released: false };
|
||||
readonly pointer: PointerState = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
inside: false,
|
||||
down: false,
|
||||
pressed: false,
|
||||
released: false,
|
||||
};
|
||||
/** Messages logged by behaviors (Log node), newest last. */
|
||||
readonly log: string[] = [];
|
||||
private readonly systems: System[] = [];
|
||||
@@ -41,7 +48,10 @@ export class World {
|
||||
/** Set when `spawn` added an object; reported (and cleared) by the next `syncToModel`. */
|
||||
private structureChanged = false;
|
||||
|
||||
private constructor(readonly project: Project, readonly sceneId: string) {}
|
||||
private constructor(
|
||||
readonly project: Project,
|
||||
readonly sceneId: string,
|
||||
) {}
|
||||
|
||||
/** Builds a world from the given scene. Throws when the scene does not exist. */
|
||||
static instantiate(project: Project, sceneId: string): World {
|
||||
@@ -75,11 +85,12 @@ export class World {
|
||||
if (!asset || !isPlaceableType(asset.type)) continue;
|
||||
keep.add(this.materialize(object, asset));
|
||||
}
|
||||
for (let i = this.entities.length - 1; i >= 0; i--) if (!keep.has(this.entities[i]!)) this.entities.splice(i, 1);
|
||||
for (let i = this.entities.length - 1; i >= 0; i--)
|
||||
if (!keep.has(this.entities[i]!)) this.entities.splice(i, 1);
|
||||
// Link parents once every entity exists (storage order is not guaranteed to be parent-first).
|
||||
for (const entity of this.entities) {
|
||||
const object = findObject(scene, entity.objectId);
|
||||
entity.parent = object?.parentId ? this.findByObjectId(object.parentId) ?? null : null;
|
||||
entity.parent = object?.parentId ? (this.findByObjectId(object.parentId) ?? null) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +98,18 @@ export class World {
|
||||
private materialize(object: SceneObject, asset: Asset): Entity {
|
||||
let entity = this.findByObjectId(object.id);
|
||||
if (!entity) {
|
||||
entity = { id: `e-${object.id}`, objectId: object.id, assetId: asset.id, type: asset.type, name: '', x: 0, y: 0, z: 0, props: {}, parent: null };
|
||||
entity = {
|
||||
id: `e-${object.id}`,
|
||||
objectId: object.id,
|
||||
assetId: asset.id,
|
||||
type: asset.type,
|
||||
name: '',
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0,
|
||||
props: {},
|
||||
parent: null,
|
||||
};
|
||||
this.entities.push(entity);
|
||||
}
|
||||
entity.assetId = asset.id;
|
||||
@@ -109,14 +131,21 @@ export class World {
|
||||
* during this tick already). Position is local to `parent`, world when there is none.
|
||||
* Returns null for unknown or unplaceable assets.
|
||||
*/
|
||||
spawn(assetId: string, position: { x: number; y: number; z: number }, parent: Entity | null): Entity | null {
|
||||
spawn(
|
||||
assetId: string,
|
||||
position: { x: number; y: number; z: number },
|
||||
parent: Entity | null,
|
||||
): Entity | null {
|
||||
const scene = findScene(this.project, this.sceneId);
|
||||
const asset = findAsset(this.project, assetId);
|
||||
if (!scene || !asset || !isPlaceableType(asset.type)) return null;
|
||||
const parentEntity = parent && this.entities.includes(parent) ? parent : null;
|
||||
const object = createSceneObject(newId(), asset.id, position, parentEntity?.objectId ?? '');
|
||||
// A container brings its content along (parented below it, in template order).
|
||||
const created = [object, ...(asset.type === 'container' ? instantiateContainer(this.project, asset, object.id) : [])];
|
||||
const created = [
|
||||
object,
|
||||
...(asset.type === 'container' ? instantiateContainer(this.project, asset, object.id) : []),
|
||||
];
|
||||
scene.objects.push(...created);
|
||||
const entity = this.materialize(object, asset);
|
||||
entity.parent = parentEntity;
|
||||
@@ -152,9 +181,11 @@ export class World {
|
||||
const asset = object && findAsset(this.project, entity.assetId);
|
||||
if (!object || !asset) continue;
|
||||
const p = object.position;
|
||||
if (p.x !== entity.x || p.y !== entity.y || p.z !== entity.z) object.position = { x: entity.x, y: entity.y, z: entity.z };
|
||||
if (p.x !== entity.x || p.y !== entity.y || p.z !== entity.z)
|
||||
object.position = { x: entity.x, y: entity.y, z: entity.z };
|
||||
const base = resolveSceneObjectProps(asset, object);
|
||||
for (const [key, value] of Object.entries(entity.props)) if (key in base && base[key] !== value) object.overrides[key] = value;
|
||||
for (const [key, value] of Object.entries(entity.props))
|
||||
if (key in base && base[key] !== value) object.overrides[key] = value;
|
||||
}
|
||||
return spawned || scene.objects.length !== before;
|
||||
}
|
||||
@@ -210,8 +241,13 @@ export class World {
|
||||
const point = { x, y };
|
||||
const parts = [entity, ...this.descendantsOf(entity)].filter((e) => isHandleEnabled(e));
|
||||
const colliders = parts.filter((e) => e.type === 'collider');
|
||||
if (colliders.length > 0) return colliders.some((c) => shapeContains(colliderShape(c.props, worldPositionOf(c)), point));
|
||||
return parts.some((e) => (e.type === 'sprite' || e.type === 'text') && footprintContains(e.type, e.props, worldPositionOf(e), point));
|
||||
if (colliders.length > 0)
|
||||
return colliders.some((c) => shapeContains(colliderShape(c.props, worldPositionOf(c)), point));
|
||||
return parts.some(
|
||||
(e) =>
|
||||
(e.type === 'sprite' || e.type === 'text') &&
|
||||
footprintContains(e.type, e.props, worldPositionOf(e), point),
|
||||
);
|
||||
}
|
||||
|
||||
childrenOf(entity: Entity): Entity[] {
|
||||
@@ -220,7 +256,11 @@ export class World {
|
||||
|
||||
/** Everything parented below an entity, at any depth. */
|
||||
descendantsOf(entity: Entity): Entity[] {
|
||||
return descendantsBelow(entity, (parent) => this.childrenOf(parent), (child) => child);
|
||||
return descendantsBelow(
|
||||
entity,
|
||||
(parent) => this.childrenOf(parent),
|
||||
(child) => child,
|
||||
);
|
||||
}
|
||||
|
||||
findByObjectId(objectId: string): Entity | undefined {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { type EntityHandle, GraphInterpreter, isHandleEnabled, type NodeContext, type TraceListener } from '../../core/graph';
|
||||
import {
|
||||
type EntityHandle,
|
||||
GraphInterpreter,
|
||||
isHandleEnabled,
|
||||
type NodeContext,
|
||||
type TraceListener,
|
||||
} from '../../core/graph';
|
||||
import type { PropertyBag } from '../../core/model/Properties';
|
||||
import { findAsset, type Project } from '../../core/model/Project';
|
||||
import type { Entity } from '../Entity';
|
||||
import type { World } from '../World';
|
||||
@@ -56,7 +63,12 @@ export class BehaviorSystem implements System {
|
||||
private syncInstances(world: World): void {
|
||||
this.instances = this.instances.filter((i) => world.entities.includes(i.entity));
|
||||
for (const entity of world.entities) {
|
||||
if (entity.type !== 'behavior' || !isHandleEnabled(entity) || this.instances.some((i) => i.entity === entity)) continue;
|
||||
if (
|
||||
entity.type !== 'behavior' ||
|
||||
!isHandleEnabled(entity) ||
|
||||
this.instances.some((i) => i.entity === entity)
|
||||
)
|
||||
continue;
|
||||
const asset = findAsset(this.project, entity.assetId);
|
||||
if (!asset || asset.type !== 'behavior') continue;
|
||||
const trace: Trace = { edges: new Map(), nodes: new Map() };
|
||||
@@ -73,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),
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { isHandleEnabled, worldPositionOf } from '../../core/graph';
|
||||
import { parentChain } from '../../core/model/walk';
|
||||
import { axisCorrection, colliderShape, colliderSize, isTrigger, type PlaneAxis, shapesOverlap } from '../../core/properties/collider';
|
||||
import {
|
||||
axisCorrection,
|
||||
colliderShape,
|
||||
colliderSize,
|
||||
isTrigger,
|
||||
type PlaneAxis,
|
||||
shapesOverlap,
|
||||
} from '../../core/properties/collider';
|
||||
import type { Entity } from '../Entity';
|
||||
import type { World } from '../World';
|
||||
import type { System } from './System';
|
||||
@@ -79,22 +86,52 @@ export class CollisionSystem implements System {
|
||||
* are split into sub-steps so nothing jumps across a thin wall. Triggers, the entity's own
|
||||
* colliders and colliders that travel with it (descendants) never block.
|
||||
*/
|
||||
moveAndSlide(world: World, entity: Entity, dx: number, dy: number): { blockedX: boolean; blockedY: boolean } {
|
||||
moveAndSlide(
|
||||
world: World,
|
||||
entity: Entity,
|
||||
dx: number,
|
||||
dy: number,
|
||||
): { blockedX: boolean; blockedY: boolean } {
|
||||
const own = collidersOf(world, entity).filter((c) => !isTrigger(c.props) && isHandleEnabled(c));
|
||||
const obstacles = own.length === 0 ? [] : world.entities.filter((c) => c.type === 'collider' && !isTrigger(c.props) && isHandleEnabled(c) && !travelsWith(c, entity));
|
||||
return { blockedX: this.slideAxis(entity, own, obstacles, 'x', dx), blockedY: this.slideAxis(entity, own, obstacles, 'y', dy) };
|
||||
const obstacles =
|
||||
own.length === 0
|
||||
? []
|
||||
: world.entities.filter(
|
||||
(c) =>
|
||||
c.type === 'collider' && !isTrigger(c.props) && isHandleEnabled(c) && !travelsWith(c, entity),
|
||||
);
|
||||
return {
|
||||
blockedX: this.slideAxis(entity, own, obstacles, 'x', dx),
|
||||
blockedY: this.slideAxis(entity, own, obstacles, 'y', dy),
|
||||
};
|
||||
}
|
||||
|
||||
private slideAxis(entity: Entity, own: Entity[], obstacles: Entity[], axis: PlaneAxis, delta: number): boolean {
|
||||
private slideAxis(
|
||||
entity: Entity,
|
||||
own: Entity[],
|
||||
obstacles: Entity[],
|
||||
axis: PlaneAxis,
|
||||
delta: number,
|
||||
): boolean {
|
||||
if (delta === 0 || own.length === 0) return false;
|
||||
// Long steps are split so a fast mover cannot jump across a thin obstacle in one frame.
|
||||
const steps = Math.min(MAX_STEPS, Math.max(1, Math.ceil(Math.abs(delta) / stepLimit(own, obstacles, axis))));
|
||||
for (let i = 0; i < steps; i++) if (this.stepAxis(entity, own, obstacles, axis, delta / steps)) return true;
|
||||
const steps = Math.min(
|
||||
MAX_STEPS,
|
||||
Math.max(1, Math.ceil(Math.abs(delta) / stepLimit(own, obstacles, axis))),
|
||||
);
|
||||
for (let i = 0; i < steps; i++)
|
||||
if (this.stepAxis(entity, own, obstacles, axis, delta / steps)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** One indivisible move along `axis`; returns whether an obstacle stopped it. */
|
||||
private stepAxis(entity: Entity, own: Entity[], obstacles: Entity[], axis: PlaneAxis, delta: number): boolean {
|
||||
private stepAxis(
|
||||
entity: Entity,
|
||||
own: Entity[],
|
||||
obstacles: Entity[],
|
||||
axis: PlaneAxis,
|
||||
delta: number,
|
||||
): boolean {
|
||||
const dir: 1 | -1 = delta > 0 ? 1 : -1;
|
||||
// How deep each pair already sits in the other before the step. A step may never deepen
|
||||
// that, but resolving it is not our job (no jolt, and leaving a wall is always possible).
|
||||
|
||||
@@ -8,18 +8,29 @@ export class Input {
|
||||
private readonly released = new Set<string>();
|
||||
private readonly onKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.target as HTMLElement | null)?.tagName === 'INPUT') return;
|
||||
if (!this.down.has(e.key)) this.pressed.add(e.key); // ignore auto-repeat
|
||||
this.down.add(e.key);
|
||||
this.press(e.key);
|
||||
if (e.key.startsWith('Arrow') || e.key === ' ') e.preventDefault();
|
||||
};
|
||||
private readonly onKeyUp = (e: KeyboardEvent) => {
|
||||
if (this.down.delete(e.key)) this.released.add(e.key);
|
||||
};
|
||||
private readonly onKeyUp = (e: KeyboardEvent) => this.release(e.key);
|
||||
/** Losing the window releases every key, so nothing stays "held" behind the user's back. */
|
||||
private readonly onBlur = () => {
|
||||
private readonly onBlur = () => this.releaseAll();
|
||||
|
||||
// The state machine below is what the DOM handlers above drive – and what tests drive
|
||||
// directly, since a headless run has no window to dispatch key events on.
|
||||
|
||||
press(key: string): void {
|
||||
if (!this.down.has(key)) this.pressed.add(key); // ignore auto-repeat
|
||||
this.down.add(key);
|
||||
}
|
||||
|
||||
release(key: string): void {
|
||||
if (this.down.delete(key)) this.released.add(key);
|
||||
}
|
||||
|
||||
releaseAll(): void {
|
||||
for (const key of this.down) this.released.add(key);
|
||||
this.down.clear();
|
||||
};
|
||||
}
|
||||
|
||||
attach(): void {
|
||||
window.addEventListener('keydown', this.onKeyDown);
|
||||
|
||||
Reference in new issue
Block a user