Enabled switch, text assets, mouse input and the jump-and-run example game
Engine additions that the example game needed: - `enabled` as a shared property of every placeable asset (default on, an override on the scene object). A switched-off object is not drawn, does not collide and its behavior does not run - and neither does anything parented below it. Disabled objects are now hidden in the scene view as well, so the editor shows what the game shows; they stay in the hierarchy panel, which is where they are selected and switched back on. - Asset type `text` (text, fontSize, color, align), rendered in scene and game. - Pointer input in the game view: `On click`, `Mouse position`, `Mouse button`, `Is hovered`, hit-tested against colliders, else sprite/text footprints. - New nodes: Set/Is enabled, Set text, Is asset, Name of, Equals, To text, Join text, Floor/Ceil/Round, Modulo, Random, Time, Restart game. Tooling: shared CDP harness (tools/harness.mjs), central project registry with a guard that keeps destructive scripts off the playground (tools/projects.mjs), plus smoke suites for the demo scene and the jump-and-run game. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4900d7bbf6
commit
76aa441dce
33 files changed
+1905
-301
No files matched your search
+54
-1
@@ -1,4 +1,6 @@
|
||||
import { worldPositionOf } from '../core/graph';
|
||||
import { isHandleEnabled, type PointerState, worldPositionOf } from '../core/graph';
|
||||
import { footprintContains } from '../core/properties/bounds';
|
||||
import { colliderShape, shapeContains } from '../core/properties/collider';
|
||||
import { instantiateContainer } from '../core/model/container';
|
||||
import { newId } from '../core/ids';
|
||||
import type { Asset } from '../core/model/Asset';
|
||||
@@ -23,6 +25,13 @@ export class World {
|
||||
backgroundColor = 0x000000;
|
||||
/** Set by a behavior (Stop game node); the editor leaves play mode on the next tick. */
|
||||
stopRequested = false;
|
||||
/** Set by a behavior (Restart game node); the editor restarts the scene on the next tick. */
|
||||
restartRequested = false;
|
||||
/**
|
||||
* 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 };
|
||||
/** Messages logged by behaviors (Log node), newest last. */
|
||||
readonly log: string[] = [];
|
||||
private readonly systems: System[] = [];
|
||||
@@ -160,6 +169,50 @@ export class World {
|
||||
tick(dt: number): void {
|
||||
this.elapsed += dt;
|
||||
for (const system of this.systems) system.update(this, dt);
|
||||
// The pointer edges belong to exactly one tick, whatever the systems did with them.
|
||||
this.pointer.pressed = false;
|
||||
this.pointer.released = false;
|
||||
}
|
||||
|
||||
/** Game view: where the mouse is (world units) and whether it is over the view at all. */
|
||||
movePointer(x: number, y: number, inside: boolean): void {
|
||||
this.pointer.x = x;
|
||||
this.pointer.y = y;
|
||||
this.pointer.inside = inside;
|
||||
}
|
||||
|
||||
/** Game view: the mouse button changed. Sets the edge flag the current tick reports. */
|
||||
setPointerDown(down: boolean): void {
|
||||
if (down === this.pointer.down) return;
|
||||
this.pointer.down = down;
|
||||
if (down) this.pointer.pressed = true;
|
||||
else this.pointer.released = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a world point lies on an entity: on the colliders of its subtree when it has
|
||||
* any, otherwise on the box of a sprite or text in it. Disabled parts never answer, so a
|
||||
* hidden screen cannot swallow clicks meant for what is drawn behind it.
|
||||
*/
|
||||
hitTest(entity: Entity, x: number, y: number): boolean {
|
||||
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, this.worldPosition(c)), point));
|
||||
return parts.some((e) => (e.type === 'sprite' || e.type === 'text') && footprintContains(e.type, e.props, this.worldPosition(e), point));
|
||||
}
|
||||
|
||||
/** Everything parented below an entity, at any depth. */
|
||||
descendantsOf(entity: Entity): Entity[] {
|
||||
const result: Entity[] = [];
|
||||
const visit = (parent: Entity) => {
|
||||
for (const child of this.childrenOf(parent)) {
|
||||
result.push(child);
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
visit(entity);
|
||||
return result;
|
||||
}
|
||||
|
||||
get time(): number {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type EntityHandle, GraphInterpreter, type NodeContext, type TraceListener } from '../../core/graph';
|
||||
import { type EntityHandle, GraphInterpreter, isHandleEnabled, type NodeContext, type TraceListener } from '../../core/graph';
|
||||
import { findAsset, type Project } from '../../core/model/Project';
|
||||
import type { Entity } from '../Entity';
|
||||
import type { World } from '../World';
|
||||
@@ -44,12 +44,14 @@ export class BehaviorSystem implements System {
|
||||
|
||||
/**
|
||||
* One instance per behavior entity: entities that appeared (placed while playing) get an
|
||||
* interpreter and their `On start`; instances whose entity is gone are dropped.
|
||||
* interpreter and their `On start`; instances whose entity is gone are dropped. A behavior
|
||||
* that is switched off has no instance yet – it gets one, and its `On start`, the moment it
|
||||
* is switched on. That is what lets a hidden screen wake up when it is shown.
|
||||
*/
|
||||
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' || 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() };
|
||||
@@ -66,6 +68,7 @@ export class BehaviorSystem implements System {
|
||||
const keyOf = (node: { params: Record<string, unknown> }) => 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);
|
||||
@@ -81,6 +84,10 @@ export class BehaviorSystem implements System {
|
||||
}
|
||||
}
|
||||
this.fireCollisions(instance, i, ctx);
|
||||
const pointer = world.pointer;
|
||||
if (pointer.pressed && world.hitTest(instance.entity.parent ?? instance.entity, pointer.x, pointer.y)) {
|
||||
i.fire('OnClick', ctx, { x: pointer.x, y: pointer.y });
|
||||
}
|
||||
i.fire('OnUpdate', ctx, { dt });
|
||||
}, dt);
|
||||
}
|
||||
@@ -144,6 +151,11 @@ export class BehaviorSystem implements System {
|
||||
dt,
|
||||
findByName: (name) => world.findByName(name) ?? null,
|
||||
isKeyDown: (key) => this.input.isDown(key),
|
||||
pointer: world.pointer,
|
||||
hitTest: (target, x, y) => {
|
||||
const found = world.entities.find((e) => e === target || e.id === target.id);
|
||||
return Boolean(found && world.hitTest(found, x, y));
|
||||
},
|
||||
isTouching: (a, b) => {
|
||||
const ea = world.entities.find((e) => e === a || e.id === a.id);
|
||||
const eb = world.entities.find((e) => e === b || e.id === b.id);
|
||||
@@ -172,6 +184,9 @@ export class BehaviorSystem implements System {
|
||||
stop: () => {
|
||||
world.stopRequested = true;
|
||||
},
|
||||
restart: () => {
|
||||
world.restartRequested = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isHandleEnabled } from '../../core/graph';
|
||||
import { type Axis, axisCorrection, colliderShape, colliderSize, isTrigger, shapesOverlap } from '../../core/properties/collider';
|
||||
import type { Entity } from '../Entity';
|
||||
import type { World } from '../World';
|
||||
@@ -27,7 +28,8 @@ export class CollisionSystem implements System {
|
||||
private previous = new Map<string, CollisionPair>();
|
||||
|
||||
update(world: World): void {
|
||||
const colliders = world.entities.filter((e) => e.type === 'collider');
|
||||
// Switched-off colliders (or ones below something switched off) take no part at all.
|
||||
const colliders = world.entities.filter((e) => e.type === 'collider' && isHandleEnabled(e));
|
||||
const current = new Map<string, CollisionPair>();
|
||||
for (let i = 0; i < colliders.length; i++) {
|
||||
for (let j = i + 1; j < colliders.length; j++) {
|
||||
@@ -77,8 +79,8 @@ export class CollisionSystem implements System {
|
||||
* colliders and colliders that travel with it (descendants) never block.
|
||||
*/
|
||||
moveAndSlide(world: World, entity: Entity, dx: number, dy: number): { blockedX: boolean; blockedY: boolean } {
|
||||
const own = collidersOf(world, entity).filter((c) => !isTrigger(c.props));
|
||||
const obstacles = own.length === 0 ? [] : world.entities.filter((c) => c.type === 'collider' && !isTrigger(c.props) && !this.travelsWith(c, entity));
|
||||
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) && !this.travelsWith(c, entity));
|
||||
return { blockedX: this.slideAxis(world, entity, own, obstacles, 'x', dx), blockedY: this.slideAxis(world, entity, own, obstacles, 'y', dy) };
|
||||
}
|
||||
|
||||
@@ -155,5 +157,5 @@ export function bodyOf(collider: Entity): Entity {
|
||||
/** A collider stands for itself; any other entity stands for its direct collider children. */
|
||||
export function collidersOf(world: World, entity: Entity): Entity[] {
|
||||
if (entity.type === 'collider') return [entity];
|
||||
return world.childrenOf(entity).filter((e) => e.type === 'collider');
|
||||
return world.childrenOf(entity).filter((e) => e.type === 'collider' && isHandleEnabled(e));
|
||||
}
|
||||
Reference in new issue
Block a user