@zylem/game-lib 0.6.0 → 0.6.2

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/behaviors.js CHANGED
@@ -1,398 +1,1209 @@
1
- // src/lib/actions/behaviors/boundaries/boundary.ts
2
- var defaultBoundaryOptions = {
3
- boundaries: {
4
- top: 0,
5
- bottom: 0,
6
- left: 0,
7
- right: 0
8
- },
9
- stopMovement: true
10
- };
11
- function boundary2d(options = {}) {
1
+ // src/lib/behaviors/behavior-descriptor.ts
2
+ function defineBehavior(config) {
12
3
  return {
13
- type: "update",
14
- handler: (updateContext) => {
15
- _boundary2d(updateContext, options);
16
- }
4
+ key: /* @__PURE__ */ Symbol.for(`zylem:behavior:${config.name}`),
5
+ defaultOptions: config.defaultOptions,
6
+ systemFactory: config.systemFactory,
7
+ createHandle: config.createHandle
17
8
  };
18
9
  }
19
- function _boundary2d(updateContext, options) {
20
- const { me: entity } = updateContext;
21
- const { boundaries, onBoundary } = {
22
- ...defaultBoundaryOptions,
23
- ...options
24
- };
25
- const position = entity.getPosition();
26
- if (!position) return;
27
- let boundariesHit = { top: false, bottom: false, left: false, right: false };
28
- if (position.x <= boundaries.left) {
29
- boundariesHit.left = true;
30
- } else if (position.x >= boundaries.right) {
31
- boundariesHit.right = true;
32
- }
33
- if (position.y <= boundaries.bottom) {
34
- boundariesHit.bottom = true;
35
- } else if (position.y >= boundaries.top) {
36
- boundariesHit.top = true;
37
- }
38
- const stopMovement = options.stopMovement ?? true;
39
- if (stopMovement && boundariesHit) {
40
- const velocity = entity.getVelocity() ?? { x: 0, y: 0, z: 0 };
41
- let { x: newX, y: newY } = velocity;
42
- if (velocity?.y < 0 && boundariesHit.bottom) {
43
- newY = 0;
44
- } else if (velocity?.y > 0 && boundariesHit.top) {
45
- newY = 0;
46
- }
47
- if (velocity?.x < 0 && boundariesHit.left) {
48
- newX = 0;
49
- } else if (velocity?.x > 0 && boundariesHit.right) {
50
- newX = 0;
51
- }
52
- entity.moveXY(newX, newY);
53
- }
54
- if (onBoundary && boundariesHit) {
55
- onBoundary({
56
- me: entity,
57
- boundary: boundariesHit,
58
- position: { x: position.x, y: position.y, z: position.z },
59
- updateContext
60
- });
61
- }
10
+
11
+ // src/lib/behaviors/use-behavior.ts
12
+ function useBehavior(entity, descriptor, options) {
13
+ entity.use(descriptor, options);
14
+ return entity;
62
15
  }
63
16
 
64
- // src/lib/collision/collision-builder.ts
65
- import { ActiveCollisionTypes, ColliderDesc, RigidBodyDesc, RigidBodyType, Vector3 } from "@dimforge/rapier3d-compat";
66
- var typeToGroup = /* @__PURE__ */ new Map();
67
- var nextGroupId = 0;
68
- function getOrCreateCollisionGroupId(type) {
69
- let groupId = typeToGroup.get(type);
70
- if (groupId === void 0) {
71
- groupId = nextGroupId++ % 16;
72
- typeToGroup.set(type, groupId);
73
- }
74
- return groupId;
17
+ // src/lib/behaviors/components.ts
18
+ import { Vector3, Quaternion } from "three";
19
+ function createTransformComponent() {
20
+ return {
21
+ position: new Vector3(),
22
+ rotation: new Quaternion()
23
+ };
24
+ }
25
+ function createPhysicsBodyComponent(body) {
26
+ return { body };
75
27
  }
76
28
 
77
- // src/lib/collision/utils.ts
78
- function matchesCollisionSelector(other, selector) {
79
- if (!selector) return true;
80
- const otherName = other.name ?? "";
81
- if ("name" in selector) {
82
- const sel = selector.name;
83
- if (sel instanceof RegExp) {
84
- return sel.test(otherName);
85
- } else if (Array.isArray(sel)) {
86
- return sel.some((s) => s === otherName);
87
- } else {
88
- return otherName === sel;
29
+ // src/lib/behaviors/physics-step.behavior.ts
30
+ var PhysicsStepBehavior = class {
31
+ constructor(physicsWorld) {
32
+ this.physicsWorld = physicsWorld;
33
+ }
34
+ update(dt) {
35
+ this.physicsWorld.timestep = dt;
36
+ this.physicsWorld.step();
37
+ }
38
+ };
39
+
40
+ // src/lib/behaviors/physics-sync.behavior.ts
41
+ var PhysicsSyncBehavior = class {
42
+ constructor(world) {
43
+ this.world = world;
44
+ }
45
+ /**
46
+ * Query entities that have both physics body and transform components
47
+ */
48
+ queryEntities() {
49
+ const entities = [];
50
+ for (const [, entity] of this.world.collisionMap) {
51
+ const gameEntity = entity;
52
+ if (gameEntity.physics?.body && gameEntity.transform) {
53
+ entities.push({
54
+ physics: gameEntity.physics,
55
+ transform: gameEntity.transform
56
+ });
57
+ }
89
58
  }
90
- } else if ("mask" in selector) {
91
- const m = selector.mask;
92
- if (m instanceof RegExp) {
93
- const type = other.collisionType ?? "";
94
- return m.test(type);
95
- } else {
96
- const type = other.collisionType ?? "";
97
- const gid = getOrCreateCollisionGroupId(type);
98
- return (m & 1 << gid) !== 0;
59
+ return entities;
60
+ }
61
+ update(_dt) {
62
+ const entities = this.queryEntities();
63
+ for (const e of entities) {
64
+ const body = e.physics.body;
65
+ const transform = e.transform;
66
+ const p = body.translation();
67
+ transform.position.set(p.x, p.y, p.z);
68
+ const r = body.rotation();
69
+ transform.rotation.set(r.x, r.y, r.z, r.w);
99
70
  }
100
- } else if ("test" in selector) {
101
- return !!selector.test(other);
102
71
  }
103
- return true;
104
- }
72
+ };
105
73
 
106
- // src/lib/actions/behaviors/ricochet/ricochet-2d-collision.ts
107
- function ricochet2DCollision(options = {}, callback) {
74
+ // src/lib/behaviors/thruster/components.ts
75
+ function createThrusterMovementComponent(linearThrust, angularThrust, options) {
108
76
  return {
109
- type: "collision",
110
- handler: (collisionContext) => {
111
- _handleRicochet2DCollision(collisionContext, options, callback);
112
- }
77
+ linearThrust,
78
+ angularThrust,
79
+ linearDamping: options?.linearDamping,
80
+ angularDamping: options?.angularDamping
81
+ };
82
+ }
83
+ function createThrusterInputComponent() {
84
+ return {
85
+ thrust: 0,
86
+ rotate: 0
113
87
  };
114
88
  }
115
- function _handleRicochet2DCollision(collisionContext, options, callback) {
116
- const { entity, other } = collisionContext;
117
- const self = entity;
118
- if (other.collider?.isSensor()) return;
119
- const {
120
- minSpeed = 2,
121
- maxSpeed = 20,
122
- separation = 0,
123
- collisionWith = void 0
124
- } = {
125
- ...options
89
+ function createThrusterStateComponent() {
90
+ return {
91
+ enabled: true,
92
+ currentThrust: 0
126
93
  };
127
- const reflectionMode = options?.reflectionMode ?? "angled";
128
- const maxAngleDeg = options?.maxAngleDeg ?? 60;
129
- const speedUpFactor = options?.speedUpFactor ?? 1.05;
130
- const minOffsetForAngle = options?.minOffsetForAngle ?? 0.15;
131
- const centerRetentionFactor = options?.centerRetentionFactor ?? 0.5;
132
- if (!matchesCollisionSelector(other, collisionWith)) return;
133
- const selfPos = self.getPosition();
134
- const otherPos = other.body?.translation();
135
- const vel = self.getVelocity();
136
- if (!selfPos || !otherPos || !vel) return;
137
- let newVelX = vel.x;
138
- let newVelY = vel.y;
139
- let newX = selfPos.x;
140
- let newY = selfPos.y;
141
- const dx = selfPos.x - otherPos.x;
142
- const dy = selfPos.y - otherPos.y;
143
- let extentX = null;
144
- let extentY = null;
145
- const colliderShape = other.collider?.shape;
146
- if (colliderShape) {
147
- if (colliderShape.halfExtents) {
148
- extentX = Math.abs(colliderShape.halfExtents.x ?? colliderShape.halfExtents[0] ?? null);
149
- extentY = Math.abs(colliderShape.halfExtents.y ?? colliderShape.halfExtents[1] ?? null);
150
- }
151
- if ((extentX == null || extentY == null) && typeof colliderShape.radius === "number") {
152
- extentX = extentX ?? Math.abs(colliderShape.radius);
153
- extentY = extentY ?? Math.abs(colliderShape.radius);
154
- }
155
- }
156
- if ((extentX == null || extentY == null) && typeof other.collider?.halfExtents === "function") {
157
- const he = other.collider.halfExtents();
158
- if (he) {
159
- extentX = extentX ?? Math.abs(he.x);
160
- extentY = extentY ?? Math.abs(he.y);
161
- }
162
- }
163
- if ((extentX == null || extentY == null) && typeof other.collider?.radius === "function") {
164
- const r = other.collider.radius();
165
- if (typeof r === "number") {
166
- extentX = extentX ?? Math.abs(r);
167
- extentY = extentY ?? Math.abs(r);
168
- }
169
- }
170
- let relX = 0;
171
- let relY = 0;
172
- if (extentX && extentY) {
173
- relX = clamp(dx / extentX, -1, 1);
174
- relY = clamp(dy / extentY, -1, 1);
175
- } else {
176
- relX = Math.sign(dx);
177
- relY = Math.sign(dy);
178
- }
179
- let bounceVertical = Math.abs(dy) >= Math.abs(dx);
180
- let selfExtentX = null;
181
- let selfExtentY = null;
182
- const selfShape = self.collider?.shape;
183
- if (selfShape) {
184
- if (selfShape.halfExtents) {
185
- selfExtentX = Math.abs(selfShape.halfExtents.x ?? selfShape.halfExtents[0] ?? null);
186
- selfExtentY = Math.abs(selfShape.halfExtents.y ?? selfShape.halfExtents[1] ?? null);
187
- }
188
- if ((selfExtentX == null || selfExtentY == null) && typeof selfShape.radius === "number") {
189
- selfExtentX = selfExtentX ?? Math.abs(selfShape.radius);
190
- selfExtentY = selfExtentY ?? Math.abs(selfShape.radius);
191
- }
192
- }
193
- if ((selfExtentX == null || selfExtentY == null) && typeof self.collider?.halfExtents === "function") {
194
- const heS = self.collider.halfExtents();
195
- if (heS) {
196
- selfExtentX = selfExtentX ?? Math.abs(heS.x);
197
- selfExtentY = selfExtentY ?? Math.abs(heS.y);
198
- }
199
- }
200
- if ((selfExtentX == null || selfExtentY == null) && typeof self.collider?.radius === "function") {
201
- const rS = self.collider.radius();
202
- if (typeof rS === "number") {
203
- selfExtentX = selfExtentX ?? Math.abs(rS);
204
- selfExtentY = selfExtentY ?? Math.abs(rS);
205
- }
206
- }
207
- if (extentX != null && extentY != null && selfExtentX != null && selfExtentY != null) {
208
- const penX = selfExtentX + extentX - Math.abs(dx);
209
- const penY = selfExtentY + extentY - Math.abs(dy);
210
- if (!Number.isNaN(penX) && !Number.isNaN(penY)) {
211
- bounceVertical = penY <= penX;
212
- }
213
- }
214
- let usedAngleDeflection = false;
215
- if (bounceVertical) {
216
- const resolvedY = (extentY ?? 0) + (selfExtentY ?? 0) + separation;
217
- newY = otherPos.y + (dy > 0 ? resolvedY : -resolvedY);
218
- newX = selfPos.x;
219
- const isHorizontalPaddle = extentX != null && extentY != null && extentX > extentY;
220
- if (isHorizontalPaddle && reflectionMode === "angled") {
221
- const maxAngleRad = maxAngleDeg * Math.PI / 180;
222
- const deadzone = Math.max(0, Math.min(1, minOffsetForAngle));
223
- const clampedOffsetX = clamp(relX, -1, 1);
224
- const absOff = Math.abs(clampedOffsetX);
225
- const baseSpeed = Math.sqrt(vel.x * vel.x + vel.y * vel.y);
226
- const speed = clamp(baseSpeed * speedUpFactor, minSpeed, maxSpeed);
227
- if (absOff > deadzone) {
228
- const t = (absOff - deadzone) / (1 - deadzone);
229
- const angle = Math.sign(clampedOffsetX) * (t * maxAngleRad);
230
- const cosA = Math.cos(angle);
231
- const sinA = Math.sin(angle);
232
- const vy = Math.abs(speed * cosA);
233
- const vx = speed * sinA;
234
- newVelY = dy > 0 ? vy : -vy;
235
- newVelX = vx;
236
- } else {
237
- const vx = vel.x * centerRetentionFactor;
238
- const vyMagSquared = Math.max(0, speed * speed - vx * vx);
239
- const vy = Math.sqrt(vyMagSquared);
240
- newVelY = dy > 0 ? vy : -vy;
241
- newVelX = vx;
94
+ }
95
+
96
+ // src/lib/behaviors/thruster/thruster-fsm.ts
97
+ import { StateMachine, t } from "typescript-fsm";
98
+ var ThrusterState = /* @__PURE__ */ ((ThrusterState2) => {
99
+ ThrusterState2["Idle"] = "idle";
100
+ ThrusterState2["Active"] = "active";
101
+ ThrusterState2["Boosting"] = "boosting";
102
+ ThrusterState2["Disabled"] = "disabled";
103
+ ThrusterState2["Docked"] = "docked";
104
+ return ThrusterState2;
105
+ })(ThrusterState || {});
106
+ var ThrusterEvent = /* @__PURE__ */ ((ThrusterEvent2) => {
107
+ ThrusterEvent2["Activate"] = "activate";
108
+ ThrusterEvent2["Deactivate"] = "deactivate";
109
+ ThrusterEvent2["Boost"] = "boost";
110
+ ThrusterEvent2["EndBoost"] = "endBoost";
111
+ ThrusterEvent2["Disable"] = "disable";
112
+ ThrusterEvent2["Enable"] = "enable";
113
+ ThrusterEvent2["Dock"] = "dock";
114
+ ThrusterEvent2["Undock"] = "undock";
115
+ return ThrusterEvent2;
116
+ })(ThrusterEvent || {});
117
+ var ThrusterFSM = class {
118
+ constructor(ctx) {
119
+ this.ctx = ctx;
120
+ this.machine = new StateMachine(
121
+ "idle" /* Idle */,
122
+ [
123
+ // Core transitions
124
+ t("idle" /* Idle */, "activate" /* Activate */, "active" /* Active */),
125
+ t("active" /* Active */, "deactivate" /* Deactivate */, "idle" /* Idle */),
126
+ t("active" /* Active */, "boost" /* Boost */, "boosting" /* Boosting */),
127
+ t("active" /* Active */, "disable" /* Disable */, "disabled" /* Disabled */),
128
+ t("active" /* Active */, "dock" /* Dock */, "docked" /* Docked */),
129
+ t("boosting" /* Boosting */, "endBoost" /* EndBoost */, "active" /* Active */),
130
+ t("boosting" /* Boosting */, "disable" /* Disable */, "disabled" /* Disabled */),
131
+ t("disabled" /* Disabled */, "enable" /* Enable */, "idle" /* Idle */),
132
+ t("docked" /* Docked */, "undock" /* Undock */, "idle" /* Idle */),
133
+ // Self-transitions (no-ops for redundant events)
134
+ t("idle" /* Idle */, "deactivate" /* Deactivate */, "idle" /* Idle */),
135
+ t("active" /* Active */, "activate" /* Activate */, "active" /* Active */)
136
+ ]
137
+ );
138
+ }
139
+ machine;
140
+ /**
141
+ * Get current state
142
+ */
143
+ getState() {
144
+ return this.machine.getState();
145
+ }
146
+ /**
147
+ * Dispatch an event to transition state
148
+ */
149
+ dispatch(event) {
150
+ if (this.machine.can(event)) {
151
+ this.machine.dispatch(event);
152
+ }
153
+ }
154
+ /**
155
+ * Update FSM state based on player input.
156
+ * Auto-transitions between Idle/Active to report current state.
157
+ * Does NOT modify input - just observes and reports.
158
+ */
159
+ update(playerInput) {
160
+ const state = this.machine.getState();
161
+ const hasInput = Math.abs(playerInput.thrust) > 0.01 || Math.abs(playerInput.rotate) > 0.01;
162
+ if (hasInput && state === "idle" /* Idle */) {
163
+ this.dispatch("activate" /* Activate */);
164
+ } else if (!hasInput && state === "active" /* Active */) {
165
+ this.dispatch("deactivate" /* Deactivate */);
166
+ }
167
+ }
168
+ };
169
+
170
+ // src/lib/behaviors/thruster/thruster-movement.behavior.ts
171
+ var ThrusterMovementBehavior = class {
172
+ constructor(world) {
173
+ this.world = world;
174
+ }
175
+ /**
176
+ * Query function - returns entities with required thruster components
177
+ */
178
+ queryEntities() {
179
+ const entities = [];
180
+ for (const [, entity] of this.world.collisionMap) {
181
+ const gameEntity = entity;
182
+ if (gameEntity.physics?.body && gameEntity.thruster && gameEntity.$thruster) {
183
+ entities.push({
184
+ physics: gameEntity.physics,
185
+ thruster: gameEntity.thruster,
186
+ $thruster: gameEntity.$thruster
187
+ });
242
188
  }
243
- usedAngleDeflection = true;
244
- } else {
245
- newVelY = dy > 0 ? Math.abs(vel.y) : -Math.abs(vel.y);
246
- if (reflectionMode === "simple") usedAngleDeflection = true;
247
189
  }
248
- } else {
249
- const resolvedX = (extentX ?? 0) + (selfExtentX ?? 0) + separation;
250
- newX = otherPos.x + (dx > 0 ? resolvedX : -resolvedX);
251
- newY = selfPos.y;
252
- if (reflectionMode === "angled") {
253
- const maxAngleRad = maxAngleDeg * Math.PI / 180;
254
- const deadzone = Math.max(0, Math.min(1, minOffsetForAngle));
255
- const clampedOffsetY = clamp(relY, -1, 1);
256
- const absOff = Math.abs(clampedOffsetY);
257
- const baseSpeed = Math.sqrt(vel.x * vel.x + vel.y * vel.y);
258
- const speed = clamp(baseSpeed * speedUpFactor, minSpeed, maxSpeed);
259
- if (absOff > deadzone) {
260
- const t = (absOff - deadzone) / (1 - deadzone);
261
- const angle = Math.sign(clampedOffsetY) * (t * maxAngleRad);
262
- const cosA = Math.cos(angle);
263
- const sinA = Math.sin(angle);
264
- const vx = Math.abs(speed * cosA);
265
- const vy = speed * sinA;
266
- newVelX = dx > 0 ? vx : -vx;
267
- newVelY = vy;
190
+ return entities;
191
+ }
192
+ update(_dt) {
193
+ const entities = this.queryEntities();
194
+ for (const e of entities) {
195
+ const body = e.physics.body;
196
+ const thruster = e.thruster;
197
+ const input = e.$thruster;
198
+ const q = body.rotation();
199
+ const rotationZ = Math.atan2(2 * (q.w * q.z + q.x * q.y), 1 - 2 * (q.y * q.y + q.z * q.z));
200
+ if (input.thrust !== 0) {
201
+ const currentVel = body.linvel();
202
+ if (input.thrust > 0) {
203
+ const forwardX = Math.sin(-rotationZ);
204
+ const forwardY = Math.cos(-rotationZ);
205
+ const thrustAmount = thruster.linearThrust * input.thrust * 0.1;
206
+ body.setLinvel({
207
+ x: currentVel.x + forwardX * thrustAmount,
208
+ y: currentVel.y + forwardY * thrustAmount,
209
+ z: currentVel.z
210
+ }, true);
211
+ } else {
212
+ const brakeAmount = 0.9;
213
+ body.setLinvel({
214
+ x: currentVel.x * brakeAmount,
215
+ y: currentVel.y * brakeAmount,
216
+ z: currentVel.z
217
+ }, true);
218
+ }
219
+ }
220
+ if (input.rotate !== 0) {
221
+ body.setAngvel({ x: 0, y: 0, z: -thruster.angularThrust * input.rotate }, true);
268
222
  } else {
269
- const vy = vel.y * centerRetentionFactor;
270
- const vxMagSquared = Math.max(0, speed * speed - vy * vy);
271
- const vx = Math.sqrt(vxMagSquared);
272
- newVelX = dx > 0 ? vx : -vx;
273
- newVelY = vy;
223
+ const angVel = body.angvel();
224
+ body.setAngvel({ x: angVel.x, y: angVel.y, z: 0 }, true);
274
225
  }
275
- usedAngleDeflection = true;
276
- } else {
277
- newVelX = dx > 0 ? Math.abs(vel.x) : -Math.abs(vel.x);
278
- newVelY = vel.y;
279
- usedAngleDeflection = true;
280
226
  }
281
227
  }
282
- if (!usedAngleDeflection) {
283
- const additionBaseX = Math.abs(newVelX);
284
- const additionBaseY = Math.abs(newVelY);
285
- const addX = Math.sign(relX) * Math.abs(relX) * additionBaseX;
286
- const addY = Math.sign(relY) * Math.abs(relY) * additionBaseY;
287
- newVelX += addX;
288
- newVelY += addY;
228
+ };
229
+
230
+ // src/lib/behaviors/thruster/thruster.descriptor.ts
231
+ var defaultOptions = {
232
+ linearThrust: 10,
233
+ angularThrust: 5
234
+ };
235
+ var ThrusterBehaviorSystem = class {
236
+ constructor(world) {
237
+ this.world = world;
238
+ this.movementBehavior = new ThrusterMovementBehavior(world);
289
239
  }
290
- const currentSpeed = Math.sqrt(newVelX * newVelX + newVelY * newVelY);
291
- if (currentSpeed > 0) {
292
- const targetSpeed = clamp(currentSpeed, minSpeed, maxSpeed);
293
- if (targetSpeed !== currentSpeed) {
294
- const scale = targetSpeed / currentSpeed;
295
- newVelX *= scale;
296
- newVelY *= scale;
297
- }
298
- }
299
- if (newX !== selfPos.x || newY !== selfPos.y) {
300
- self.setPosition(newX, newY, selfPos.z);
301
- self.moveXY(newVelX, newVelY);
302
- if (callback) {
303
- const velocityAfter = self.getVelocity();
304
- if (velocityAfter) {
305
- callback({
306
- position: { x: newX, y: newY, z: selfPos.z },
307
- ...collisionContext
240
+ movementBehavior;
241
+ update(ecs, delta) {
242
+ if (!this.world?.collisionMap) return;
243
+ for (const [, entity] of this.world.collisionMap) {
244
+ const gameEntity = entity;
245
+ if (typeof gameEntity.getBehaviorRefs !== "function") continue;
246
+ const refs = gameEntity.getBehaviorRefs();
247
+ const thrusterRef = refs.find(
248
+ (r) => r.descriptor.key === /* @__PURE__ */ Symbol.for("zylem:behavior:thruster")
249
+ );
250
+ if (!thrusterRef || !gameEntity.body) continue;
251
+ const options = thrusterRef.options;
252
+ if (!gameEntity.thruster) {
253
+ gameEntity.thruster = {
254
+ linearThrust: options.linearThrust,
255
+ angularThrust: options.angularThrust
256
+ };
257
+ }
258
+ if (!gameEntity.$thruster) {
259
+ gameEntity.$thruster = {
260
+ thrust: 0,
261
+ rotate: 0
262
+ };
263
+ }
264
+ if (!gameEntity.physics) {
265
+ gameEntity.physics = { body: gameEntity.body };
266
+ }
267
+ if (!thrusterRef.fsm && gameEntity.$thruster) {
268
+ thrusterRef.fsm = new ThrusterFSM({ input: gameEntity.$thruster });
269
+ }
270
+ if (thrusterRef.fsm && gameEntity.$thruster) {
271
+ thrusterRef.fsm.update({
272
+ thrust: gameEntity.$thruster.thrust,
273
+ rotate: gameEntity.$thruster.rotate
308
274
  });
309
275
  }
310
276
  }
277
+ this.movementBehavior.update(delta);
278
+ }
279
+ destroy(_ecs) {
280
+ }
281
+ };
282
+ var ThrusterBehavior = defineBehavior({
283
+ name: "thruster",
284
+ defaultOptions,
285
+ systemFactory: (ctx) => new ThrusterBehaviorSystem(ctx.world)
286
+ });
287
+
288
+ // src/lib/behaviors/screen-wrap/screen-wrap-fsm.ts
289
+ import { StateMachine as StateMachine2, t as t2 } from "typescript-fsm";
290
+ var ScreenWrapState = /* @__PURE__ */ ((ScreenWrapState2) => {
291
+ ScreenWrapState2["Center"] = "center";
292
+ ScreenWrapState2["NearEdgeLeft"] = "near-edge-left";
293
+ ScreenWrapState2["NearEdgeRight"] = "near-edge-right";
294
+ ScreenWrapState2["NearEdgeTop"] = "near-edge-top";
295
+ ScreenWrapState2["NearEdgeBottom"] = "near-edge-bottom";
296
+ ScreenWrapState2["Wrapped"] = "wrapped";
297
+ return ScreenWrapState2;
298
+ })(ScreenWrapState || {});
299
+ var ScreenWrapEvent = /* @__PURE__ */ ((ScreenWrapEvent2) => {
300
+ ScreenWrapEvent2["EnterCenter"] = "enter-center";
301
+ ScreenWrapEvent2["ApproachLeft"] = "approach-left";
302
+ ScreenWrapEvent2["ApproachRight"] = "approach-right";
303
+ ScreenWrapEvent2["ApproachTop"] = "approach-top";
304
+ ScreenWrapEvent2["ApproachBottom"] = "approach-bottom";
305
+ ScreenWrapEvent2["Wrap"] = "wrap";
306
+ return ScreenWrapEvent2;
307
+ })(ScreenWrapEvent || {});
308
+ var ScreenWrapFSM = class {
309
+ machine;
310
+ constructor() {
311
+ this.machine = new StateMachine2(
312
+ "center" /* Center */,
313
+ [
314
+ // From Center
315
+ t2("center" /* Center */, "approach-left" /* ApproachLeft */, "near-edge-left" /* NearEdgeLeft */),
316
+ t2("center" /* Center */, "approach-right" /* ApproachRight */, "near-edge-right" /* NearEdgeRight */),
317
+ t2("center" /* Center */, "approach-top" /* ApproachTop */, "near-edge-top" /* NearEdgeTop */),
318
+ t2("center" /* Center */, "approach-bottom" /* ApproachBottom */, "near-edge-bottom" /* NearEdgeBottom */),
319
+ // From NearEdge to Wrapped
320
+ t2("near-edge-left" /* NearEdgeLeft */, "wrap" /* Wrap */, "wrapped" /* Wrapped */),
321
+ t2("near-edge-right" /* NearEdgeRight */, "wrap" /* Wrap */, "wrapped" /* Wrapped */),
322
+ t2("near-edge-top" /* NearEdgeTop */, "wrap" /* Wrap */, "wrapped" /* Wrapped */),
323
+ t2("near-edge-bottom" /* NearEdgeBottom */, "wrap" /* Wrap */, "wrapped" /* Wrapped */),
324
+ // From NearEdge back to Center
325
+ t2("near-edge-left" /* NearEdgeLeft */, "enter-center" /* EnterCenter */, "center" /* Center */),
326
+ t2("near-edge-right" /* NearEdgeRight */, "enter-center" /* EnterCenter */, "center" /* Center */),
327
+ t2("near-edge-top" /* NearEdgeTop */, "enter-center" /* EnterCenter */, "center" /* Center */),
328
+ t2("near-edge-bottom" /* NearEdgeBottom */, "enter-center" /* EnterCenter */, "center" /* Center */),
329
+ // From Wrapped back to Center
330
+ t2("wrapped" /* Wrapped */, "enter-center" /* EnterCenter */, "center" /* Center */),
331
+ // From Wrapped to NearEdge (landed near opposite edge)
332
+ t2("wrapped" /* Wrapped */, "approach-left" /* ApproachLeft */, "near-edge-left" /* NearEdgeLeft */),
333
+ t2("wrapped" /* Wrapped */, "approach-right" /* ApproachRight */, "near-edge-right" /* NearEdgeRight */),
334
+ t2("wrapped" /* Wrapped */, "approach-top" /* ApproachTop */, "near-edge-top" /* NearEdgeTop */),
335
+ t2("wrapped" /* Wrapped */, "approach-bottom" /* ApproachBottom */, "near-edge-bottom" /* NearEdgeBottom */),
336
+ // Self-transitions (no-ops for redundant events)
337
+ t2("center" /* Center */, "enter-center" /* EnterCenter */, "center" /* Center */),
338
+ t2("near-edge-left" /* NearEdgeLeft */, "approach-left" /* ApproachLeft */, "near-edge-left" /* NearEdgeLeft */),
339
+ t2("near-edge-right" /* NearEdgeRight */, "approach-right" /* ApproachRight */, "near-edge-right" /* NearEdgeRight */),
340
+ t2("near-edge-top" /* NearEdgeTop */, "approach-top" /* ApproachTop */, "near-edge-top" /* NearEdgeTop */),
341
+ t2("near-edge-bottom" /* NearEdgeBottom */, "approach-bottom" /* ApproachBottom */, "near-edge-bottom" /* NearEdgeBottom */)
342
+ ]
343
+ );
344
+ }
345
+ getState() {
346
+ return this.machine.getState();
347
+ }
348
+ dispatch(event) {
349
+ if (this.machine.can(event)) {
350
+ this.machine.dispatch(event);
351
+ }
352
+ }
353
+ /**
354
+ * Update FSM based on entity position relative to bounds
355
+ */
356
+ update(position, bounds, wrapped) {
357
+ const { x, y } = position;
358
+ const { minX, maxX, minY, maxY, edgeThreshold } = bounds;
359
+ if (wrapped) {
360
+ this.dispatch("wrap" /* Wrap */);
361
+ return;
362
+ }
363
+ const nearLeft = x < minX + edgeThreshold;
364
+ const nearRight = x > maxX - edgeThreshold;
365
+ const nearBottom = y < minY + edgeThreshold;
366
+ const nearTop = y > maxY - edgeThreshold;
367
+ if (nearLeft) {
368
+ this.dispatch("approach-left" /* ApproachLeft */);
369
+ } else if (nearRight) {
370
+ this.dispatch("approach-right" /* ApproachRight */);
371
+ } else if (nearTop) {
372
+ this.dispatch("approach-top" /* ApproachTop */);
373
+ } else if (nearBottom) {
374
+ this.dispatch("approach-bottom" /* ApproachBottom */);
375
+ } else {
376
+ this.dispatch("enter-center" /* EnterCenter */);
377
+ }
378
+ }
379
+ };
380
+
381
+ // src/lib/behaviors/screen-wrap/screen-wrap.descriptor.ts
382
+ var defaultOptions2 = {
383
+ width: 20,
384
+ height: 15,
385
+ centerX: 0,
386
+ centerY: 0,
387
+ edgeThreshold: 2
388
+ };
389
+ var ScreenWrapSystem = class {
390
+ constructor(world) {
391
+ this.world = world;
392
+ }
393
+ update(ecs, delta) {
394
+ if (!this.world?.collisionMap) return;
395
+ for (const [, entity] of this.world.collisionMap) {
396
+ const gameEntity = entity;
397
+ if (typeof gameEntity.getBehaviorRefs !== "function") continue;
398
+ const refs = gameEntity.getBehaviorRefs();
399
+ const wrapRef = refs.find(
400
+ (r) => r.descriptor.key === /* @__PURE__ */ Symbol.for("zylem:behavior:screen-wrap")
401
+ );
402
+ if (!wrapRef || !gameEntity.body) continue;
403
+ const options = wrapRef.options;
404
+ if (!wrapRef.fsm) {
405
+ wrapRef.fsm = new ScreenWrapFSM();
406
+ }
407
+ const wrapped = this.wrapEntity(gameEntity, options);
408
+ const pos = gameEntity.body.translation();
409
+ const { width, height, centerX, centerY, edgeThreshold } = options;
410
+ const halfWidth = width / 2;
411
+ const halfHeight = height / 2;
412
+ wrapRef.fsm.update(
413
+ { x: pos.x, y: pos.y },
414
+ {
415
+ minX: centerX - halfWidth,
416
+ maxX: centerX + halfWidth,
417
+ minY: centerY - halfHeight,
418
+ maxY: centerY + halfHeight,
419
+ edgeThreshold
420
+ },
421
+ wrapped
422
+ );
423
+ }
424
+ }
425
+ wrapEntity(entity, options) {
426
+ const body = entity.body;
427
+ if (!body) return false;
428
+ const { width, height, centerX, centerY } = options;
429
+ const halfWidth = width / 2;
430
+ const halfHeight = height / 2;
431
+ const minX = centerX - halfWidth;
432
+ const maxX = centerX + halfWidth;
433
+ const minY = centerY - halfHeight;
434
+ const maxY = centerY + halfHeight;
435
+ const pos = body.translation();
436
+ let newX = pos.x;
437
+ let newY = pos.y;
438
+ let wrapped = false;
439
+ if (pos.x < minX) {
440
+ newX = maxX - (minX - pos.x);
441
+ wrapped = true;
442
+ } else if (pos.x > maxX) {
443
+ newX = minX + (pos.x - maxX);
444
+ wrapped = true;
445
+ }
446
+ if (pos.y < minY) {
447
+ newY = maxY - (minY - pos.y);
448
+ wrapped = true;
449
+ } else if (pos.y > maxY) {
450
+ newY = minY + (pos.y - maxY);
451
+ wrapped = true;
452
+ }
453
+ if (wrapped) {
454
+ body.setTranslation({ x: newX, y: newY, z: pos.z }, true);
455
+ }
456
+ return wrapped;
457
+ }
458
+ destroy(_ecs) {
459
+ }
460
+ };
461
+ var ScreenWrapBehavior = defineBehavior({
462
+ name: "screen-wrap",
463
+ defaultOptions: defaultOptions2,
464
+ systemFactory: (ctx) => new ScreenWrapSystem(ctx.world)
465
+ });
466
+
467
+ // src/lib/behaviors/world-boundary-2d/world-boundary-2d-fsm.ts
468
+ import { StateMachine as StateMachine3, t as t3 } from "typescript-fsm";
469
+ var WorldBoundary2DState = /* @__PURE__ */ ((WorldBoundary2DState2) => {
470
+ WorldBoundary2DState2["Inside"] = "inside";
471
+ WorldBoundary2DState2["Touching"] = "touching";
472
+ return WorldBoundary2DState2;
473
+ })(WorldBoundary2DState || {});
474
+ var WorldBoundary2DEvent = /* @__PURE__ */ ((WorldBoundary2DEvent2) => {
475
+ WorldBoundary2DEvent2["EnterInside"] = "enter-inside";
476
+ WorldBoundary2DEvent2["TouchBoundary"] = "touch-boundary";
477
+ return WorldBoundary2DEvent2;
478
+ })(WorldBoundary2DEvent || {});
479
+ function computeWorldBoundary2DHits(position, bounds) {
480
+ const hits = {
481
+ top: false,
482
+ bottom: false,
483
+ left: false,
484
+ right: false
485
+ };
486
+ if (position.x <= bounds.left) hits.left = true;
487
+ else if (position.x >= bounds.right) hits.right = true;
488
+ if (position.y <= bounds.bottom) hits.bottom = true;
489
+ else if (position.y >= bounds.top) hits.top = true;
490
+ return hits;
491
+ }
492
+ function hasAnyWorldBoundary2DHit(hits) {
493
+ return !!(hits.left || hits.right || hits.top || hits.bottom);
494
+ }
495
+ var WorldBoundary2DFSM = class {
496
+ machine;
497
+ lastHits = { top: false, bottom: false, left: false, right: false };
498
+ lastPosition = null;
499
+ lastUpdatedAtMs = null;
500
+ constructor() {
501
+ this.machine = new StateMachine3(
502
+ "inside" /* Inside */,
503
+ [
504
+ t3("inside" /* Inside */, "touch-boundary" /* TouchBoundary */, "touching" /* Touching */),
505
+ t3("touching" /* Touching */, "enter-inside" /* EnterInside */, "inside" /* Inside */),
506
+ // Self transitions (no-ops)
507
+ t3("inside" /* Inside */, "enter-inside" /* EnterInside */, "inside" /* Inside */),
508
+ t3("touching" /* Touching */, "touch-boundary" /* TouchBoundary */, "touching" /* Touching */)
509
+ ]
510
+ );
511
+ }
512
+ getState() {
513
+ return this.machine.getState();
514
+ }
515
+ /**
516
+ * Returns the last computed hits (always available after first update call).
517
+ */
518
+ getLastHits() {
519
+ return this.lastHits;
520
+ }
521
+ /**
522
+ * Returns adjusted movement values based on boundary hits.
523
+ * If the entity is touching a boundary and trying to move further into it,
524
+ * that axis component is zeroed out.
525
+ *
526
+ * @param moveX - The desired X movement
527
+ * @param moveY - The desired Y movement
528
+ * @returns Adjusted { moveX, moveY } with boundary-blocked axes zeroed
529
+ */
530
+ getMovement(moveX, moveY) {
531
+ const hits = this.lastHits;
532
+ let adjustedX = moveX;
533
+ let adjustedY = moveY;
534
+ if (hits.left && moveX < 0 || hits.right && moveX > 0) {
535
+ adjustedX = 0;
536
+ }
537
+ if (hits.bottom && moveY < 0 || hits.top && moveY > 0) {
538
+ adjustedY = 0;
539
+ }
540
+ return { moveX: adjustedX, moveY: adjustedY };
541
+ }
542
+ /**
543
+ * Returns the last position passed to `update`, if any.
544
+ */
545
+ getLastPosition() {
546
+ return this.lastPosition;
547
+ }
548
+ /**
549
+ * Best-effort timestamp (ms) of the last `update(...)` call.
550
+ * This is optional metadata; systems can ignore it.
551
+ */
552
+ getLastUpdatedAtMs() {
553
+ return this.lastUpdatedAtMs;
554
+ }
555
+ /**
556
+ * Update FSM + extended state based on current position and bounds.
557
+ * Returns the computed hits for convenience.
558
+ */
559
+ update(position, bounds) {
560
+ const hits = computeWorldBoundary2DHits(position, bounds);
561
+ this.lastHits = hits;
562
+ this.lastPosition = { x: position.x, y: position.y };
563
+ this.lastUpdatedAtMs = Date.now();
564
+ if (hasAnyWorldBoundary2DHit(hits)) {
565
+ this.dispatch("touch-boundary" /* TouchBoundary */);
566
+ } else {
567
+ this.dispatch("enter-inside" /* EnterInside */);
568
+ }
569
+ return hits;
570
+ }
571
+ dispatch(event) {
572
+ if (this.machine.can(event)) {
573
+ this.machine.dispatch(event);
574
+ }
311
575
  }
576
+ };
577
+
578
+ // src/lib/behaviors/world-boundary-2d/world-boundary-2d.descriptor.ts
579
+ var defaultOptions3 = {
580
+ boundaries: { top: 0, bottom: 0, left: 0, right: 0 }
581
+ };
582
+ function createWorldBoundary2DHandle(ref) {
583
+ return {
584
+ getLastHits: () => {
585
+ const fsm = ref.fsm;
586
+ return fsm?.getLastHits() ?? null;
587
+ },
588
+ getMovement: (moveX, moveY) => {
589
+ const fsm = ref.fsm;
590
+ return fsm?.getMovement(moveX, moveY) ?? { moveX, moveY };
591
+ }
592
+ };
312
593
  }
594
+ var WorldBoundary2DSystem = class {
595
+ constructor(world) {
596
+ this.world = world;
597
+ }
598
+ update(_ecs, _delta) {
599
+ if (!this.world?.collisionMap) return;
600
+ for (const [, entity] of this.world.collisionMap) {
601
+ const gameEntity = entity;
602
+ if (typeof gameEntity.getBehaviorRefs !== "function") continue;
603
+ const refs = gameEntity.getBehaviorRefs();
604
+ const boundaryRef = refs.find(
605
+ (r) => r.descriptor.key === /* @__PURE__ */ Symbol.for("zylem:behavior:world-boundary-2d")
606
+ );
607
+ if (!boundaryRef || !gameEntity.body) continue;
608
+ const options = boundaryRef.options;
609
+ if (!boundaryRef.fsm) {
610
+ boundaryRef.fsm = new WorldBoundary2DFSM();
611
+ }
612
+ const body = gameEntity.body;
613
+ const pos = body.translation();
614
+ boundaryRef.fsm.update(
615
+ { x: pos.x, y: pos.y },
616
+ options.boundaries
617
+ );
618
+ }
619
+ }
620
+ destroy(_ecs) {
621
+ }
622
+ };
623
+ var WorldBoundary2DBehavior = defineBehavior({
624
+ name: "world-boundary-2d",
625
+ defaultOptions: defaultOptions3,
626
+ systemFactory: (ctx) => new WorldBoundary2DSystem(ctx.world),
627
+ createHandle: createWorldBoundary2DHandle
628
+ });
313
629
 
314
- // src/lib/actions/behaviors/ricochet/ricochet.ts
630
+ // src/lib/behaviors/ricochet-2d/ricochet-2d-fsm.ts
631
+ import { StateMachine as StateMachine4, t as t4 } from "typescript-fsm";
632
+ var Ricochet2DState = /* @__PURE__ */ ((Ricochet2DState2) => {
633
+ Ricochet2DState2["Idle"] = "idle";
634
+ Ricochet2DState2["Ricocheting"] = "ricocheting";
635
+ return Ricochet2DState2;
636
+ })(Ricochet2DState || {});
637
+ var Ricochet2DEvent = /* @__PURE__ */ ((Ricochet2DEvent2) => {
638
+ Ricochet2DEvent2["StartRicochet"] = "start-ricochet";
639
+ Ricochet2DEvent2["EndRicochet"] = "end-ricochet";
640
+ return Ricochet2DEvent2;
641
+ })(Ricochet2DEvent || {});
315
642
  function clamp(value, min, max) {
316
643
  return Math.max(min, Math.min(max, value));
317
644
  }
645
+ var Ricochet2DFSM = class {
646
+ machine;
647
+ lastResult = null;
648
+ lastUpdatedAtMs = null;
649
+ constructor() {
650
+ this.machine = new StateMachine4(
651
+ "idle" /* Idle */,
652
+ [
653
+ t4("idle" /* Idle */, "start-ricochet" /* StartRicochet */, "ricocheting" /* Ricocheting */),
654
+ t4("ricocheting" /* Ricocheting */, "end-ricochet" /* EndRicochet */, "idle" /* Idle */),
655
+ // Self transitions (no-ops)
656
+ t4("idle" /* Idle */, "end-ricochet" /* EndRicochet */, "idle" /* Idle */),
657
+ t4("ricocheting" /* Ricocheting */, "start-ricochet" /* StartRicochet */, "ricocheting" /* Ricocheting */)
658
+ ]
659
+ );
660
+ }
661
+ getState() {
662
+ return this.machine.getState();
663
+ }
664
+ /**
665
+ * Returns the last computed ricochet result, or null if none.
666
+ */
667
+ getLastResult() {
668
+ return this.lastResult;
669
+ }
670
+ /**
671
+ * Best-effort timestamp (ms) of the last computation.
672
+ */
673
+ getLastUpdatedAtMs() {
674
+ return this.lastUpdatedAtMs;
675
+ }
676
+ /**
677
+ * Compute a ricochet result from collision context.
678
+ * Returns the result for the consumer to apply, or null if invalid input.
679
+ */
680
+ computeRicochet(ctx, options = {}) {
681
+ const {
682
+ minSpeed = 2,
683
+ maxSpeed = 20,
684
+ speedMultiplier = 1.05,
685
+ reflectionMode = "angled",
686
+ maxAngleDeg = 60
687
+ } = options;
688
+ const { selfVelocity, selfPosition, otherPosition, otherSize } = this.extractDataFromEntities(ctx);
689
+ if (!selfVelocity) {
690
+ this.dispatch("end-ricochet" /* EndRicochet */);
691
+ return null;
692
+ }
693
+ const speed = Math.hypot(selfVelocity.x, selfVelocity.y);
694
+ if (speed === 0) {
695
+ this.dispatch("end-ricochet" /* EndRicochet */);
696
+ return null;
697
+ }
698
+ const normal = ctx.contact.normal ?? this.computeNormalFromPositions(selfPosition, otherPosition);
699
+ if (!normal) {
700
+ this.dispatch("end-ricochet" /* EndRicochet */);
701
+ return null;
702
+ }
703
+ let reflected = this.computeBasicReflection(selfVelocity, normal);
704
+ if (reflectionMode === "angled") {
705
+ reflected = this.computeAngledDeflection(
706
+ selfVelocity,
707
+ normal,
708
+ speed,
709
+ maxAngleDeg,
710
+ speedMultiplier,
711
+ selfPosition,
712
+ otherPosition,
713
+ otherSize,
714
+ ctx.contact.position
715
+ );
716
+ }
717
+ reflected = this.applySpeedClamp(reflected, minSpeed, maxSpeed);
718
+ const result = {
719
+ velocity: { x: reflected.x, y: reflected.y, z: 0 },
720
+ speed: Math.hypot(reflected.x, reflected.y),
721
+ normal: { x: normal.x, y: normal.y, z: 0 }
722
+ };
723
+ this.lastResult = result;
724
+ this.lastUpdatedAtMs = Date.now();
725
+ this.dispatch("start-ricochet" /* StartRicochet */);
726
+ return result;
727
+ }
728
+ /**
729
+ * Extract velocity, position, and size data from entities or context.
730
+ */
731
+ extractDataFromEntities(ctx) {
732
+ let selfVelocity = ctx.selfVelocity;
733
+ let selfPosition = ctx.selfPosition;
734
+ let otherPosition = ctx.otherPosition;
735
+ let otherSize = ctx.otherSize;
736
+ if (ctx.entity?.body) {
737
+ const vel = ctx.entity.body.linvel();
738
+ selfVelocity = selfVelocity ?? { x: vel.x, y: vel.y, z: vel.z };
739
+ const pos = ctx.entity.body.translation();
740
+ selfPosition = selfPosition ?? { x: pos.x, y: pos.y, z: pos.z };
741
+ }
742
+ if (ctx.otherEntity?.body) {
743
+ const pos = ctx.otherEntity.body.translation();
744
+ otherPosition = otherPosition ?? { x: pos.x, y: pos.y, z: pos.z };
745
+ }
746
+ if (ctx.otherEntity && "size" in ctx.otherEntity) {
747
+ const size = ctx.otherEntity.size;
748
+ if (size && typeof size.x === "number") {
749
+ otherSize = otherSize ?? { x: size.x, y: size.y, z: size.z };
750
+ }
751
+ }
752
+ return { selfVelocity, selfPosition, otherPosition, otherSize };
753
+ }
754
+ /**
755
+ * Compute collision normal from entity positions using AABB heuristic.
756
+ */
757
+ computeNormalFromPositions(selfPosition, otherPosition) {
758
+ if (!selfPosition || !otherPosition) return null;
759
+ const dx = selfPosition.x - otherPosition.x;
760
+ const dy = selfPosition.y - otherPosition.y;
761
+ if (Math.abs(dx) > Math.abs(dy)) {
762
+ return { x: dx > 0 ? 1 : -1, y: 0, z: 0 };
763
+ } else {
764
+ return { x: 0, y: dy > 0 ? 1 : -1, z: 0 };
765
+ }
766
+ }
767
+ /**
768
+ * Compute basic reflection using the formula: v' = v - 2(v·n)n
769
+ */
770
+ computeBasicReflection(velocity, normal) {
771
+ const vx = velocity.x;
772
+ const vy = velocity.y;
773
+ const dotProduct = vx * normal.x + vy * normal.y;
774
+ return {
775
+ x: vx - 2 * dotProduct * normal.x,
776
+ y: vy - 2 * dotProduct * normal.y
777
+ };
778
+ }
779
+ /**
780
+ * Compute angled deflection for paddle-style reflections.
781
+ */
782
+ computeAngledDeflection(velocity, normal, speed, maxAngleDeg, speedMultiplier, selfPosition, otherPosition, otherSize, contactPosition) {
783
+ const maxAngleRad = maxAngleDeg * Math.PI / 180;
784
+ let tx = -normal.y;
785
+ let ty = normal.x;
786
+ if (Math.abs(normal.x) > Math.abs(normal.y)) {
787
+ if (ty < 0) {
788
+ tx = -tx;
789
+ ty = -ty;
790
+ }
791
+ } else {
792
+ if (tx < 0) {
793
+ tx = -tx;
794
+ ty = -ty;
795
+ }
796
+ }
797
+ const offset = this.computeHitOffset(
798
+ velocity,
799
+ normal,
800
+ speed,
801
+ tx,
802
+ ty,
803
+ selfPosition,
804
+ otherPosition,
805
+ otherSize,
806
+ contactPosition
807
+ );
808
+ const angle = clamp(offset, -1, 1) * maxAngleRad;
809
+ const cosA = Math.cos(angle);
810
+ const sinA = Math.sin(angle);
811
+ const newSpeed = speed * speedMultiplier;
812
+ return {
813
+ x: newSpeed * (normal.x * cosA + tx * sinA),
814
+ y: newSpeed * (normal.y * cosA + ty * sinA)
815
+ };
816
+ }
817
+ /**
818
+ * Compute hit offset for angled deflection (-1 to 1).
819
+ */
820
+ computeHitOffset(velocity, normal, speed, tx, ty, selfPosition, otherPosition, otherSize, contactPosition) {
821
+ if (otherPosition && otherSize) {
822
+ const useY = Math.abs(normal.x) > Math.abs(normal.y);
823
+ const halfExtent = useY ? otherSize.y / 2 : otherSize.x / 2;
824
+ if (useY) {
825
+ const selfY = selfPosition?.y ?? contactPosition?.y ?? 0;
826
+ const paddleY = otherPosition.y;
827
+ return (selfY - paddleY) / halfExtent;
828
+ } else {
829
+ const selfX = selfPosition?.x ?? contactPosition?.x ?? 0;
830
+ const paddleX = otherPosition.x;
831
+ return (selfX - paddleX) / halfExtent;
832
+ }
833
+ }
834
+ return (velocity.x * tx + velocity.y * ty) / speed;
835
+ }
836
+ /**
837
+ * Apply speed constraints to the reflected velocity.
838
+ */
839
+ applySpeedClamp(velocity, minSpeed, maxSpeed) {
840
+ const currentSpeed = Math.hypot(velocity.x, velocity.y);
841
+ if (currentSpeed === 0) return velocity;
842
+ const targetSpeed = clamp(currentSpeed, minSpeed, maxSpeed);
843
+ const scale = targetSpeed / currentSpeed;
844
+ return {
845
+ x: velocity.x * scale,
846
+ y: velocity.y * scale
847
+ };
848
+ }
849
+ /**
850
+ * Clear the ricochet state (call after consumer has applied the result).
851
+ */
852
+ clearRicochet() {
853
+ this.dispatch("end-ricochet" /* EndRicochet */);
854
+ }
855
+ dispatch(event) {
856
+ if (this.machine.can(event)) {
857
+ this.machine.dispatch(event);
858
+ }
859
+ }
860
+ };
318
861
 
319
- // src/lib/actions/behaviors/ricochet/ricochet-2d-in-bounds.ts
320
- function ricochet2DInBounds(options = {}, callback) {
862
+ // src/lib/behaviors/ricochet-2d/ricochet-2d.descriptor.ts
863
+ var defaultOptions4 = {
864
+ minSpeed: 2,
865
+ maxSpeed: 20,
866
+ speedMultiplier: 1.05,
867
+ reflectionMode: "angled",
868
+ maxAngleDeg: 60
869
+ };
870
+ function createRicochet2DHandle(ref) {
321
871
  return {
322
- type: "update",
323
- handler: (updateContext) => {
324
- _handleRicochet2DInBounds(updateContext, options, callback);
872
+ getRicochet: (ctx) => {
873
+ const fsm = ref.fsm;
874
+ if (!fsm) return null;
875
+ return fsm.computeRicochet(ctx, ref.options);
876
+ },
877
+ getLastResult: () => {
878
+ const fsm = ref.fsm;
879
+ return fsm?.getLastResult() ?? null;
325
880
  }
326
881
  };
327
882
  }
328
- function _handleRicochet2DInBounds(updateContext, options, callback) {
329
- const { me } = updateContext;
330
- const {
331
- restitution = 0,
332
- minSpeed = 2,
333
- maxSpeed = 20,
334
- boundaries = { top: 5, bottom: -5, left: -6.5, right: 6.5 },
335
- separation = 0
336
- } = { ...options };
337
- const position = me.getPosition();
338
- const velocity = me.getVelocity();
339
- if (!position || !velocity) return;
340
- let newVelX = velocity.x;
341
- let newVelY = velocity.y;
342
- let newX = position.x;
343
- let newY = position.y;
344
- let ricochetBoundary = null;
345
- if (position.x <= boundaries.left) {
346
- newVelX = Math.abs(velocity.x);
347
- newX = boundaries.left + separation;
348
- ricochetBoundary = "left";
349
- } else if (position.x >= boundaries.right) {
350
- newVelX = -Math.abs(velocity.x);
351
- newX = boundaries.right - separation;
352
- ricochetBoundary = "right";
353
- }
354
- if (position.y <= boundaries.bottom) {
355
- newVelY = Math.abs(velocity.y);
356
- newY = boundaries.bottom + separation;
357
- ricochetBoundary = "bottom";
358
- } else if (position.y >= boundaries.top) {
359
- newVelY = -Math.abs(velocity.y);
360
- newY = boundaries.top - separation;
361
- ricochetBoundary = "top";
362
- }
363
- const currentSpeed = Math.sqrt(newVelX * newVelX + newVelY * newVelY);
364
- if (currentSpeed > 0) {
365
- const targetSpeed = clamp(currentSpeed, minSpeed, maxSpeed);
366
- if (targetSpeed !== currentSpeed) {
367
- const scale = targetSpeed / currentSpeed;
368
- newVelX *= scale;
369
- newVelY *= scale;
370
- }
371
- }
372
- if (restitution) {
373
- newVelX *= restitution;
374
- newVelY *= restitution;
375
- }
376
- if (newX !== position.x || newY !== position.y) {
377
- me.setPosition(newX, newY, position.z);
378
- me.moveXY(newVelX, newVelY);
379
- if (callback && ricochetBoundary) {
380
- const velocityAfter = me.getVelocity();
381
- if (velocityAfter) {
382
- callback({
383
- boundary: ricochetBoundary,
384
- position: { x: newX, y: newY, z: position.z },
385
- velocityBefore: velocity,
386
- velocityAfter,
387
- ...updateContext
388
- });
883
+ var Ricochet2DSystem = class {
884
+ constructor(world) {
885
+ this.world = world;
886
+ }
887
+ update(_ecs, _delta) {
888
+ if (!this.world?.collisionMap) return;
889
+ for (const [, entity] of this.world.collisionMap) {
890
+ const gameEntity = entity;
891
+ if (typeof gameEntity.getBehaviorRefs !== "function") continue;
892
+ const refs = gameEntity.getBehaviorRefs();
893
+ const ricochetRef = refs.find(
894
+ (r) => r.descriptor.key === /* @__PURE__ */ Symbol.for("zylem:behavior:ricochet-2d")
895
+ );
896
+ if (!ricochetRef) continue;
897
+ if (!ricochetRef.fsm) {
898
+ ricochetRef.fsm = new Ricochet2DFSM();
899
+ }
900
+ }
901
+ }
902
+ destroy(_ecs) {
903
+ }
904
+ };
905
+ var Ricochet2DBehavior = defineBehavior({
906
+ name: "ricochet-2d",
907
+ defaultOptions: defaultOptions4,
908
+ systemFactory: (ctx) => new Ricochet2DSystem(ctx.world),
909
+ createHandle: createRicochet2DHandle
910
+ });
911
+
912
+ // src/lib/behaviors/movement-sequence-2d/movement-sequence-2d-fsm.ts
913
+ import { StateMachine as StateMachine5, t as t5 } from "typescript-fsm";
914
+ var MovementSequence2DState = /* @__PURE__ */ ((MovementSequence2DState2) => {
915
+ MovementSequence2DState2["Idle"] = "idle";
916
+ MovementSequence2DState2["Running"] = "running";
917
+ MovementSequence2DState2["Paused"] = "paused";
918
+ MovementSequence2DState2["Completed"] = "completed";
919
+ return MovementSequence2DState2;
920
+ })(MovementSequence2DState || {});
921
+ var MovementSequence2DEvent = /* @__PURE__ */ ((MovementSequence2DEvent2) => {
922
+ MovementSequence2DEvent2["Start"] = "start";
923
+ MovementSequence2DEvent2["Pause"] = "pause";
924
+ MovementSequence2DEvent2["Resume"] = "resume";
925
+ MovementSequence2DEvent2["Complete"] = "complete";
926
+ MovementSequence2DEvent2["Reset"] = "reset";
927
+ return MovementSequence2DEvent2;
928
+ })(MovementSequence2DEvent || {});
929
+ var MovementSequence2DFSM = class {
930
+ machine;
931
+ sequence = [];
932
+ loop = true;
933
+ currentIndex = 0;
934
+ timeRemaining = 0;
935
+ constructor() {
936
+ this.machine = new StateMachine5(
937
+ "idle" /* Idle */,
938
+ [
939
+ // From Idle
940
+ t5("idle" /* Idle */, "start" /* Start */, "running" /* Running */),
941
+ // From Running
942
+ t5("running" /* Running */, "pause" /* Pause */, "paused" /* Paused */),
943
+ t5("running" /* Running */, "complete" /* Complete */, "completed" /* Completed */),
944
+ t5("running" /* Running */, "reset" /* Reset */, "idle" /* Idle */),
945
+ // From Paused
946
+ t5("paused" /* Paused */, "resume" /* Resume */, "running" /* Running */),
947
+ t5("paused" /* Paused */, "reset" /* Reset */, "idle" /* Idle */),
948
+ // From Completed
949
+ t5("completed" /* Completed */, "reset" /* Reset */, "idle" /* Idle */),
950
+ t5("completed" /* Completed */, "start" /* Start */, "running" /* Running */),
951
+ // Self-transitions (no-ops)
952
+ t5("idle" /* Idle */, "pause" /* Pause */, "idle" /* Idle */),
953
+ t5("idle" /* Idle */, "resume" /* Resume */, "idle" /* Idle */),
954
+ t5("running" /* Running */, "start" /* Start */, "running" /* Running */),
955
+ t5("running" /* Running */, "resume" /* Resume */, "running" /* Running */),
956
+ t5("paused" /* Paused */, "pause" /* Pause */, "paused" /* Paused */),
957
+ t5("completed" /* Completed */, "complete" /* Complete */, "completed" /* Completed */)
958
+ ]
959
+ );
960
+ }
961
+ /**
962
+ * Initialize the sequence. Call this once with options.
963
+ */
964
+ init(sequence, loop) {
965
+ this.sequence = sequence;
966
+ this.loop = loop;
967
+ this.currentIndex = 0;
968
+ this.timeRemaining = sequence.length > 0 ? sequence[0].timeInSeconds : 0;
969
+ }
970
+ getState() {
971
+ return this.machine.getState();
972
+ }
973
+ /**
974
+ * Start the sequence (from Idle or Completed).
975
+ */
976
+ start() {
977
+ if (this.machine.getState() === "idle" /* Idle */ || this.machine.getState() === "completed" /* Completed */) {
978
+ this.currentIndex = 0;
979
+ this.timeRemaining = this.sequence.length > 0 ? this.sequence[0].timeInSeconds : 0;
980
+ }
981
+ this.dispatch("start" /* Start */);
982
+ }
983
+ /**
984
+ * Pause the sequence.
985
+ */
986
+ pause() {
987
+ this.dispatch("pause" /* Pause */);
988
+ }
989
+ /**
990
+ * Resume a paused sequence.
991
+ */
992
+ resume() {
993
+ this.dispatch("resume" /* Resume */);
994
+ }
995
+ /**
996
+ * Reset to Idle state.
997
+ */
998
+ reset() {
999
+ this.dispatch("reset" /* Reset */);
1000
+ this.currentIndex = 0;
1001
+ this.timeRemaining = this.sequence.length > 0 ? this.sequence[0].timeInSeconds : 0;
1002
+ }
1003
+ /**
1004
+ * Update the sequence with delta time.
1005
+ * Returns the current movement to apply.
1006
+ * Automatically starts if in Idle state.
1007
+ */
1008
+ update(delta) {
1009
+ if (this.sequence.length === 0) {
1010
+ return { moveX: 0, moveY: 0 };
1011
+ }
1012
+ if (this.machine.getState() === "idle" /* Idle */) {
1013
+ this.start();
1014
+ }
1015
+ if (this.machine.getState() !== "running" /* Running */) {
1016
+ if (this.machine.getState() === "completed" /* Completed */) {
1017
+ return { moveX: 0, moveY: 0 };
1018
+ }
1019
+ const step2 = this.sequence[this.currentIndex];
1020
+ return { moveX: step2?.moveX ?? 0, moveY: step2?.moveY ?? 0 };
1021
+ }
1022
+ let timeLeft = this.timeRemaining - delta;
1023
+ while (timeLeft <= 0) {
1024
+ const overflow = -timeLeft;
1025
+ this.currentIndex += 1;
1026
+ if (this.currentIndex >= this.sequence.length) {
1027
+ if (!this.loop) {
1028
+ this.dispatch("complete" /* Complete */);
1029
+ return { moveX: 0, moveY: 0 };
1030
+ }
1031
+ this.currentIndex = 0;
389
1032
  }
1033
+ timeLeft = this.sequence[this.currentIndex].timeInSeconds - overflow;
390
1034
  }
1035
+ this.timeRemaining = timeLeft;
1036
+ const step = this.sequence[this.currentIndex];
1037
+ return { moveX: step?.moveX ?? 0, moveY: step?.moveY ?? 0 };
391
1038
  }
1039
+ /**
1040
+ * Get the current movement without advancing time.
1041
+ */
1042
+ getMovement() {
1043
+ if (this.sequence.length === 0 || this.machine.getState() === "completed" /* Completed */) {
1044
+ return { moveX: 0, moveY: 0 };
1045
+ }
1046
+ const step = this.sequence[this.currentIndex];
1047
+ return { moveX: step?.moveX ?? 0, moveY: step?.moveY ?? 0 };
1048
+ }
1049
+ /**
1050
+ * Get current step info.
1051
+ */
1052
+ getCurrentStep() {
1053
+ if (this.sequence.length === 0) return null;
1054
+ const step = this.sequence[this.currentIndex];
1055
+ if (!step) return null;
1056
+ return {
1057
+ name: step.name,
1058
+ index: this.currentIndex,
1059
+ moveX: step.moveX ?? 0,
1060
+ moveY: step.moveY ?? 0,
1061
+ timeRemaining: this.timeRemaining
1062
+ };
1063
+ }
1064
+ /**
1065
+ * Get sequence progress.
1066
+ */
1067
+ getProgress() {
1068
+ return {
1069
+ stepIndex: this.currentIndex,
1070
+ totalSteps: this.sequence.length,
1071
+ stepTimeRemaining: this.timeRemaining,
1072
+ done: this.machine.getState() === "completed" /* Completed */
1073
+ };
1074
+ }
1075
+ dispatch(event) {
1076
+ if (this.machine.can(event)) {
1077
+ this.machine.dispatch(event);
1078
+ }
1079
+ }
1080
+ };
1081
+
1082
+ // src/lib/behaviors/movement-sequence-2d/movement-sequence-2d.descriptor.ts
1083
+ var defaultOptions5 = {
1084
+ sequence: [],
1085
+ loop: true
1086
+ };
1087
+ function createMovementSequence2DHandle(ref) {
1088
+ return {
1089
+ getMovement: () => {
1090
+ const fsm = ref.fsm;
1091
+ return fsm?.getMovement() ?? { moveX: 0, moveY: 0 };
1092
+ },
1093
+ getCurrentStep: () => {
1094
+ const fsm = ref.fsm;
1095
+ return fsm?.getCurrentStep() ?? null;
1096
+ },
1097
+ getProgress: () => {
1098
+ const fsm = ref.fsm;
1099
+ return fsm?.getProgress() ?? { stepIndex: 0, totalSteps: 0, stepTimeRemaining: 0, done: true };
1100
+ },
1101
+ pause: () => {
1102
+ const fsm = ref.fsm;
1103
+ fsm?.pause();
1104
+ },
1105
+ resume: () => {
1106
+ const fsm = ref.fsm;
1107
+ fsm?.resume();
1108
+ },
1109
+ reset: () => {
1110
+ const fsm = ref.fsm;
1111
+ fsm?.reset();
1112
+ }
1113
+ };
392
1114
  }
1115
+ var MovementSequence2DSystem = class {
1116
+ constructor(world) {
1117
+ this.world = world;
1118
+ }
1119
+ update(_ecs, delta) {
1120
+ if (!this.world?.collisionMap) return;
1121
+ for (const [, entity] of this.world.collisionMap) {
1122
+ const gameEntity = entity;
1123
+ if (typeof gameEntity.getBehaviorRefs !== "function") continue;
1124
+ const refs = gameEntity.getBehaviorRefs();
1125
+ const sequenceRef = refs.find(
1126
+ (r) => r.descriptor.key === /* @__PURE__ */ Symbol.for("zylem:behavior:movement-sequence-2d")
1127
+ );
1128
+ if (!sequenceRef) continue;
1129
+ const options = sequenceRef.options;
1130
+ if (!sequenceRef.fsm) {
1131
+ sequenceRef.fsm = new MovementSequence2DFSM();
1132
+ sequenceRef.fsm.init(options.sequence, options.loop);
1133
+ }
1134
+ sequenceRef.fsm.update(delta);
1135
+ }
1136
+ }
1137
+ destroy(_ecs) {
1138
+ }
1139
+ };
1140
+ var MovementSequence2DBehavior = defineBehavior({
1141
+ name: "movement-sequence-2d",
1142
+ defaultOptions: defaultOptions5,
1143
+ systemFactory: (ctx) => new MovementSequence2DSystem(ctx.world),
1144
+ createHandle: createMovementSequence2DHandle
1145
+ });
1146
+
1147
+ // src/lib/coordinators/boundary-ricochet.coordinator.ts
1148
+ var BoundaryRicochetCoordinator = class {
1149
+ constructor(entity, boundary, ricochet) {
1150
+ this.entity = entity;
1151
+ this.boundary = boundary;
1152
+ this.ricochet = ricochet;
1153
+ }
1154
+ /**
1155
+ * Update loop - call this every frame
1156
+ */
1157
+ update() {
1158
+ const hits = this.boundary.getLastHits();
1159
+ if (!hits) return null;
1160
+ const anyHit = hits.left || hits.right || hits.top || hits.bottom;
1161
+ if (!anyHit) return null;
1162
+ let normalX = 0;
1163
+ let normalY = 0;
1164
+ if (hits.left) normalX = 1;
1165
+ if (hits.right) normalX = -1;
1166
+ if (hits.bottom) normalY = 1;
1167
+ if (hits.top) normalY = -1;
1168
+ return this.ricochet.getRicochet({
1169
+ entity: this.entity,
1170
+ contact: { normal: { x: normalX, y: normalY } }
1171
+ });
1172
+ }
1173
+ };
393
1174
  export {
394
- boundary2d,
395
- ricochet2DCollision,
396
- ricochet2DInBounds
1175
+ BoundaryRicochetCoordinator,
1176
+ MovementSequence2DBehavior,
1177
+ MovementSequence2DEvent,
1178
+ MovementSequence2DFSM,
1179
+ MovementSequence2DState,
1180
+ PhysicsStepBehavior,
1181
+ PhysicsSyncBehavior,
1182
+ Ricochet2DBehavior,
1183
+ Ricochet2DEvent,
1184
+ Ricochet2DFSM,
1185
+ Ricochet2DState,
1186
+ ScreenWrapBehavior,
1187
+ ScreenWrapEvent,
1188
+ ScreenWrapFSM,
1189
+ ScreenWrapState,
1190
+ ThrusterBehavior,
1191
+ ThrusterEvent,
1192
+ ThrusterFSM,
1193
+ ThrusterMovementBehavior,
1194
+ ThrusterState,
1195
+ WorldBoundary2DBehavior,
1196
+ WorldBoundary2DEvent,
1197
+ WorldBoundary2DFSM,
1198
+ WorldBoundary2DState,
1199
+ computeWorldBoundary2DHits,
1200
+ createPhysicsBodyComponent,
1201
+ createThrusterInputComponent,
1202
+ createThrusterMovementComponent,
1203
+ createThrusterStateComponent,
1204
+ createTransformComponent,
1205
+ defineBehavior,
1206
+ hasAnyWorldBoundary2DHit,
1207
+ useBehavior
397
1208
  };
398
1209
  //# sourceMappingURL=behaviors.js.map