@waica/engine 0.3.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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/dist/aabb.d.ts +2 -0
  3. package/dist/aabb.js +4 -0
  4. package/dist/animation/clip-player.d.ts +20 -0
  5. package/dist/animation/clip-player.js +24 -0
  6. package/dist/animation/contract.d.ts +21 -0
  7. package/dist/animation/contract.js +23 -0
  8. package/dist/animation/sheet.d.ts +65 -0
  9. package/dist/animation/sheet.js +44 -0
  10. package/dist/archetype.d.ts +37 -0
  11. package/dist/archetype.js +1 -0
  12. package/dist/camera.d.ts +71 -0
  13. package/dist/camera.js +59 -0
  14. package/dist/collision-shape.d.ts +26 -0
  15. package/dist/collision-shape.js +136 -0
  16. package/dist/component-registry.d.ts +16 -0
  17. package/dist/component-registry.js +44 -0
  18. package/dist/component.d.ts +58 -0
  19. package/dist/component.js +11 -0
  20. package/dist/components/animated-sprite.d.ts +80 -0
  21. package/dist/components/animated-sprite.js +210 -0
  22. package/dist/components/dynamic-body.d.ts +68 -0
  23. package/dist/components/dynamic-body.js +189 -0
  24. package/dist/components/hitbox.d.ts +25 -0
  25. package/dist/components/hitbox.js +21 -0
  26. package/dist/components/solid.d.ts +32 -0
  27. package/dist/components/solid.js +45 -0
  28. package/dist/components/sprite.d.ts +51 -0
  29. package/dist/components/sprite.js +112 -0
  30. package/dist/entity.d.ts +22 -0
  31. package/dist/entity.js +52 -0
  32. package/dist/events.d.ts +8 -0
  33. package/dist/events.js +20 -0
  34. package/dist/game.d.ts +95 -0
  35. package/dist/game.js +263 -0
  36. package/dist/index.d.ts +39 -0
  37. package/dist/index.js +26 -0
  38. package/dist/input.d.ts +40 -0
  39. package/dist/input.js +84 -0
  40. package/dist/scene.d.ts +80 -0
  41. package/dist/scene.js +93 -0
  42. package/dist/solid-axis.d.ts +21 -0
  43. package/dist/solid-axis.js +77 -0
  44. package/dist/state/hooks.d.ts +90 -0
  45. package/dist/state/hooks.js +81 -0
  46. package/dist/state/state-machine.d.ts +83 -0
  47. package/dist/state/state-machine.js +173 -0
  48. package/dist/stats.d.ts +27 -0
  49. package/dist/stats.js +54 -0
  50. package/dist/ui.d.ts +47 -0
  51. package/dist/ui.js +175 -0
  52. package/package.json +32 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ayrton Marini and Waica contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/aabb.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** Do two center+size AABBs overlap? Edges that merely touch don't count. */
