@waica/engine 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/game.d.ts CHANGED
@@ -4,6 +4,7 @@ import type { Component } from './component.js';
4
4
  import { Entity } from './entity.js';
5
5
  import { Emitter } from './events.js';
6
6
  import { Input, type InputBindings } from './input.js';
7
+ import { Pointer } from './pointer.js';
7
8
  import { type SceneRegistry, type SceneRenderJson } from './scene.js';
8
9
  import { Stats, type StatValue } from './stats.js';
9
10
  import { GameUi } from './ui.js';
@@ -41,6 +42,7 @@ export declare class Game {
41
42
  readonly scene: THREE.Scene<THREE.Object3DEventMap>;
42
43
  readonly camera: THREE.OrthographicCamera;
43
44
  readonly input: Input;
45
+ readonly pointer: Pointer;
44
46
  readonly entities: Entity[];
45
47
  readonly events: Emitter;
46
48
  readonly stats: Stats;
package/dist/game.js CHANGED
@@ -6,6 +6,7 @@ import { Hitbox } from './components/hitbox.js';
6
6
  import { Entity } from './entity.js';
7
7
  import { Emitter } from './events.js';
8
8
  import { Input } from './input.js';
9
+ import { Pointer } from './pointer.js';
9
10
  import { activeRuntimeBridgeHook, EngineRuntimeBridge, } from './runtime-bridge.js';
10
11
  import { RuntimeInspector } from './runtime-inspection.js';
11
12
  import { projectIsometric } from './projection.js';
@@ -21,6 +22,7 @@ export class Game {
21
22
  scene = new THREE.Scene();
22
23
  camera;
23
24
  input;
25
+ pointer;
24
26
  entities = [];
25
27
  events = new Emitter();
26
28
  stats;
@@ -58,6 +60,12 @@ export class Game {
58
60
  this.scene.background = new THREE.Color(background);
59
61
  this.camera = new THREE.OrthographicCamera();
60
62
  this.camera.position.z = 10;
63
+ this.pointer = new Pointer(canvas, {
64
+ camera: this.camera,
65
+ resolution: this.resolution,
66
+ projection: () => this.sceneProjection,
67
+ entities: this.entities,
68
+ });
61
69
  this.resize();
62
70
  this.resizeObserver = new ResizeObserver(() => this.resize());
63
71
  this.resizeObserver.observe(canvas);
@@ -162,6 +170,9 @@ export class Game {
162
170
  availableActions: () => this.input.availableActions(),
163
171
  heldActions: () => this.input.heldActions(),
164
172
  inspect: (metadata, filters) => inspector.snapshot(metadata, filters),
173
+ click: (x, y) => {
174
+ this.pointer.injectClick(x, y);
175
+ },
165
176
  });
166
177
  activation.register(this.runtimeBridge);
167
178
  window.addEventListener('pagehide', this.unregisterRuntimeBridge);
@@ -197,6 +208,7 @@ export class Game {
197
208
  this.stop();
198
209
  this.unregisterRuntimeBridge();
199
210
  this.input.dispose();
211
+ this.pointer.dispose();
200
212
  this.resizeObserver.disconnect();
201
213
  this.ui.dispose();
202
214
  for (const entity of [...this.entities])
package/dist/index.d.ts CHANGED
@@ -20,7 +20,9 @@ export { resolveComponentUpdateSchedule } from './component-update-schedule.js';
20
20
  export type { ComponentUpdateCycleIssue, ComponentUpdateRegistry, ComponentUpdateScheduleIssue, ComponentUpdateScheduleResult, DuplicateComponentUpdateIssue, InvalidComponentUpdateConstraintIssue, InvalidComponentUpdateSchedule, InvalidUpdateConstraintReason, ValidComponentUpdateSchedule, } from './component-update-schedule.js';
21
21
  export { Input, DEFAULT_BINDINGS } from './input.js';
22
22
  export type { ActionName, InjectedActionOperation, InputBindings } from './input.js';
23
- export { RUNTIME_BRIDGE_PROTOCOL_VERSION, RUNTIME_BRIDGE_SYMBOL, RuntimeBridgeOperationError, } from './runtime-bridge.js';
23
+ export { Pointer } from './pointer.js';
24
+ export type { PointerCamera, PointerDeps, PointerPick, PointerResolution } from './pointer.js';
25
+ export { RUNTIME_BRIDGE_CAPABILITIES, RUNTIME_BRIDGE_PROTOCOL_VERSION, RUNTIME_BRIDGE_SYMBOL, RuntimeBridgeOperationError, } from './runtime-bridge.js';
24
26
  export type { RuntimeBridge, RuntimeBridgeActivation, RuntimeControlRequest, RuntimeControlResult, RuntimeMetadata, RuntimeMode, } from './runtime-bridge.js';
25
27
  export { RUNTIME_PROJECTION_LIMITS } from './runtime-inspection.js';
26
28
  export type { ProjectedValue, ProjectionIssue, ProjectionMarker, ProjectionMarkerKind, RuntimeComponentSnapshot, RuntimeEntitySnapshot, RuntimeSnapshot, RuntimeSnapshotFilters, RuntimeTransformSnapshot, } from './runtime-inspection.js';
package/dist/index.js CHANGED
@@ -10,7 +10,8 @@ export { authoringDefaults } from './authoring-defaults.js';
10
10
  export { collectModuleComponents, mergeRegistryComponents } from './component-registry.js';
11
11
  export { resolveComponentUpdateSchedule } from './component-update-schedule.js';
12
12
  export { Input, DEFAULT_BINDINGS } from './input.js';
13
- export { RUNTIME_BRIDGE_PROTOCOL_VERSION, RUNTIME_BRIDGE_SYMBOL, RuntimeBridgeOperationError, } from './runtime-bridge.js';
13
+ export { Pointer } from './pointer.js';
14
+ export { RUNTIME_BRIDGE_CAPABILITIES, RUNTIME_BRIDGE_PROTOCOL_VERSION, RUNTIME_BRIDGE_SYMBOL, RuntimeBridgeOperationError, } from './runtime-bridge.js';
14
15
  export { RUNTIME_PROJECTION_LIMITS } from './runtime-inspection.js';
15
16
  export { Stats } from './stats.js';
16
17
  export { GameUi } from './ui.js';
@@ -0,0 +1,71 @@
1
+ import type { Entity } from './entity.js';
2
+ import { type ProjectedPoint } from './projection.js';
3
+ /** Fixed-resolution letterbox target; a duck-typed subset of GameResolution. */
4
+ export interface PointerResolution {
5
+ width: number;
6
+ height: number;
7
+ }
8
+ /** The orthographic camera state a Pointer needs; a duck-typed THREE.OrthographicCamera. */
9
+ export interface PointerCamera {
10
+ position: {
11
+ x: number;
12
+ y: number;
13
+ };
14
+ left: number;
15
+ right: number;
16
+ top: number;
17
+ bottom: number;
18
+ }
19
+ export interface PointerDeps {
20
+ camera: PointerCamera;
21
+ /** Fixed resolution (letterbox target), or null to fill the canvas. */
22
+ resolution: PointerResolution | null;
23
+ /** The scene's active render projection, read live. */
24
+ projection: () => 'isometric' | null;
25
+ /** Live entity list — read at pick time, so later spawns are visible. */
26
+ entities: readonly Entity[];
27
+ }
28
+ /** One resolved click: the logical-space point, plus the entity picked there (if any). */
29
+ export interface PointerPick {
30
+ readonly point: ProjectedPoint;
31
+ readonly entity: Entity | null;
32
+ }
33
+ /**
34
+ * Engine-owned pointer primitive (ADR-0010): the only place a click/tap on
35
+ * the game canvas is read. Converts a real `pointerdown` into a logical-space
36
+ * point (camera, letterbox, isometric unprojection) plus the entity picked
37
+ * there — resolved against declared sprite bounds (width/height/offset/anchor
38
+ * at the entity's projected position), y-sort breaking ties front-most wins —
39
+ * and queues it as one pending click for a consumer (ClickToMove) to drain.
40
+ * The Runtime Bridge injects a click through the same resolution, in logical
41
+ * coordinates, via injectClick.
42
+ */
43
+ export declare class Pointer {
44
+ private readonly canvas;
45
+ private readonly deps;
46
+ private pending;
47
+ constructor(canvas: HTMLCanvasElement, deps: PointerDeps);
48
+ dispose(): void;
49
+ /**
50
+ * Consumes and clears the pending click, if any. A consumer (the grid
51
+ * player role's update) drains this once per simulated frame, so a click
52
+ * queued while paused takes effect on the next stepped frame.
53
+ */
54
+ takePending(): PointerPick | null;
55
+ /**
56
+ * Programmatic click injection (Runtime Bridge CA-10), in logical
57
+ * coordinates — resolved through the exact same picking a real click uses.
58
+ */
59
+ injectClick(x: number, y: number): PointerPick;
60
+ private handlePointerDown;
61
+ private letterboxRect;
62
+ private toRenderPoint;
63
+ private resolveRenderPoint;
64
+ /**
65
+ * The entity whose projected sprite bounds contain the render-space point,
66
+ * front-most under y-sort winning ties (ADR-0010, CA-1). Ignores current
67
+ * animation frame and mirroring: picking is scoped to the declared
68
+ * width/height/offset/anchor box, not the momentary displayed frame.
69
+ */
70
+ private pickEntity;
71
+ }
@@ -0,0 +1,162 @@
1
+ import { projectIsometric, unprojectIsometric } from './projection.js';
2
+ import { ySortZ } from './render-sort.js';
3
+ import { AnimatedSprite } from './components/animated-sprite.js';
4
+ import { Sprite } from './components/sprite.js';
5
+ import { spritePlacement } from './sprite-placement.js';
6
+ function spriteBoxOf(component) {
7
+ if (!(component instanceof Sprite) && !(component instanceof AnimatedSprite))
8
+ return null;
9
+ return {
10
+ width: component.width,
11
+ height: component.height,
12
+ offsetX: component.offsetX,
13
+ offsetY: component.offsetY,
14
+ anchorX: component.anchorX,
15
+ anchorY: component.anchorY,
16
+ layer: component.layer,
17
+ };
18
+ }
19
+ /**
20
+ * Engine-owned pointer primitive (ADR-0010): the only place a click/tap on
21
+ * the game canvas is read. Converts a real `pointerdown` into a logical-space
22
+ * point (camera, letterbox, isometric unprojection) plus the entity picked
23
+ * there — resolved against declared sprite bounds (width/height/offset/anchor
24
+ * at the entity's projected position), y-sort breaking ties front-most wins —
25
+ * and queues it as one pending click for a consumer (ClickToMove) to drain.
26
+ * The Runtime Bridge injects a click through the same resolution, in logical
27
+ * coordinates, via injectClick.
28
+ */
29
+ export class Pointer {
30
+ canvas;
31
+ deps;
32
+ pending = null;
33
+ constructor(canvas, deps) {
34
+ this.canvas = canvas;
35
+ this.deps = deps;
36
+ this.canvas.addEventListener('pointerdown', this.handlePointerDown);
37
+ }
38
+ dispose() {
39
+ this.canvas.removeEventListener('pointerdown', this.handlePointerDown);
40
+ this.pending = null;
41
+ }
42
+ /**
43
+ * Consumes and clears the pending click, if any. A consumer (the grid
44
+ * player role's update) drains this once per simulated frame, so a click
45
+ * queued while paused takes effect on the next stepped frame.
46
+ */
47
+ takePending() {
48
+ const pick = this.pending;
49
+ this.pending = null;
50
+ return pick;
51
+ }
52
+ /**
53
+ * Programmatic click injection (Runtime Bridge CA-10), in logical
54
+ * coordinates — resolved through the exact same picking a real click uses.
55
+ */
56
+ injectClick(x, y) {
57
+ const renderPoint = this.toRenderPoint(x, y);
58
+ const pick = this.resolveRenderPoint(renderPoint.x, renderPoint.y);
59
+ this.pending = pick;
60
+ return pick;
61
+ }
62
+ handlePointerDown = (event) => {
63
+ // Primary button only; a touch tap reports the same button (ADR-0010).
64
+ if (event.button !== 0)
65
+ return;
66
+ const w = this.canvas.clientWidth;
67
+ const h = this.canvas.clientHeight;
68
+ if (w <= 0 || h <= 0)
69
+ return;
70
+ const { vx, vy, vw, vh } = this.letterboxRect(w, h);
71
+ if (vw <= 0 || vh <= 0)
72
+ return;
73
+ const rect = this.canvas.getBoundingClientRect();
74
+ const px = event.clientX - rect.left;
75
+ const py = event.clientY - rect.top;
76
+ const nx = (px - vx) / vw;
77
+ const ny = (py - vy) / vh;
78
+ // Outside the visible viewport (a letterbox bar): not a click on the world.
79
+ if (nx < 0 || nx > 1 || ny < 0 || ny > 1)
80
+ return;
81
+ const camera = this.deps.camera;
82
+ const renderX = camera.position.x + camera.left + nx * (camera.right - camera.left);
83
+ const renderY = camera.position.y + camera.top - ny * (camera.top - camera.bottom);
84
+ this.pending = this.resolveRenderPoint(renderX, renderY);
85
+ };
86
+ letterboxRect(w, h) {
87
+ const resolution = this.deps.resolution;
88
+ if (!resolution)
89
+ return { vx: 0, vy: 0, vw: w, vh: h };
90
+ const aspect = resolution.width / resolution.height;
91
+ const vw = Math.min(w, h * aspect);
92
+ const vh = vw / aspect;
93
+ return { vx: (w - vw) / 2, vy: (h - vh) / 2, vw, vh };
94
+ }
95
+ toRenderPoint(logicalX, logicalY) {
96
+ return this.deps.projection() === 'isometric'
97
+ ? projectIsometric(logicalX, logicalY)
98
+ : { x: logicalX, y: logicalY };
99
+ }
100
+ resolveRenderPoint(renderX, renderY) {
101
+ const point = this.deps.projection() === 'isometric'
102
+ ? unprojectIsometric(renderX, renderY)
103
+ : { x: renderX, y: renderY };
104
+ return { point, entity: this.pickEntity(renderX, renderY) };
105
+ }
106
+ /**
107
+ * The entity whose projected sprite bounds contain the render-space point,
108
+ * front-most under y-sort winning ties (ADR-0010, CA-1). Ignores current
109
+ * animation frame and mirroring: picking is scoped to the declared
110
+ * width/height/offset/anchor box, not the momentary displayed frame.
111
+ */
112
+ pickEntity(renderX, renderY) {
113
+ const hits = [];
114
+ for (const entity of this.deps.entities) {
115
+ if (!entity.alive)
116
+ continue;
117
+ for (const component of entity.components) {
118
+ const box = spriteBoxOf(component);
119
+ if (!box)
120
+ continue;
121
+ const anchor = this.deps.projection() === 'isometric'
122
+ ? projectIsometric(entity.position.x, entity.position.y)
123
+ : { x: entity.position.x, y: entity.position.y };
124
+ const placement = spritePlacement({
125
+ width: box.width,
126
+ height: box.height,
127
+ offsetX: box.offsetX,
128
+ offsetY: box.offsetY,
129
+ anchorX: box.anchorX,
130
+ anchorY: box.anchorY,
131
+ flipX: false,
132
+ frameScaleX: 1,
133
+ frameScaleY: 1,
134
+ });
135
+ const halfW = Math.abs(placement.scaleX) / 2;
136
+ const halfH = Math.abs(placement.scaleY) / 2;
137
+ const centerX = anchor.x + placement.x;
138
+ const centerY = anchor.y + placement.y;
139
+ if (renderX >= centerX - halfW &&
140
+ renderX <= centerX + halfW &&
141
+ renderY >= centerY - halfH &&
142
+ renderY <= centerY + halfH) {
143
+ // Matches Game.applyYSort's key exactly: the entity's node
144
+ // position, which for an isometric scene is the projected Y (the
145
+ // same `anchor.y` just used to place this box), not the logical one.
146
+ hits.push({ entity, layer: box.layer, y: anchor.y });
147
+ break;
148
+ }
149
+ }
150
+ }
151
+ if (hits.length === 0)
152
+ return null;
153
+ if (hits.length === 1)
154
+ return hits[0].entity;
155
+ const z = ySortZ(hits.map((h) => ({ layer: h.layer, y: h.y })));
156
+ let bestIndex = 0;
157
+ for (let i = 1; i < hits.length; i += 1)
158
+ if (z[i] > z[bestIndex])
159
+ bestIndex = i;
160
+ return hits[bestIndex].entity;
161
+ }
162
+ }
@@ -2,12 +2,23 @@ import type { RuntimeSnapshot, RuntimeSnapshotFilters } from './runtime-inspecti
2
2
  export declare const RUNTIME_BRIDGE_PROTOCOL_VERSION: 1;
3
3
  export declare const RUNTIME_BRIDGE_SYMBOL: unique symbol;
4
4
  export type RuntimeMode = 'paused' | 'real-time';
5
+ /**
6
+ * Operations this build's control() actually implements, beyond the
7
+ * baseline protocol 1 set (press/hold/release/pause/resume/step) every
8
+ * bridge has always supported. Additive metadata, not a protocol bump: a
9
+ * pre-CA-10 engine simply lacks this field, which is exactly what callers
10
+ * gating on capabilities check for (review finding #4) — protocol 1 alone
11
+ * doesn't distinguish an engine that silently no-ops an unknown operation
12
+ * from one that runs it.
13
+ */
14
+ export declare const RUNTIME_BRIDGE_CAPABILITIES: readonly ['click'];
5
15
  export interface RuntimeMetadata {
6
16
  bridgeVersion: typeof RUNTIME_BRIDGE_PROTOCOL_VERSION;
7
17
  engineVersion: string;
8
18
  mode: RuntimeMode;
9
19
  frame: number;
10
20
  simulationTime: number;
21
+ capabilities: readonly string[];
11
22
  }
12
23
  export type RuntimeControlRequest = {
13
24
  operation: 'press' | 'hold' | 'release';
@@ -18,6 +29,10 @@ export type RuntimeControlRequest = {
18
29
  operation: 'step';
19
30
  dt?: number;
20
31
  frames?: number;
32
+ } | {
33
+ operation: 'click';
34
+ x: number;
35
+ y: number;
21
36
  };
22
37
  export interface RuntimeControlResult extends RuntimeMetadata {
23
38
  heldActions: string[];
@@ -50,6 +65,7 @@ export interface RuntimeBridgeHost {
50
65
  availableActions(): string[];
51
66
  heldActions(): string[];
52
67
  inspect(metadata: RuntimeMetadata, filters?: RuntimeSnapshotFilters): RuntimeSnapshot;
68
+ click(x: number, y: number): void;
53
69
  }
54
70
  export declare class EngineRuntimeBridge implements RuntimeBridge {
55
71
  readonly surface: HTMLCanvasElement;
@@ -1,6 +1,16 @@
1
1
  import enginePackage from '../package.json' with { type: 'json' };
2
2
  export const RUNTIME_BRIDGE_PROTOCOL_VERSION = 1;
3
3
  export const RUNTIME_BRIDGE_SYMBOL = Symbol.for('@waica/runtime-bridge/v1');
4
+ /**
5
+ * Operations this build's control() actually implements, beyond the
6
+ * baseline protocol 1 set (press/hold/release/pause/resume/step) every
7
+ * bridge has always supported. Additive metadata, not a protocol bump: a
8
+ * pre-CA-10 engine simply lacks this field, which is exactly what callers
9
+ * gating on capabilities check for (review finding #4) — protocol 1 alone
10
+ * doesn't distinguish an engine that silently no-ops an unknown operation
11
+ * from one that runs it.
12
+ */
13
+ export const RUNTIME_BRIDGE_CAPABILITIES = ['click'];
4
14
  export class RuntimeBridgeOperationError extends Error {
5
15
  code;
6
16
  availableActions;
@@ -45,6 +55,7 @@ export class EngineRuntimeBridge {
45
55
  mode: this.mode,
46
56
  frame: this.frame,
47
57
  simulationTime: this.simulationTime,
58
+ capabilities: RUNTIME_BRIDGE_CAPABILITIES,
48
59
  };
49
60
  }
50
61
  inspect(filters = {}) {
@@ -88,6 +99,17 @@ export class EngineRuntimeBridge {
88
99
  this.advance(dt);
89
100
  break;
90
101
  }
102
+ case 'click': {
103
+ if (!Number.isFinite(request.x) || !Number.isFinite(request.y)) {
104
+ throw new RuntimeBridgeOperationError('runtime-operation-failed', 'x and y must be finite numbers.');
105
+ }
106
+ this.host.click(request.x, request.y);
107
+ break;
108
+ }
109
+ default: {
110
+ const unsupported = request;
111
+ throw new RuntimeBridgeOperationError('runtime-operation-failed', `Unsupported runtime control operation "${unsupported.operation}".`);
112
+ }
91
113
  }
92
114
  return { ...this.metadata(), heldActions: this.host.heldActions() };
93
115
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waica/engine",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "description": "Waica game engine core — archetype-driven, web-first, 2D & 3D",
5
5
  "license": "MIT",
6
6
  "type": "module",