@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
@@ -0,0 +1,58 @@
1
+ import type { Entity } from './entity.js';
2
+ import type { Game } from './game.js';
3
+ import type { Solid } from './components/solid.js';
4
+ /** Metadata for a parameter editable from the inspector. */
5
+ export interface ParamSpec {
6
+ label?: string;
7
+ min?: number;
8
+ max?: number;
9
+ step?: number;
10
+ /** Allowed values for a string param; rendered as a dropdown. */
11
+ options?: string[];
12
+ }
13
+ export interface ComponentClass<T extends Component = Component> {
14
+ new (): T;
15
+ /**
16
+ * Stable component name (survives minification).
17
+ * Used for `waica.params.json` overrides and the inspector.
18
+ */
19
+ componentName: string;
20
+ /** Friendly name the inspector shows instead of componentName. */
21
+ displayName?: string;
22
+ /** Which properties the inspector exposes, with their ranges. */
23
+ params?: Record<string, ParamSpec>;
24
+ }
25
+ /** Cardinal unit normal pointing away from the contacted Solid surface. */
26
+ export interface ContactNormal {
27
+ readonly x: -1 | 0 | 1;
28
+ readonly y: -1 | 0 | 1;
29
+ }
30
+ /** A physical DynamicBody contact, deliberately separate from Hitbox triggers. */
31
+ export interface SolidContact {
32
+ /** Entity that owns the contacted Solid. */
33
+ readonly entity: Entity;
34
+ readonly solid: Solid;
35
+ readonly axis: 'x' | 'y';
36
+ readonly normal: ContactNormal;
37
+ }
38
+ /**
39
+ * A pluggable piece of an entity. User behaviors and engine ones are the
40
+ * same thing: Component subclasses with public props.
41
+ */
42
+ export declare abstract class Component {
43
+ static componentName: string;
44
+ static displayName?: string;
45
+ static params?: Record<string, ParamSpec>;
46
+ entity: Entity;
47
+ game: Game;
48
+ /** Runs once the component is mounted on its entity. */
49
+ onReady?(): void;
50
+ /** Runs once per frame. */
51
+ onUpdate?(dt: number): void;
52
+ /** Runs when this entity's Hitbox overlaps another one's. */
53
+ onCollide?(other: Entity): void;
54
+ /** Runs when this entity's DynamicBody physically contacts a Solid. */
55
+ onContact?(contact: SolidContact): void;
56
+ /** Runs when the entity is destroyed or the component removed. */
57
+ onDestroy?(): void;
58
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * A pluggable piece of an entity. User behaviors and engine ones are the
3
+ * same thing: Component subclasses with public props.
4
+ */
5
+ export class Component {
6
+ static componentName = 'Component';
7
+ static displayName;
8
+ static params;
9
+ entity;
10
+ game;
11
+ }
@@ -0,0 +1,80 @@
1
+ import { Component } from '../component.js';
2
+ import { type ClipDef } from '../animation/clip-player.js';
3
+ import { type SheetCell, type SheetDef } from '../animation/sheet.js';
4
+ /**
5
+ * Sprite animated from one or more spritesheets. The main sheet is the
6
+ * top-level texture/cols/rows (or explicit cells); extraSheets append after
7
+ * it, and clip frames index the sheets consecutively (sheet 0 owns 0..n0-1,
8
+ * sheet 1 the next n1, …). Each instance clones its textures to animate UVs
9
+ * independently. On sheets with explicit cells the frames vary in pixel size,
10
+ * so the quad rescales per frame, anchored bottom-center — width/height size
11
+ * the sheet's largest frame and smaller ones keep their feet planted.
12
+ */
13
+ export declare class AnimatedSprite extends Component {
14
+ static componentName: string;
15
+ static params: {
16
+ offsetX: {
17
+ label: string;
18
+ };
19
+ offsetY: {
20
+ label: string;
21
+ };
22
+ layer: {
23
+ label: string;
24
+ min: number;
25
+ max: number;
26
+ step: number;
27
+ };
28
+ };
29
+ /** Spritesheet URL. */
30
+ texture: string;
31
+ /** Sheet grid dimensions. */
32
+ cols: number;
33
+ rows: number;
34
+ gridOffsetX: number;
35
+ gridOffsetY: number;
36
+ spacingX: number;
37
+ spacingY: number;
38
+ cellWidth: number;
39
+ cellHeight: number;
40
+ /** Explicit frame rects for the main sheet (packed sheets); wins over the grid. */
41
+ cells: SheetCell[];
42
+ /** Additional sheets after the main one; clip frames continue across them. */
43
+ extraSheets: SheetDef[];
44
+ private _width;
45
+ private _height;
46
+ get width(): number;
47
+ set width(value: number);
48
+ get height(): number;
49
+ set height(value: number);
50
+ private _offsetX;
51
+ private _offsetY;
52
+ get offsetX(): number;
53
+ set offsetX(value: number);
54
+ get offsetY(): number;
55
+ set offsetY(value: number);
56
+ pixelArt: boolean;
57
+ private _layer;
58
+ get layer(): number;
59
+ set layer(value: number);
60
+ clips: Record<string, ClipDef>;
61
+ initialClip?: string;
62
+ /** Clip currently playing. */
63
+ current?: string;
64
+ private readonly player;
65
+ private sheets;
66
+ private texs;
67
+ private mesh?;
68
+ private frame;
69
+ private frameScaleX;
70
+ private frameScaleY;
71
+ onReady(): void;
72
+ /** Switches clip; repeating the same name doesn't restart the animation. */
73
+ play(name: string): void;
74
+ onUpdate(dt: number): void;
75
+ onDestroy(): void;
76
+ private showFrame;
77
+ /** Repositions/rescales the quad from size, offsets and the frame scale. */
78
+ private syncQuad;
79
+ private applyFrame;
80
+ }
@@ -0,0 +1,210 @@
1
+ import * as THREE from 'three';
2
+ import { Component } from '../component.js';
3
+ import { ClipPlayer } from '../animation/clip-player.js';
4
+ import { locateFrame, sheetCell } from '../animation/sheet.js';
5
+ const loader = new THREE.TextureLoader();
6
+ /**
7
+ * Sprite animated from one or more spritesheets. The main sheet is the
8
+ * top-level texture/cols/rows (or explicit cells); extraSheets append after
9
+ * it, and clip frames index the sheets consecutively (sheet 0 owns 0..n0-1,
10
+ * sheet 1 the next n1, …). Each instance clones its textures to animate UVs
11
+ * independently. On sheets with explicit cells the frames vary in pixel size,
12
+ * so the quad rescales per frame, anchored bottom-center — width/height size
13
+ * the sheet's largest frame and smaller ones keep their feet planted.
14
+ */
15
+ export class AnimatedSprite extends Component {
16
+ static componentName = 'AnimatedSprite';
17
+ static params = {
18
+ offsetX: { label: 'x offset' },
19
+ offsetY: { label: 'y offset' },
20
+ layer: { label: 'layer', min: -5, max: 5, step: 1 },
21
+ };
22
+ /** Spritesheet URL. */
23
+ texture = '';
24
+ /** Sheet grid dimensions. */
25
+ cols = 1;
26
+ rows = 1;
27
+ // Slicing params for sheets whose frames don't fill the image (margins,
28
+ // gaps, fixed cell size). In image pixels; 0 = default uniform grid.
29
+ gridOffsetX = 0;
30
+ gridOffsetY = 0;
31
+ spacingX = 0;
32
+ spacingY = 0;
33
+ cellWidth = 0;
34
+ cellHeight = 0;
35
+ /** Explicit frame rects for the main sheet (packed sheets); wins over the grid. */
36
+ cells = [];
37
+ /** Additional sheets after the main one; clip frames continue across them. */
38
+ extraSheets = [];
39
+ // Size in world units. Reactive like Sprite's: assigning rescales the unit
40
+ // quad, even outside the simulation loop (the editor drags them live).
41
+ _width = 1;
42
+ _height = 1;
43
+ get width() {
44
+ return this._width;
45
+ }
46
+ set width(value) {
47
+ this._width = value;
48
+ this.syncQuad();
49
+ }
50
+ get height() {
51
+ return this._height;
52
+ }
53
+ set height(value) {
54
+ this._height = value;
55
+ this.syncQuad();
56
+ }
57
+ _offsetX = 0;
58
+ _offsetY = 0;
59
+ get offsetX() {
60
+ return this._offsetX;
61
+ }
62
+ set offsetX(value) {
63
+ this._offsetX = value;
64
+ this.syncQuad();
65
+ }
66
+ get offsetY() {
67
+ return this._offsetY;
68
+ }
69
+ set offsetY(value) {
70
+ this._offsetY = value;
71
+ this.syncQuad();
72
+ }
73
+ pixelArt = true;
74
+ // Draw order among sprites: higher layers render in front. Same-layer
75
+ // sprites fall back to spawn order, so give overlap an explicit layer.
76
+ _layer = 0;
77
+ get layer() {
78
+ return this._layer;
79
+ }
80
+ set layer(value) {
81
+ this._layer = value;
82
+ if (this.mesh)
83
+ this.mesh.position.z = value * 0.01;
84
+ }
85
+ clips = {};
86
+ initialClip;
87
+ /** Clip currently playing. */
88
+ current;
89
+ player = new ClipPlayer();
90
+ sheets = [];
91
+ texs = [];
92
+ mesh;
93
+ frame = 0;
94
+ // Current frame's quad scale relative to the sheet's largest cell: always
95
+ // 1 on grid sheets, per-frame on cell sheets (frames vary in pixel size).
96
+ frameScaleX = 1;
97
+ frameScaleY = 1;
98
+ onReady() {
99
+ const main = {
100
+ texture: this.texture,
101
+ cols: this.cols,
102
+ rows: this.rows,
103
+ gridOffsetX: this.gridOffsetX,
104
+ gridOffsetY: this.gridOffsetY,
105
+ spacingX: this.spacingX,
106
+ spacingY: this.spacingY,
107
+ cellWidth: this.cellWidth,
108
+ cellHeight: this.cellHeight,
109
+ };
110
+ if (this.cells.length)
111
+ main.cells = this.cells;
112
+ this.sheets = [main, ...this.extraSheets];
113
+ this.texs = this.sheets.map((sheet) => {
114
+ // Non-uniform slicing needs the image's pixel size, so re-apply the
115
+ // current frame once each texture is in.
116
+ const tex = loader.load(sheet.texture, () => this.applyFrame());
117
+ tex.colorSpace = THREE.SRGBColorSpace;
118
+ if (this.pixelArt) {
119
+ tex.magFilter = THREE.NearestFilter;
120
+ tex.minFilter = THREE.NearestFilter;
121
+ }
122
+ tex.repeat.set(1 / Math.max(1, sheet.cols), 1 / Math.max(1, sheet.rows));
123
+ return tex;
124
+ });
125
+ const material = new THREE.MeshBasicMaterial({ map: this.texs[0], transparent: true });
126
+ this.mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material);
127
+ this.mesh.position.z = this.layer * 0.01;
128
+ this.syncQuad();
129
+ this.entity.node.add(this.mesh);
130
+ if (this.initialClip)
131
+ this.play(this.initialClip);
132
+ }
133
+ /** Switches clip; repeating the same name doesn't restart the animation. */
134
+ play(name) {
135
+ if (this.current === name)
136
+ return;
137
+ const clip = this.clips[name];
138
+ if (!clip)
139
+ return;
140
+ this.player.set(clip);
141
+ this.current = name;
142
+ }
143
+ onUpdate(dt) {
144
+ if (this.texs.length === 0 || !this.current)
145
+ return;
146
+ this.showFrame(this.player.advance(dt));
147
+ }
148
+ onDestroy() {
149
+ this.mesh?.removeFromParent();
150
+ this.mesh?.geometry.dispose();
151
+ this.mesh?.material.dispose();
152
+ for (const tex of this.texs)
153
+ tex.dispose();
154
+ }
155
+ showFrame(index) {
156
+ this.frame = index;
157
+ this.applyFrame();
158
+ }
159
+ /** Repositions/rescales the quad from size, offsets and the frame scale. */
160
+ syncQuad() {
161
+ if (!this.mesh)
162
+ return;
163
+ this.mesh.scale.set(this._width * this.frameScaleX, this._height * this.frameScaleY, 1);
164
+ // Bottom-center anchor: a shrunk frame keeps its feet on the quad's floor.
165
+ this.mesh.position.x = this._offsetX;
166
+ this.mesh.position.y = this._offsetY - (this._height * (1 - this.frameScaleY)) / 2;
167
+ }
168
+ applyFrame() {
169
+ const located = locateFrame(this.sheets, this.frame);
170
+ const sheet = this.sheets[located.sheet];
171
+ const tex = this.texs[located.sheet];
172
+ if (!sheet || !tex)
173
+ return;
174
+ if (this.mesh && this.mesh.material.map !== tex) {
175
+ this.mesh.material.map = tex;
176
+ this.mesh.material.needsUpdate = true;
177
+ }
178
+ const cells = sheet.cells;
179
+ if (cells?.length) {
180
+ const cell = sheetCell(0, 0, sheet.cols, sheet.rows, located.frame, sheet);
181
+ const maxW = Math.max(...cells.map((c) => c.width));
182
+ const maxH = Math.max(...cells.map((c) => c.height));
183
+ this.frameScaleX = maxW > 0 ? cell.width / maxW : 1;
184
+ this.frameScaleY = maxH > 0 ? cell.height / maxH : 1;
185
+ this.syncQuad();
186
+ const image = tex.image;
187
+ if (image?.width && image?.height) {
188
+ tex.repeat.set(cell.width / image.width, cell.height / image.height);
189
+ tex.offset.set(cell.x / image.width, 1 - (cell.y + cell.height) / image.height);
190
+ }
191
+ return;
192
+ }
193
+ if (this.frameScaleX !== 1 || this.frameScaleY !== 1) {
194
+ this.frameScaleX = 1;
195
+ this.frameScaleY = 1;
196
+ this.syncQuad();
197
+ }
198
+ const image = tex.image;
199
+ if (image?.width && image?.height) {
200
+ const cell = sheetCell(image.width, image.height, sheet.cols, sheet.rows, located.frame, sheet);
201
+ tex.repeat.set(cell.width / image.width, cell.height / image.height);
202
+ tex.offset.set(cell.x / image.width, 1 - (cell.y + cell.height) / image.height);
203
+ return;
204
+ }
205
+ // Image not loaded yet: uniform-grid math needs no pixel size.
206
+ const cols = Math.max(1, sheet.cols);
207
+ const rows = Math.max(1, sheet.rows);
208
+ tex.offset.set((located.frame % cols) / cols, 1 - (Math.floor(located.frame / cols) + 1) / rows);
209
+ }
210
+ }
@@ -0,0 +1,68 @@
1
+ import { type CollisionPoint, type CollisionShape } from '../collision-shape.js';
2
+ import { Component } from '../component.js';
3
+ /**
4
+ * Reusable linear body: integrates its public velocity and resolves only
5
+ * against static Solid geometry. Hitbox trigger overlaps remain independent.
6
+ */
7
+ export declare class DynamicBody extends Component {
8
+ static componentName: string;
9
+ static params: {
10
+ vx: {
11
+ label: string;
12
+ step: number;
13
+ };
14
+ vy: {
15
+ label: string;
16
+ step: number;
17
+ };
18
+ shape: {
19
+ label: string;
20
+ options: CollisionShape[];
21
+ };
22
+ width: {
23
+ label: string;
24
+ min: number;
25
+ step: number;
26
+ };
27
+ height: {
28
+ label: string;
29
+ min: number;
30
+ step: number;
31
+ };
32
+ offsetX: {
33
+ label: string;
34
+ step: number;
35
+ };
36
+ offsetY: {
37
+ label: string;
38
+ step: number;
39
+ };
40
+ points: {
41
+ label: string;
42
+ };
43
+ };
44
+ vx: number;
45
+ vy: number;
46
+ shape: CollisionShape;
47
+ width: number;
48
+ height: number;
49
+ offsetX: number;
50
+ offsetY: number;
51
+ /** Polygon vertices normalized against width/height. */
52
+ points: CollisionPoint[];
53
+ onUpdate(dt: number): void;
54
+ private moveAxis;
55
+ /**
56
+ * Finds the shortest cardinal translation that clears every Solid.
57
+ * Equal distances keep RECOVERY_DIRECTIONS order: -X, +X, -Y, +Y.
58
+ */
59
+ private recoverInitialOverlap;
60
+ private setShift;
61
+ private resetPosition;
62
+ private overlapsAny;
63
+ private deliver;
64
+ private contact;
65
+ private solids;
66
+ private collisionBody;
67
+ private solidBody;
68
+ }
@@ -0,0 +1,189 @@
1
+ import { COLLISION_SHAPES, collisionBounds, collisionOverlap, resolveCollisionPoints, } from '../collision-shape.js';
2
+ import { Component } from '../component.js';
3
+ import { resolveSolidAxis } from '../solid-axis.js';
4
+ import { Solid } from './solid.js';
5
+ /** Stable tie order for equal-distance initial-overlap recovery. */
6
+ const RECOVERY_DIRECTIONS = [
7
+ { axis: 'x', sign: -1, normal: { x: -1, y: 0 } },
8
+ { axis: 'x', sign: 1, normal: { x: 1, y: 0 } },
9
+ { axis: 'y', sign: -1, normal: { x: 0, y: -1 } },
10
+ { axis: 'y', sign: 1, normal: { x: 0, y: 1 } },
11
+ ];
12
+ const RECOVERY_ITERATIONS = 24;
13
+ /**
14
+ * Reusable linear body: integrates its public velocity and resolves only
15
+ * against static Solid geometry. Hitbox trigger overlaps remain independent.
16
+ */
17
+ export class DynamicBody extends Component {
18
+ static componentName = 'DynamicBody';
19
+ static params = {
20
+ vx: { label: 'x velocity', step: 0.1 },
21
+ vy: { label: 'y velocity', step: 0.1 },
22
+ shape: { label: 'shape', options: [...COLLISION_SHAPES] },
23
+ width: { label: 'width', min: 0, step: 0.1 },
24
+ height: { label: 'height', min: 0, step: 0.1 },
25
+ offsetX: { label: 'x offset', step: 0.1 },
26
+ offsetY: { label: 'y offset', step: 0.1 },
27
+ points: { label: 'polygon points' },
28
+ };
29
+ vx = 0;
30
+ vy = 0;
31
+ shape = 'rectangle';
32
+ width = 1;
33
+ height = 1;
34
+ offsetX = 0;
35
+ offsetY = 0;
36
+ /** Polygon vertices normalized against width/height. */
37
+ points = resolveCollisionPoints(undefined);
38
+ onUpdate(dt) {
39
+ const delivered = new Map();
40
+ const recovery = this.recoverInitialOverlap();
41
+ if (recovery && !this.deliver(recovery.contacts, delivered))
42
+ return;
43
+ if (!this.moveAxis('x', this.vx * dt, delivered))
44
+ return;
45
+ this.moveAxis('y', this.vy * dt, delivered);
46
+ }
47
+ moveAxis(axis, displacement, delivered) {
48
+ if (displacement === 0)
49
+ return this.entity.alive;
50
+ const previous = this.entity.position[axis];
51
+ this.entity.position[axis] += displacement;
52
+ const solids = [];
53
+ const blocked = resolveSolidAxis({
54
+ entity: this.entity,
55
+ axis,
56
+ previous,
57
+ body: () => this.collisionBody(),
58
+ onContact: (solid) => solids.push(solid),
59
+ });
60
+ if (!blocked)
61
+ return this.entity.alive;
62
+ if (axis === 'x')
63
+ this.vx = 0;
64
+ else
65
+ this.vy = 0;
66
+ const normal = axis === 'x'
67
+ ? { x: displacement > 0 ? -1 : 1, y: 0 }
68
+ : { x: 0, y: displacement > 0 ? -1 : 1 };
69
+ return this.deliver(solids.map((solid) => this.contact(solid, axis, normal)), delivered);
70
+ }
71
+ /**
72
+ * Finds the shortest cardinal translation that clears every Solid.
73
+ * Equal distances keep RECOVERY_DIRECTIONS order: -X, +X, -Y, +Y.
74
+ */
75
+ recoverInitialOverlap() {
76
+ const solids = this.solids();
77
+ const overlapping = solids.filter((solid) => collisionOverlap(this.collisionBody(), this.solidBody(solid)));
78
+ if (overlapping.length === 0)
79
+ return null;
80
+ const original = { x: this.entity.position.x, y: this.entity.position.y };
81
+ const bodyBounds = collisionBounds(this.collisionBody());
82
+ let best = null;
83
+ for (const direction of RECOVERY_DIRECTIONS) {
84
+ const shifts = solids
85
+ .map((solid) => {
86
+ const bounds = collisionBounds(this.solidBody(solid));
87
+ if (direction.axis === 'x') {
88
+ return direction.sign < 0
89
+ ? bounds.left - bodyBounds.right
90
+ : bounds.right - bodyBounds.left;
91
+ }
92
+ return direction.sign < 0
93
+ ? bounds.bottom - bodyBounds.top
94
+ : bounds.top - bodyBounds.bottom;
95
+ })
96
+ .filter((shift) => shift * direction.sign > 0)
97
+ .sort((a, b) => Math.abs(a) - Math.abs(b));
98
+ let clear = null;
99
+ for (const shift of shifts) {
100
+ this.setShift(original, direction.axis, shift);
101
+ if (!this.overlapsAny(solids)) {
102
+ clear = shift;
103
+ break;
104
+ }
105
+ }
106
+ this.resetPosition(original);
107
+ if (clear === null)
108
+ continue;
109
+ let blockedShift = 0;
110
+ let clearShift = clear;
111
+ for (let iteration = 0; iteration < RECOVERY_ITERATIONS; iteration++) {
112
+ const middle = (blockedShift + clearShift) / 2;
113
+ this.setShift(original, direction.axis, middle);
114
+ if (this.overlapsAny(solids))
115
+ blockedShift = middle;
116
+ else
117
+ clearShift = middle;
118
+ }
119
+ this.resetPosition(original);
120
+ if (best && Math.abs(clearShift) >= Math.abs(best.shift))
121
+ continue;
122
+ best = {
123
+ direction,
124
+ shift: clearShift,
125
+ contacts: overlapping.map((solid) => this.contact(solid, direction.axis, direction.normal)),
126
+ };
127
+ }
128
+ if (!best)
129
+ return null;
130
+ this.setShift(original, best.direction.axis, best.shift);
131
+ return best;
132
+ }
133
+ setShift(original, axis, shift) {
134
+ this.entity.position[axis] = original[axis] + shift;
135
+ }
136
+ resetPosition(original) {
137
+ this.entity.position.x = original.x;
138
+ this.entity.position.y = original.y;
139
+ }
140
+ overlapsAny(solids) {
141
+ const body = this.collisionBody();
142
+ return solids.some((solid) => collisionOverlap(body, this.solidBody(solid)));
143
+ }
144
+ deliver(contacts, delivered) {
145
+ for (const contact of contacts) {
146
+ const direction = `${contact.axis}:${contact.normal.x}:${contact.normal.y}`;
147
+ const directions = delivered.get(contact.solid) ?? new Set();
148
+ if (directions.has(direction))
149
+ continue;
150
+ directions.add(direction);
151
+ delivered.set(contact.solid, directions);
152
+ for (const component of [...this.entity.components]) {
153
+ if (!this.entity.alive)
154
+ return false;
155
+ component.onContact?.(contact);
156
+ }
157
+ }
158
+ return this.entity.alive;
159
+ }
160
+ contact(solid, axis, normal) {
161
+ return { entity: solid.entity, solid, axis, normal };
162
+ }
163
+ solids() {
164
+ return this.game.entities
165
+ .filter((entity) => entity !== this.entity)
166
+ .map((entity) => entity.get(Solid))
167
+ .filter((solid) => solid !== undefined);
168
+ }
169
+ collisionBody() {
170
+ return {
171
+ x: this.entity.position.x + this.offsetX,
172
+ y: this.entity.position.y + this.offsetY,
173
+ width: this.width,
174
+ height: this.height,
175
+ shape: this.shape,
176
+ points: this.points,
177
+ };
178
+ }
179
+ solidBody(solid) {
180
+ return {
181
+ x: solid.entity.position.x + solid.offsetX,
182
+ y: solid.entity.position.y + solid.offsetY,
183
+ width: solid.width,
184
+ height: solid.height,
185
+ shape: solid.shape,
186
+ points: solid.points,
187
+ };
188
+ }
189
+ }
@@ -0,0 +1,25 @@
1
+ import { Component } from '../component.js';
2
+ import { type CollisionPoint, type CollisionShape } from '../collision-shape.js';
3
+ /**
4
+ * Trigger collider: the Game detects overlaps between Hitboxes and calls
5
+ * onCollide(other) on both entities' components. For static physical
6
+ * collision see Solid.
7
+ */
8
+ export declare class Hitbox extends Component {
9
+ static componentName: string;
10
+ static params: {
11
+ offsetX: {
12
+ label: string;
13
+ };
14
+ offsetY: {
15
+ label: string;
16
+ };
17
+ };
18
+ shape: CollisionShape;
19
+ width: number;
20
+ height: number;
21
+ offsetX: number;
22
+ offsetY: number;
23
+ /** Polygon vertices normalized against width/height. */
24
+ points: CollisionPoint[];
25
+ }
@@ -0,0 +1,21 @@
1
+ import { Component } from '../component.js';
2
+ import { resolveCollisionPoints, } from '../collision-shape.js';
3
+ /**
4
+ * Trigger collider: the Game detects overlaps between Hitboxes and calls
5
+ * onCollide(other) on both entities' components. For static physical
6
+ * collision see Solid.
7
+ */
8
+ export class Hitbox extends Component {
9
+ static componentName = 'Hitbox';
10
+ static params = {
11
+ offsetX: { label: 'x offset' },
12
+ offsetY: { label: 'y offset' },
13
+ };
14
+ shape = 'rectangle';
15
+ width = 1;
16
+ height = 1;
17
+ offsetX = 0;
18
+ offsetY = 0;
19
+ /** Polygon vertices normalized against width/height. */
20
+ points = resolveCollisionPoints(undefined);
21
+ }