2
+ export declare function aabbOverlap(ax: number, ay: number, aw: number, ah: number, bx: number, by: number, bw: number, bh: number): boolean;
package/dist/aabb.js ADDED
@@ -0,0 +1,4 @@
1
+ /** Do two center+size AABBs overlap? Edges that merely touch don't count. */
2
+ export function aabbOverlap(ax, ay, aw, ah, bx, by, bw, bh) {
3
+ return Math.abs(ax - bx) * 2 < aw + bw && Math.abs(ay - by) * 2 < ah + bh;
4
+ }
@@ -0,0 +1,20 @@
1
+ export interface ClipDef {
2
+ /** Frame indices inside the spritesheet (row×cols + col). */
3
+ frames: number[];
4
+ fps: number;
5
+ /** Defaults to true; with false it sticks on the last frame. */
6
+ loop?: boolean;
7
+ }
8
+ /**
9
+ * Advances a clip through time. Pure logic (no three, no DOM) so it can
10
+ * be tested deterministically.
11
+ */
12
+ export declare class ClipPlayer {
13
+ private frames;
14
+ private fps;
15
+ private loop;
16
+ private t;
17
+ set(clip: ClipDef): void;
18
+ /** Advances the clock and returns the sheet frame to show. */
19
+ advance(dt: number): number;
20
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Advances a clip through time. Pure logic (no three, no DOM) so it can
3
+ * be tested deterministically.
4
+ */
5
+ export class ClipPlayer {
6
+ frames = [0];
7
+ fps = 1;
8
+ loop = true;
9
+ t = 0;
10
+ set(clip) {
11
+ this.frames = clip.frames.length > 0 ? clip.frames : [0];
12
+ this.fps = clip.fps;
13
+ this.loop = clip.loop ?? true;
14
+ this.t = 0;
15
+ }
16
+ /** Advances the clock and returns the sheet frame to show. */
17
+ advance(dt) {
18
+ this.t += dt;
19
+ const idx = Math.floor(this.t * this.fps);
20
+ const n = this.frames.length;
21
+ const clamped = this.loop ? idx % n : Math.min(idx, n - 1);
22
+ return this.frames[clamped] ?? 0;
23
+ }
24
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Animation contract: the archetype declares which clips a character
3
+ * needs and how to degrade when one is missing. It's the centerpiece of
4
+ * the "the engine tells you which assets you need" thesis (DESIGN.md §2
5
+ * and §4): the game works from minute zero with whatever is there, and
6
+ * the editor (H2) will show the gaps as a checklist.
7
+ */
8
+ export interface AnimationContract {
9
+ /** Clips the archetype expects to exist. */
10
+ required: string[];
11
+ /** Degradation chain: if a clip is missing, which one replaces it. */
12
+ fallbacks: Record<string, string>;
13
+ }
14
+ /**
15
+ * Resolves which clip to play: the requested one if it exists, otherwise
16
+ * it follows the fallback chain, and as a last resort the first available
17
+ * clip.
18
+ */
19
+ export declare function resolveClip(contract: AnimationContract, available: Iterable<string>, wanted: string): string | undefined;
20
+ /** Which contract clips are missing — what the editor will show as gaps. */
21
+ export declare function missingClips(contract: AnimationContract, available: Iterable<string>): string[];
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Resolves which clip to play: the requested one if it exists, otherwise
3
+ * it follows the fallback chain, and as a last resort the first available
4
+ * clip.
5
+ */
6
+ export function resolveClip(contract, available, wanted) {
7
+ const set = new Set(available);
8
+ const seen = new Set();
9
+ let current = wanted;
10
+ while (current && !seen.has(current)) {
11
+ if (set.has(current))
12
+ return current;
13
+ seen.add(current);
14
+ current = contract.fallbacks[current];
15
+ }
16
+ const [first] = set;
17
+ return first;
18
+ }
19
+ /** Which contract clips are missing — what the editor will show as gaps. */
20
+ export function missingClips(contract, available) {
21
+ const set = new Set(available);
22
+ return contract.required.filter((clip) => !set.has(clip));
23
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Spritesheet slicing geometry shared by the runtime (UV math) and the
3
+ * editor (grid overlay + previews). A sheet is a cols×rows grid of cells;
4
+ * the optional params handle sheets whose frames don't fill the image:
5
+ * a margin before the first cell, gaps between cells, or an explicit
6
+ * cell size. All values are in source-image pixels and may be fractional
7
+ * (downscaled exports often land on half pixels).
8
+ */
9
+ export interface SheetGridParams {
10
+ /** Top-left corner of the first cell. */
11
+ gridOffsetX?: number;
12
+ gridOffsetY?: number;
13
+ /** Gap between adjacent cells. */
14
+ spacingX?: number;
15
+ spacingY?: number;
16
+ /** Explicit cell size; unset/0 = split what the offset leaves evenly. */
17
+ cellWidth?: number;
18
+ cellHeight?: number;
19
+ }
20
+ /** One cell's pixel rect within the sheet image. */
21
+ export interface SheetCell {
22
+ x: number;
23
+ y: number;
24
+ width: number;
25
+ height: number;
26
+ }
27
+ /**
28
+ * One spritesheet: its image plus how it slices into frames — a uniform
29
+ * cols×rows grid, or explicit per-frame `cells` (auto-detected or hand-drawn)
30
+ * for packed sheets whose frames don't sit on a grid. When `cells` is present
31
+ * it wins over the grid.
32
+ */
33
+ export interface SheetDef extends SheetGridParams {
34
+ texture: string;
35
+ cols: number;
36
+ rows: number;
37
+ cells?: SheetCell[];
38
+ }
39
+ /** How many frames a sheet yields: its cell count, else the grid, at least 1. */
40
+ export declare function sheetFrameCount(sheet: {
41
+ cols: number;
42
+ rows: number;
43
+ cells?: SheetCell[];
44
+ }): number;
45
+ /**
46
+ * Maps a global frame index to its sheet and the frame within it. Sheets
47
+ * are numbered consecutively: sheet 0 owns frames 0..n0-1, sheet 1 the next
48
+ * n1, and so on. Out-of-range indices clamp to the nearest valid frame.
49
+ */
50
+ export declare function locateFrame(sheets: readonly {
51
+ cols: number;
52
+ rows: number;
53
+ cells?: SheetCell[];
54
+ }[], frame: number): {
55
+ sheet: number;
56
+ frame: number;
57
+ };
58
+ /**
59
+ * The pixel rect of `frame` (row-major index) in a sheet image. Explicit
60
+ * `cells` win when present (out-of-range indices clamp); otherwise the grid:
61
+ * with no params, the image divided evenly into cols×rows.
62
+ */
63
+ export declare function sheetCell(imageWidth: number, imageHeight: number, cols: number, rows: number, frame: number, params?: SheetGridParams & {
64
+ cells?: SheetCell[];
65
+ }): SheetCell;
@@ -0,0 +1,44 @@
1
+ /** How many frames a sheet yields: its cell count, else the grid, at least 1. */
2
+ export function sheetFrameCount(sheet) {
3
+ if (sheet.cells?.length)
4
+ return sheet.cells.length;
5
+ return Math.max(1, Math.floor(sheet.cols)) * Math.max(1, Math.floor(sheet.rows));
6
+ }
7
+ /**
8
+ * Maps a global frame index to its sheet and the frame within it. Sheets
9
+ * are numbered consecutively: sheet 0 owns frames 0..n0-1, sheet 1 the next
10
+ * n1, and so on. Out-of-range indices clamp to the nearest valid frame.
11
+ */
12
+ export function locateFrame(sheets, frame) {
13
+ let rest = Math.max(0, Math.floor(frame));
14
+ for (let i = 0; i < sheets.length; i++) {
15
+ const count = sheetFrameCount(sheets[i]);
16
+ if (rest < count || i === sheets.length - 1)
17
+ return { sheet: i, frame: Math.min(rest, count - 1) };
18
+ rest -= count;
19
+ }
20
+ return { sheet: 0, frame: 0 };
21
+ }
22
+ const positive = (value) => typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
23
+ /**
24
+ * The pixel rect of `frame` (row-major index) in a sheet image. Explicit
25
+ * `cells` win when present (out-of-range indices clamp); otherwise the grid:
26
+ * with no params, the image divided evenly into cols×rows.
27
+ */
28
+ export function sheetCell(imageWidth, imageHeight, cols, rows, frame, params = {}) {
29
+ if (params.cells?.length) {
30
+ const index = Math.min(Math.max(0, Math.floor(frame)), params.cells.length - 1);
31
+ return params.cells[index];
32
+ }
33
+ const c = Math.max(1, Math.floor(cols));
34
+ const r = Math.max(1, Math.floor(rows));
35
+ const ox = positive(params.gridOffsetX);
36
+ const oy = positive(params.gridOffsetY);
37
+ const sx = positive(params.spacingX);
38
+ const sy = positive(params.spacingY);
39
+ const width = positive(params.cellWidth) || Math.max(1, (imageWidth - ox - sx * (c - 1)) / c);
40
+ const height = positive(params.cellHeight) || Math.max(1, (imageHeight - oy - sy * (r - 1)) / r);
41
+ const col = frame % c;
42
+ const row = Math.floor(frame / c);
43
+ return { x: ox + col * (width + sx), y: oy + row * (height + sy), width, height };
44
+ }
@@ -0,0 +1,37 @@
1
+ import type { InputBindings } from './input.js';
2
+ import type { PrefabJson, SceneEntityJson, SceneJson, SceneRegistry } from './scene.js';
3
+ import type { ArchetypeBundle } from './state/hooks.js';
4
+ /** One entity template exposed in an archetype's editor palette. */
5
+ export interface EntityTemplate {
6
+ label: string;
7
+ icon: string;
8
+ category: PrefabJson['type'];
9
+ /** Builds the JSON for a new instance (no position; the editor sets it). */
10
+ make: () => SceneEntityJson;
11
+ }
12
+ /** One stock-art file shipped by an archetype package. */
13
+ export interface ArchetypeArt {
14
+ /** File name under the archetype's assets/ and a demo project's src/art/. */
15
+ file: string;
16
+ /** Registry URI resolved by the archetype at runtime. */
17
+ uri: string;
18
+ }
19
+ /** The conventional contract exported by every archetype package. */
20
+ export interface ArchetypeManifest {
21
+ id: string;
22
+ label: string;
23
+ scene: SceneJson;
24
+ blankScene: SceneJson;
25
+ registry: SceneRegistry;
26
+ palette: EntityTemplate[];
27
+ prefabs: Record<string, PrefabJson>;
28
+ art: ArchetypeArt[];
29
+ entityIcons: Readonly<Record<string, string>>;
30
+ bindings: Readonly<InputBindings>;
31
+ actionLabels: Readonly<Record<string, string>>;
32
+ bundle: ArchetypeBundle;
33
+ }
34
+ /** Browser manifest enriched with URLs produced by an asset-aware bundler. */
35
+ export interface BrowserArchetypeManifest extends ArchetypeManifest {
36
+ artUrls: Readonly<Record<string, string>>;
37
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The scene camera: a built-in, singular part of every scene — not a
3
+ * component you attach to an entity. The scene JSON carries its framing
4
+ * (position, zoom), an optional follow target and world limits; the Game
5
+ * drives the real THREE camera from it while simulating.
6
+ */
7
+ /** World-space rectangle the camera view may never leave. */
8
+ export interface CameraLimitsJson {
9
+ minX: number;
10
+ maxX: number;
11
+ minY: number;
12
+ maxY: number;
13
+ }
14
+ export interface SceneCameraJson {
15
+ /** Where the camera starts (and stays, without a follow target). */
16
+ position?: [number, number];
17
+ /** Visible world height in units — the camera's zoom. */
18
+ zoom?: number;
19
+ /** Entity name to follow; absent or empty = fixed camera. */
20
+ follow?: string;
21
+ deadzoneWidth?: number;
22
+ deadzoneHeight?: number;
23
+ lookahead?: number;
24
+ smoothing?: number;
25
+ limits?: CameraLimitsJson;
26
+ }
27
+ export interface ResolvedSceneCamera {
28
+ position: [number, number];
29
+ zoom: number;
30
+ follow: string;
31
+ deadzoneWidth: number;
32
+ deadzoneHeight: number;
33
+ lookahead: number;
34
+ smoothing: number;
35
+ limits: CameraLimitsJson | null;
36
+ }
37
+ export declare const CAMERA_DEFAULTS: {
38
+ readonly position: [number, number];
39
+ readonly zoom: 12;
40
+ readonly follow: '';
41
+ readonly deadzoneWidth: 2;
42
+ readonly deadzoneHeight: 2.5;
43
+ readonly lookahead: 1.5;
44
+ readonly smoothing: 6;
45
+ };
46
+ /** Fills a scene's camera block with the engine defaults. */
47
+ export declare function resolveSceneCamera(json?: SceneCameraJson): ResolvedSceneCamera;
48
+ export interface CameraStepInput {
49
+ /** Current camera center. */
50
+ x: number;
51
+ y: number;
52
+ /** Half the visible world extents (from zoom and aspect). */
53
+ halfW: number;
54
+ halfH: number;
55
+ /** Followed entity's position, if the target exists. */
56
+ target: {
57
+ x: number;
58
+ y: number;
59
+ } | null;
60
+ /** Followed entity's horizontal velocity, for lookahead. */
61
+ vx: number;
62
+ dt: number;
63
+ }
64
+ /**
65
+ * One simulation step of the camera: deadzone-follow with lookahead and
66
+ * exponential smoothing, then limits. Pure — returns the next center.
67
+ */
68
+ export declare function stepSceneCamera(cam: ResolvedSceneCamera, input: CameraStepInput): {
69
+ x: number;
70
+ y: number;
71
+ };
package/dist/camera.js ADDED
@@ -0,0 +1,59 @@
1
+ import * as THREE from 'three';
2
+ export const CAMERA_DEFAULTS = {
3
+ position: [0, 0],
4
+ zoom: 12,
5
+ follow: '',
6
+ deadzoneWidth: 2,
7
+ deadzoneHeight: 2.5,
8
+ lookahead: 1.5,
9
+ smoothing: 6,
10
+ };
11
+ /** Fills a scene's camera block with the engine defaults. */
12
+ export function resolveSceneCamera(json) {
13
+ return {
14
+ position: json?.position ?? CAMERA_DEFAULTS.position,
15
+ zoom: json?.zoom ?? CAMERA_DEFAULTS.zoom,
16
+ follow: json?.follow ?? CAMERA_DEFAULTS.follow,
17
+ deadzoneWidth: json?.deadzoneWidth ?? CAMERA_DEFAULTS.deadzoneWidth,
18
+ deadzoneHeight: json?.deadzoneHeight ?? CAMERA_DEFAULTS.deadzoneHeight,
19
+ lookahead: json?.lookahead ?? CAMERA_DEFAULTS.lookahead,
20
+ smoothing: json?.smoothing ?? CAMERA_DEFAULTS.smoothing,
21
+ limits: json?.limits ?? null,
22
+ };
23
+ }
24
+ /** Clamps a camera center so the view stays inside the limits on one axis. */
25
+ function clampAxis(center, halfView, min, max) {
26
+ // Limits narrower than the view: center the view on them.
27
+ if (max - min <= halfView * 2)
28
+ return (min + max) / 2;
29
+ return Math.min(Math.max(center, min + halfView), max - halfView);
30
+ }
31
+ /**
32
+ * One simulation step of the camera: deadzone-follow with lookahead and
33
+ * exponential smoothing, then limits. Pure — returns the next center.
34
+ */
35
+ export function stepSceneCamera(cam, input) {
36
+ let x = input.x;
37
+ let y = input.y;
38
+ if (input.target) {
39
+ let wantX = x;
40
+ let wantY = y;
41
+ const dx = input.target.x - x;
42
+ const dy = input.target.y - y;
43
+ const halfDzW = cam.deadzoneWidth / 2;
44
+ const halfDzH = cam.deadzoneHeight / 2;
45
+ if (Math.abs(dx) > halfDzW)
46
+ wantX = input.target.x - Math.sign(dx) * halfDzW;
47
+ if (Math.abs(dy) > halfDzH)
48
+ wantY = input.target.y - Math.sign(dy) * halfDzH;
49
+ if (Math.abs(input.vx) > 1)
50
+ wantX += Math.sign(input.vx) * cam.lookahead;
51
+ x = THREE.MathUtils.damp(x, wantX, cam.smoothing, input.dt);
52
+ y = THREE.MathUtils.damp(y, wantY, cam.smoothing, input.dt);
53
+ }
54
+ if (cam.limits) {
55
+ x = clampAxis(x, input.halfW, cam.limits.minX, cam.limits.maxX);
56
+ y = clampAxis(y, input.halfH, cam.limits.minY, cam.limits.maxY);
57
+ }
58
+ return { x, y };
59
+ }
@@ -0,0 +1,26 @@
1
+ export type CollisionShape = 'rectangle' | 'circle' | 'polygon';
2
+ export type CollisionPoint = [number, number];
3
+ export interface CollisionBody {
4
+ x: number;
5
+ y: number;
6
+ width: number;
7
+ height: number;
8
+ shape?: CollisionShape;
9
+ /** Polygon vertices normalized against width/height, centered on the entity. */
10
+ points?: unknown;
11
+ }
12
+ export interface CollisionBounds {
13
+ left: number;
14
+ right: number;
15
+ top: number;
16
+ bottom: number;
17
+ }
18
+ export declare const COLLISION_SHAPES: readonly CollisionShape[];
19
+ export declare const DEFAULT_COLLISION_POLYGON: ReadonlyArray<Readonly<CollisionPoint>>;
20
+ /** Valid serialized points, or a fresh default triangle. */
21
+ export declare function resolveCollisionPoints(value: unknown): CollisionPoint[];
22
+ /** World-space outline used by collision tests and editor guides. */
23
+ export declare function collisionVertices(body: CollisionBody): CollisionPoint[];
24
+ export declare function collisionBounds(body: CollisionBody): CollisionBounds;
25
+ /** Overlap between rectangle, ellipse/circle, and freeform simple polygons. */
26
+ export declare function collisionOverlap(a: CollisionBody, b: CollisionBody): boolean;
@@ -0,0 +1,136 @@
1
+ export const COLLISION_SHAPES = [
2
+ 'rectangle',
3
+ 'circle',
4
+ 'polygon',
5
+ ];
6
+ export const DEFAULT_COLLISION_POLYGON = [
7
+ [-0.5, -0.5],
8
+ [0.5, -0.5],
9
+ [0, 0.5],
10
+ ];
11
+ const CIRCLE_SEGMENTS = 32;
12
+ const EPSILON = 1e-9;
13
+ /** Valid serialized points, or a fresh default triangle. */
14
+ export function resolveCollisionPoints(value) {
15
+ if (Array.isArray(value) && value.length >= 3) {
16
+ const points = [];
17
+ for (const point of value) {
18
+ if (!Array.isArray(point) ||
19
+ point.length < 2 ||
20
+ typeof point[0] !== 'number' ||
21
+ typeof point[1] !== 'number' ||
22
+ !Number.isFinite(point[0]) ||
23
+ !Number.isFinite(point[1])) {
24
+ return DEFAULT_COLLISION_POLYGON.map(([x, y]) => [x, y]);
25
+ }
26
+ points.push([point[0], point[1]]);
27
+ }
28
+ return points;
29
+ }
30
+ return DEFAULT_COLLISION_POLYGON.map(([x, y]) => [x, y]);
31
+ }
32
+ /** World-space outline used by collision tests and editor guides. */
33
+ export function collisionVertices(body) {
34
+ const width = Math.abs(body.width);
35
+ const height = Math.abs(body.height);
36
+ const normalized = body.shape === 'circle'
37
+ ? Array.from({ length: CIRCLE_SEGMENTS }, (_, index) => {
38
+ const angle = (index / CIRCLE_SEGMENTS) * Math.PI * 2;
39
+ return [Math.cos(angle) * 0.5, Math.sin(angle) * 0.5];
40
+ })
41
+ : body.shape === 'polygon'
42
+ ? resolveCollisionPoints(body.points)
43
+ : [
44
+ [-0.5, -0.5],
45
+ [0.5, -0.5],
46
+ [0.5, 0.5],
47
+ [-0.5, 0.5],
48
+ ];
49
+ return normalized.map(([x, y]) => [body.x + x * width, body.y + y * height]);
50
+ }
51
+ export function collisionBounds(body) {
52
+ const vertices = collisionVertices(body);
53
+ let left = Infinity;
54
+ let right = -Infinity;
55
+ let top = -Infinity;
56
+ let bottom = Infinity;
57
+ for (const [x, y] of vertices) {
58
+ left = Math.min(left, x);
59
+ right = Math.max(right, x);
60
+ top = Math.max(top, y);
61
+ bottom = Math.min(bottom, y);
62
+ }
63
+ return { left, right, top, bottom };
64
+ }
65
+ /** Overlap between rectangle, ellipse/circle, and freeform simple polygons. */
66
+ export function collisionOverlap(a, b) {
67
+ const boundsA = collisionBounds(a);
68
+ const boundsB = collisionBounds(b);
69
+ if (boundsA.right <= boundsB.left ||
70
+ boundsA.left >= boundsB.right ||
71
+ boundsA.top <= boundsB.bottom ||
72
+ boundsA.bottom >= boundsB.top) {
73
+ return false;
74
+ }
75
+ const verticesA = collisionVertices(a);
76
+ const verticesB = collisionVertices(b);
77
+ for (let ai = 0; ai < verticesA.length; ai++) {
78
+ const a1 = verticesA[ai];
79
+ const a2 = verticesA[(ai + 1) % verticesA.length];
80
+ for (let bi = 0; bi < verticesB.length; bi++) {
81
+ const b1 = verticesB[bi];
82
+ const b2 = verticesB[(bi + 1) % verticesB.length];
83
+ if (segmentsCross(a1, a2, b1, b2))
84
+ return true;
85
+ }
86
+ }
87
+ return hasInteriorPoint(verticesA, verticesB) || hasInteriorPoint(verticesB, verticesA);
88
+ }
89
+ function hasInteriorPoint(points, polygon) {
90
+ for (let index = 0; index < points.length; index++) {
91
+ const point = points[index];
92
+ if (pointInPolygon(point, polygon))
93
+ return true;
94
+ const next = points[(index + 1) % points.length];
95
+ if (pointInPolygon([(point[0] + next[0]) / 2, (point[1] + next[1]) / 2], polygon)) {
96
+ return true;
97
+ }
98
+ }
99
+ const center = [
100
+ points.reduce((sum, [x]) => sum + x, 0) / points.length,
101
+ points.reduce((sum, [, y]) => sum + y, 0) / points.length,
102
+ ];
103
+ return pointInPolygon(center, polygon);
104
+ }
105
+ function segmentsCross(a1, a2, b1, b2) {
106
+ const ab1 = cross(a1, a2, b1);
107
+ const ab2 = cross(a1, a2, b2);
108
+ const ba1 = cross(b1, b2, a1);
109
+ const ba2 = cross(b1, b2, a2);
110
+ return ab1 * ab2 < -EPSILON && ba1 * ba2 < -EPSILON;
111
+ }
112
+ function cross(a, b, point) {
113
+ return (b[0] - a[0]) * (point[1] - a[1]) - (b[1] - a[1]) * (point[0] - a[0]);
114
+ }
115
+ function pointInPolygon(point, polygon) {
116
+ let inside = false;
117
+ for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index++) {
118
+ const a = polygon[index];
119
+ const b = polygon[previous];
120
+ if (pointOnSegment(point, a, b))
121
+ return false;
122
+ const crosses = (a[1] > point[1]) !== (b[1] > point[1]) &&
123
+ point[0] < ((b[0] - a[0]) * (point[1] - a[1])) / (b[1] - a[1]) + a[0];
124
+ if (crosses)
125
+ inside = !inside;
126
+ }
127
+ return inside;
128
+ }
129
+ function pointOnSegment(point, a, b) {
130
+ if (Math.abs(cross(a, b, point)) > EPSILON)
131
+ return false;
132
+ return (point[0] >= Math.min(a[0], b[0]) - EPSILON &&
133
+ point[0] <= Math.max(a[0], b[0]) + EPSILON &&
134
+ point[1] >= Math.min(a[1], b[1]) - EPSILON &&
135
+ point[1] <= Math.max(a[1], b[1]) + EPSILON);
136
+ }
@@ -0,0 +1,16 @@
1
+ import { type ComponentClass } from './component.js';
2
+ import type { SceneRegistry } from './scene.js';
3
+ /** The namespace returned by importing one project code module. */
4
+ export type ComponentModule = Readonly<Record<string, unknown>>;
5
+ /**
6
+ * Finds exported Component subclasses in project modules. Classes are keyed by
7
+ * their stable componentName, not by the export name, so minification and
8
+ * default exports do not change scene JSON.
9
+ */
10
+ export declare function collectModuleComponents(modules: Iterable<ComponentModule>, warn?: (message: string) => void): Record<string, ComponentClass>;
11
+ /**
12
+ * Adds project-owned component classes to a registry. Project code is the
13
+ * extension layer, so it deliberately wins a stable-name collision while
14
+ * making that shadowing visible to the host.
15
+ */
16
+ export declare function mergeRegistryComponents(registry: SceneRegistry, project: Readonly<Record<string, ComponentClass>>, warn?: (message: string) => void): SceneRegistry;
@@ -0,0 +1,44 @@
1
+ import { Component } from './component.js';
2
+ /**
3
+ * Finds exported Component subclasses in project modules. Classes are keyed by
4
+ * their stable componentName, not by the export name, so minification and
5
+ * default exports do not change scene JSON.
6
+ */
7
+ export function collectModuleComponents(modules, warn = console.warn) {
8
+ const components = {};
9
+ for (const module of modules) {
10
+ for (const value of Object.values(module)) {
11
+ if (typeof value !== 'function')
12
+ continue;
13
+ const Class = value;
14
+ if (!(Class.prototype instanceof Component))
15
+ continue;
16
+ // Without its own componentName a class inherits the base's, so nothing
17
+ // could reference it from scene JSON. Silently skipping it looks like
18
+ // the editor lost the file: say so instead.
19
+ if (typeof Class.componentName !== 'string' || Class.componentName === 'Component') {
20
+ warn(`[waica] component class "${Class.name || '(anonymous)'}" declares no ` +
21
+ `static componentName — scenes cannot reference it`);
22
+ continue;
23
+ }
24
+ components[Class.componentName] = Class;
25
+ }
26
+ }
27
+ return components;
28
+ }
29
+ /**
30
+ * Adds project-owned component classes to a registry. Project code is the
31
+ * extension layer, so it deliberately wins a stable-name collision while
32
+ * making that shadowing visible to the host.
33
+ */
34
+ export function mergeRegistryComponents(registry, project, warn = console.warn) {
35
+ for (const name of Object.keys(project)) {
36
+ if (registry.components[name]) {
37
+ warn(`[waica] project component "${name}" shadows registry component "${name}"`);
38
+ }
39
+ }
40
+ return {
41
+ ...registry,
42
+ components: { ...registry.components, ...project },
43
+ };
44
+ }