cubeforge 0.3.13 → 0.3.14

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/README.md CHANGED
@@ -32,8 +32,8 @@ npm install cubeforge react react-dom
32
32
  ## What's included
33
33
 
34
34
  - **ECS** — archetype-based entity-component-system with query caching
35
- - **Physics** — two-pass AABB, capsule colliders, kinematic bodies, one-way platforms, 60 Hz fixed timestep
36
- - **Renderer** — WebGL2 instanced renderer by default; Canvas2D opt-in via `renderer={Canvas2DRenderSystem}`
35
+ - **Physics** — two-pass AABB, kinematic bodies, one-way platforms, 60 Hz fixed timestep
36
+ - **Renderer** — WebGL2 instanced renderer by default
37
37
  - **Input** — keyboard, mouse, gamepad, per-player input maps, input contexts, recording/playback
38
38
  - **Audio** — Web Audio API with volume groups, fade, crossfade, ducking (`useSound`)
39
39
  - **Gameplay hooks** — `usePlatformerController`, `useTopDownMovement`, `useHealth`, `useSave`, `useGameStateMachine`, `useLevelTransition`, `usePathfinding`, `useAISteering`, and more
package/dist/index.d.ts CHANGED
@@ -153,8 +153,10 @@ interface RigidBodyProps {
153
153
  lockX?: boolean;
154
154
  /** Prevent any vertical movement — velocity.y is zeroed every frame (disables gravity) */
155
155
  lockY?: boolean;
156
+ /** Enable continuous collision detection to prevent tunneling through thin colliders */
157
+ ccd?: boolean;
156
158
  }
157
- declare function RigidBody({ mass, gravityScale, isStatic, bounce, friction, vx, vy, lockX, lockY, }: RigidBodyProps): null;
159
+ declare function RigidBody({ mass, gravityScale, isStatic, bounce, friction, vx, vy, lockX, lockY, ccd, }: RigidBodyProps): null;
158
160
 
159
161
  interface BoxColliderProps {
160
162
  width: number;
package/dist/index.js CHANGED
@@ -249,14 +249,16 @@ function findByTag(world, tag) {
249
249
 
250
250
  // ../../packages/core/src/loop/gameLoop.ts
251
251
  var GameLoop = class {
252
- constructor(onTick) {
252
+ constructor(onTick, options) {
253
253
  this.onTick = onTick;
254
+ this.fixedDt = options?.fixedDt;
254
255
  }
255
256
  rafId = 0;
256
257
  lastTime = 0;
257
258
  running = false;
258
259
  paused = false;
259
260
  hitPauseTimer = 0;
261
+ fixedDt;
260
262
  hitPause(duration) {
261
263
  this.hitPauseTimer = duration;
262
264
  }
@@ -292,8 +294,9 @@ var GameLoop = class {
292
294
  }
293
295
  frame = (time) => {
294
296
  if (!this.running) return;
295
- const dt = Math.min((time - this.lastTime) / 1e3, 0.1);
297
+ const rawDt = Math.min((time - this.lastTime) / 1e3, 0.1);
296
298
  this.lastTime = time;
299
+ const dt = this.fixedDt ?? rawDt;
297
300
  if (this.hitPauseTimer > 0) {
298
301
  this.hitPauseTimer -= dt;
299
302
  } else {
@@ -947,11 +950,16 @@ var Mouse = class {
947
950
  var InputManager = class {
948
951
  keyboard = new Keyboard();
949
952
  mouse = new Mouse();
953
+ _attachedElement = null;
950
954
  attach(canvas) {
955
+ if (this._attachedElement === canvas) return;
956
+ if (this._attachedElement) this.detach();
957
+ this._attachedElement = canvas;
951
958
  this.keyboard.attach(window);
952
959
  this.mouse.attach(canvas);
953
960
  }
954
961
  detach() {
962
+ this._attachedElement = null;
955
963
  this.keyboard.detach();
956
964
  this.mouse.detach();
957
965
  }
@@ -1391,6 +1399,7 @@ function parseCSSColor(css) {
1391
1399
  // ../../packages/renderer/src/webglRenderSystem.ts
1392
1400
  var FLOATS_PER_INSTANCE = 18;
1393
1401
  var MAX_INSTANCES = 8192;
1402
+ var MAX_SPRITE_TEXTURES = 512;
1394
1403
  var MAX_TEXT_CACHE = 200;
1395
1404
  function compileShader(gl, type, src) {
1396
1405
  const shader = gl.createShader(type);
@@ -1571,6 +1580,8 @@ var RenderSystem = class {
1571
1580
  instanceData;
1572
1581
  whiteTexture;
1573
1582
  textures = /* @__PURE__ */ new Map();
1583
+ /** Tracks texture access order for LRU eviction (most recent at end). */
1584
+ textureLRU = [];
1574
1585
  imageCache = /* @__PURE__ */ new Map();
1575
1586
  // Cached uniform locations — sprite program
1576
1587
  uCamPos;
@@ -1603,6 +1614,21 @@ var RenderSystem = class {
1603
1614
  getDefaultSampling() {
1604
1615
  return this._defaultSampling;
1605
1616
  }
1617
+ // ── Sprite texture LRU helpers ──────────────────────────────────────────
1618
+ /** Record a texture key as recently used; evict LRU entries if over limit. */
1619
+ touchTexture(key) {
1620
+ const idx = this.textureLRU.indexOf(key);
1621
+ if (idx !== -1) this.textureLRU.splice(idx, 1);
1622
+ this.textureLRU.push(key);
1623
+ while (this.textureLRU.length > MAX_SPRITE_TEXTURES) {
1624
+ const evict = this.textureLRU.shift();
1625
+ const tex = this.textures.get(evict);
1626
+ if (tex) {
1627
+ this.gl.deleteTexture(tex);
1628
+ this.textures.delete(evict);
1629
+ }
1630
+ }
1631
+ }
1606
1632
  // ── Debug overlays ──────────────────────────────────────────────────────
1607
1633
  debugNavGrid = null;
1608
1634
  contactFlashPoints = [];
@@ -1620,7 +1646,10 @@ var RenderSystem = class {
1620
1646
  // ── Texture management (sprite textures — CLAMP_TO_EDGE) ──────────────────
1621
1647
  loadTexture(src) {
1622
1648
  const cached = this.textures.get(src);
1623
- if (cached) return cached;
1649
+ if (cached) {
1650
+ this.touchTexture(src);
1651
+ return cached;
1652
+ }
1624
1653
  let imgSrc = src;
1625
1654
  const sampIdx = imgSrc.indexOf(":s=");
1626
1655
  if (sampIdx !== -1) imgSrc = imgSrc.slice(0, sampIdx);
@@ -1636,6 +1665,7 @@ var RenderSystem = class {
1636
1665
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1637
1666
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1638
1667
  this.textures.set(src, tex);
1668
+ this.touchTexture(src);
1639
1669
  return tex;
1640
1670
  }
1641
1671
  if (!existing) {
@@ -1653,6 +1683,11 @@ var RenderSystem = class {
1653
1683
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrap);
1654
1684
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrap);
1655
1685
  this.textures.set(src, tex);
1686
+ this.touchTexture(src);
1687
+ };
1688
+ img.onerror = () => {
1689
+ console.warn(`[WebGLRenderSystem] Failed to load image: ${imgSrc}`);
1690
+ this.imageCache.delete(imgSrc);
1656
1691
  };
1657
1692
  this.imageCache.set(imgSrc, img);
1658
1693
  }
@@ -1943,6 +1978,9 @@ var RenderSystem = class {
1943
1978
  gl2.texParameteri(gl2.TEXTURE_2D, gl2.TEXTURE_WRAP_S, wrap);
1944
1979
  gl2.texParameteri(gl2.TEXTURE_2D, gl2.TEXTURE_WRAP_T, wrap);
1945
1980
  this.textures.set(cacheKey, tex);
1981
+ this.touchTexture(cacheKey);
1982
+ } else if (cacheKey) {
1983
+ this.touchTexture(cacheKey);
1946
1984
  }
1947
1985
  } else if (sprite.src && !sprite.image) {
1948
1986
  let img = this.imageCache.get(sprite.src);
@@ -1958,6 +1996,11 @@ var RenderSystem = class {
1958
1996
  gl2.texParameteri(gl2.TEXTURE_2D, gl2.TEXTURE_MIN_FILTER, gl2.NEAREST);
1959
1997
  gl2.texParameteri(gl2.TEXTURE_2D, gl2.TEXTURE_MAG_FILTER, gl2.NEAREST);
1960
1998
  this.textures.set(img.src, tex);
1999
+ this.touchTexture(img.src);
2000
+ };
2001
+ img.onerror = () => {
2002
+ console.warn(`[WebGLRenderSystem] Failed to load image: ${img.src}`);
2003
+ this.imageCache.delete(img.src);
1961
2004
  };
1962
2005
  }
1963
2006
  sprite.image = img;
@@ -2266,6 +2309,7 @@ function createRigidBody(opts) {
2266
2309
  lockY: false,
2267
2310
  isKinematic: false,
2268
2311
  dropThrough: 0,
2312
+ ccd: false,
2269
2313
  ...opts
2270
2314
  };
2271
2315
  }
@@ -2337,6 +2381,14 @@ function getAABB(transform, collider) {
2337
2381
  hh: collider.height / 2
2338
2382
  };
2339
2383
  }
2384
+ function getCapsuleAABB(transform, capsule) {
2385
+ return {
2386
+ cx: transform.x + capsule.offsetX,
2387
+ cy: transform.y + capsule.offsetY,
2388
+ hw: capsule.width / 2,
2389
+ hh: capsule.height / 2
2390
+ };
2391
+ }
2340
2392
  function getOverlap(a, b) {
2341
2393
  const dx = a.cx - b.cx;
2342
2394
  const dy = a.cy - b.cy;
@@ -2439,6 +2491,36 @@ function getCompoundBounds(tx, ty, shapes) {
2439
2491
  function canInteractGeneric(aLayer, aMask, bLayer, bMask) {
2440
2492
  return maskAllows(aMask, bLayer) && maskAllows(bMask, aLayer);
2441
2493
  }
2494
+ function sweepAABB(aCx, aCy, aHw, aHh, dx, dy, b) {
2495
+ const eHw = b.hw + aHw;
2496
+ const eHh = b.hh + aHh;
2497
+ const left = b.cx - eHw;
2498
+ const right = b.cx + eHw;
2499
+ const top = b.cy - eHh;
2500
+ const bottom = b.cy + eHh;
2501
+ let tmin = -Infinity;
2502
+ let tmax = Infinity;
2503
+ if (dx !== 0) {
2504
+ const t1 = (left - aCx) / dx;
2505
+ const t2 = (right - aCx) / dx;
2506
+ tmin = Math.max(tmin, Math.min(t1, t2));
2507
+ tmax = Math.min(tmax, Math.max(t1, t2));
2508
+ } else if (aCx < left || aCx > right) {
2509
+ return null;
2510
+ }
2511
+ if (dy !== 0) {
2512
+ const t1 = (top - aCy) / dy;
2513
+ const t2 = (bottom - aCy) / dy;
2514
+ tmin = Math.max(tmin, Math.min(t1, t2));
2515
+ tmax = Math.min(tmax, Math.max(t1, t2));
2516
+ } else if (aCy < top || aCy > bottom) {
2517
+ return null;
2518
+ }
2519
+ if (tmax < 0 || tmin > tmax || tmin > 1) return null;
2520
+ const t = Math.max(0, tmin);
2521
+ if (t > 1) return null;
2522
+ return t;
2523
+ }
2442
2524
  function pairKey(a, b) {
2443
2525
  return a < b ? `${a}:${b}` : `${b}:${a}`;
2444
2526
  }
@@ -2449,11 +2531,15 @@ var PhysicsSystem = class {
2449
2531
  }
2450
2532
  accumulator = 0;
2451
2533
  FIXED_DT = 1 / 60;
2534
+ /** Maximum accumulated time (seconds). Prevents hundreds of sub-steps when
2535
+ * the tab is backgrounded and dt spikes on resume. 0.1s ≈ 6 steps at 60Hz. */
2536
+ MAX_ACCUMULATOR = 0.1;
2452
2537
  // Active contact sets — updated each physics step.
2453
2538
  activeTriggerPairs = /* @__PURE__ */ new Map();
2454
2539
  activeCollisionPairs = /* @__PURE__ */ new Map();
2455
2540
  activeCirclePairs = /* @__PURE__ */ new Map();
2456
2541
  activeCompoundPairs = /* @__PURE__ */ new Map();
2542
+ activeCapsulePairs = /* @__PURE__ */ new Map();
2457
2543
  // Previous-frame positions of static entities — used to compute platform carry delta.
2458
2544
  staticPrevPos = /* @__PURE__ */ new Map();
2459
2545
  setGravity(g) {
@@ -2461,8 +2547,8 @@ var PhysicsSystem = class {
2461
2547
  }
2462
2548
  update(world, dt) {
2463
2549
  this.accumulator += dt;
2464
- if (this.accumulator > 5 * this.FIXED_DT) {
2465
- this.accumulator = 5 * this.FIXED_DT;
2550
+ if (this.accumulator > this.MAX_ACCUMULATOR) {
2551
+ this.accumulator = this.MAX_ACCUMULATOR;
2466
2552
  }
2467
2553
  while (this.accumulator >= this.FIXED_DT) {
2468
2554
  this.step(world, this.FIXED_DT);
@@ -2490,6 +2576,12 @@ var PhysicsSystem = class {
2490
2576
  if (rb.isStatic) statics.push(id);
2491
2577
  else dynamics.push(id);
2492
2578
  }
2579
+ const allCapsule = world.query("Transform", "RigidBody", "CapsuleCollider");
2580
+ const capsuleDynamics = [];
2581
+ for (const id of allCapsule) {
2582
+ const rb = world.getComponent(id, "RigidBody");
2583
+ if (!rb.isStatic) capsuleDynamics.push(id);
2584
+ }
2493
2585
  for (const [key, [a, b]] of this.activeTriggerPairs) {
2494
2586
  if (!world.hasEntity(a) || !world.hasEntity(b)) {
2495
2587
  this.events?.emit("triggerExit", { a, b });
@@ -2514,6 +2606,12 @@ var PhysicsSystem = class {
2514
2606
  this.activeCompoundPairs.delete(key);
2515
2607
  }
2516
2608
  }
2609
+ for (const [key, [a, b]] of this.activeCapsulePairs) {
2610
+ if (!world.hasEntity(a) || !world.hasEntity(b)) {
2611
+ this.events?.emit("capsuleExit", { a, b });
2612
+ this.activeCapsulePairs.delete(key);
2613
+ }
2614
+ }
2517
2615
  const staticDelta = /* @__PURE__ */ new Map();
2518
2616
  for (const sid of statics) {
2519
2617
  const st = world.getComponent(sid, "Transform");
@@ -2548,6 +2646,24 @@ var PhysicsSystem = class {
2548
2646
  if (rb.lockY) rb.vy = 0;
2549
2647
  if (rb.dropThrough > 0) rb.dropThrough--;
2550
2648
  }
2649
+ const ccdPrev = /* @__PURE__ */ new Map();
2650
+ for (const id of dynamics) {
2651
+ const rb = world.getComponent(id, "RigidBody");
2652
+ if (rb.ccd) {
2653
+ const t = world.getComponent(id, "Transform");
2654
+ ccdPrev.set(id, { x: t.x, y: t.y });
2655
+ }
2656
+ }
2657
+ for (const id of capsuleDynamics) {
2658
+ const rb = world.getComponent(id, "RigidBody");
2659
+ rb.onGround = false;
2660
+ rb.isNearGround = false;
2661
+ if (rb.isKinematic) continue;
2662
+ if (!rb.lockY) rb.vy += this.gravity * rb.gravityScale * dt;
2663
+ if (rb.lockX) rb.vx = 0;
2664
+ if (rb.lockY) rb.vy = 0;
2665
+ if (rb.dropThrough > 0) rb.dropThrough--;
2666
+ }
2551
2667
  for (const id of dynamics) {
2552
2668
  const transform = world.getComponent(id, "Transform");
2553
2669
  const rb = world.getComponent(id, "RigidBody");
@@ -2578,6 +2694,36 @@ var PhysicsSystem = class {
2578
2694
  }
2579
2695
  }
2580
2696
  }
2697
+ for (const id of capsuleDynamics) {
2698
+ const transform = world.getComponent(id, "Transform");
2699
+ const rb = world.getComponent(id, "RigidBody");
2700
+ const cap = world.getComponent(id, "CapsuleCollider");
2701
+ transform.x += rb.vx * dt;
2702
+ if (!cap.isTrigger) {
2703
+ const dynAABB = getCapsuleAABB(transform, cap);
2704
+ const candidateCells = this.getCells(dynAABB.cx, dynAABB.cy, dynAABB.hw, dynAABB.hh);
2705
+ const checked = /* @__PURE__ */ new Set();
2706
+ for (const cell of candidateCells) {
2707
+ const bucket = staticGrid.get(cell);
2708
+ if (!bucket) continue;
2709
+ for (const sid of bucket) {
2710
+ if (checked.has(sid)) continue;
2711
+ checked.add(sid);
2712
+ const st = world.getComponent(sid, "Transform");
2713
+ const sc = world.getComponent(sid, "BoxCollider");
2714
+ if (sc.isTrigger) continue;
2715
+ if (sc.slope !== 0) continue;
2716
+ if (!canInteractGeneric(cap.layer, cap.mask, sc.layer, sc.mask)) continue;
2717
+ const ov = getOverlap(getCapsuleAABB(transform, cap), getAABB(st, sc));
2718
+ if (!ov) continue;
2719
+ if (Math.abs(ov.x) < Math.abs(ov.y)) {
2720
+ transform.x += ov.x;
2721
+ rb.vx = rb.bounce > 0 ? -rb.vx * rb.bounce : 0;
2722
+ }
2723
+ }
2724
+ }
2725
+ }
2726
+ }
2581
2727
  for (const id of dynamics) {
2582
2728
  const transform = world.getComponent(id, "Transform");
2583
2729
  const rb = world.getComponent(id, "RigidBody");
@@ -2637,6 +2783,127 @@ var PhysicsSystem = class {
2637
2783
  }
2638
2784
  }
2639
2785
  }
2786
+ for (const [id, prev] of ccdPrev) {
2787
+ const transform = world.getComponent(id, "Transform");
2788
+ const rb = world.getComponent(id, "RigidBody");
2789
+ const col = world.getComponent(id, "BoxCollider");
2790
+ const totalDx = transform.x - prev.x;
2791
+ const totalDy = transform.y - prev.y;
2792
+ const moveLen = Math.abs(totalDx) + Math.abs(totalDy);
2793
+ const halfSize = Math.min(col.width, col.height) / 2;
2794
+ if (moveLen <= halfSize) continue;
2795
+ const startCx = prev.x + col.offsetX;
2796
+ const startCy = prev.y + col.offsetY;
2797
+ const sweepDx = totalDx;
2798
+ const sweepDy = totalDy;
2799
+ const hw = col.width / 2;
2800
+ const hh = col.height / 2;
2801
+ let earliestT = 1;
2802
+ let hitSid = null;
2803
+ const endCx = startCx + sweepDx;
2804
+ const endCy = startCy + sweepDy;
2805
+ const minCx = Math.min(startCx, endCx);
2806
+ const maxCx = Math.max(startCx, endCx);
2807
+ const minCy = Math.min(startCy, endCy);
2808
+ const maxCy = Math.max(startCy, endCy);
2809
+ const sweepCells = this.getCells(
2810
+ (minCx + maxCx) / 2,
2811
+ (minCy + maxCy) / 2,
2812
+ (maxCx - minCx) / 2 + hw,
2813
+ (maxCy - minCy) / 2 + hh
2814
+ );
2815
+ const checked = /* @__PURE__ */ new Set();
2816
+ for (const cell of sweepCells) {
2817
+ const bucket = staticGrid.get(cell);
2818
+ if (!bucket) continue;
2819
+ for (const sid of bucket) {
2820
+ if (checked.has(sid)) continue;
2821
+ checked.add(sid);
2822
+ const st = world.getComponent(sid, "Transform");
2823
+ const sc = world.getComponent(sid, "BoxCollider");
2824
+ if (sc.isTrigger) continue;
2825
+ if (!canInteract(col, sc)) continue;
2826
+ const staticAABB = getAABB(st, sc);
2827
+ const t = sweepAABB(startCx, startCy, hw, hh, sweepDx, sweepDy, staticAABB);
2828
+ if (t !== null && t < earliestT) {
2829
+ earliestT = t;
2830
+ hitSid = sid;
2831
+ }
2832
+ }
2833
+ }
2834
+ if (hitSid !== null && earliestT < 1) {
2835
+ const eps = 0.01;
2836
+ const clampedT = Math.max(0, earliestT - eps / (Math.hypot(sweepDx, sweepDy) || 1));
2837
+ transform.x = prev.x + totalDx * clampedT;
2838
+ transform.y = prev.y + totalDy * clampedT;
2839
+ const st = world.getComponent(hitSid, "Transform");
2840
+ const sc = world.getComponent(hitSid, "BoxCollider");
2841
+ const staticAABB = getAABB(st, sc);
2842
+ const contactCx = prev.x + col.offsetX + totalDx * earliestT;
2843
+ const contactCy = prev.y + col.offsetY + totalDy * earliestT;
2844
+ const dxFromCenter = contactCx - staticAABB.cx;
2845
+ const dyFromCenter = contactCy - staticAABB.cy;
2846
+ const overlapX = hw + staticAABB.hw - Math.abs(dxFromCenter);
2847
+ const overlapY = hh + staticAABB.hh - Math.abs(dyFromCenter);
2848
+ if (overlapX > overlapY) {
2849
+ rb.vy = rb.bounce > 0 ? -rb.vy * rb.bounce : 0;
2850
+ if (dyFromCenter < 0) {
2851
+ rb.onGround = true;
2852
+ if (rb.friction < 1) rb.vx *= rb.friction;
2853
+ }
2854
+ } else {
2855
+ rb.vx = rb.bounce > 0 ? -rb.vx * rb.bounce : 0;
2856
+ }
2857
+ }
2858
+ }
2859
+ for (const id of capsuleDynamics) {
2860
+ const transform = world.getComponent(id, "Transform");
2861
+ const rb = world.getComponent(id, "RigidBody");
2862
+ const cap = world.getComponent(id, "CapsuleCollider");
2863
+ transform.y += rb.vy * dt;
2864
+ if (!cap.isTrigger) {
2865
+ const dynAABB = getCapsuleAABB(transform, cap);
2866
+ const candidateCells = this.getCells(dynAABB.cx, dynAABB.cy, dynAABB.hw, dynAABB.hh);
2867
+ const checked = /* @__PURE__ */ new Set();
2868
+ for (const cell of candidateCells) {
2869
+ const bucket = staticGrid.get(cell);
2870
+ if (!bucket) continue;
2871
+ for (const sid of bucket) {
2872
+ if (checked.has(sid)) continue;
2873
+ checked.add(sid);
2874
+ const st = world.getComponent(sid, "Transform");
2875
+ const sc = world.getComponent(sid, "BoxCollider");
2876
+ if (sc.isTrigger) continue;
2877
+ if (!canInteractGeneric(cap.layer, cap.mask, sc.layer, sc.mask)) continue;
2878
+ if (sc.slope !== 0) continue;
2879
+ const ov = getOverlap(getCapsuleAABB(transform, cap), getAABB(st, sc));
2880
+ if (!ov) continue;
2881
+ if (Math.abs(ov.y) <= Math.abs(ov.x)) {
2882
+ if (sc.oneWay) {
2883
+ if (rb.dropThrough > 0) continue;
2884
+ if (ov.y >= 0) continue;
2885
+ const platformTop = st.y + sc.offsetY - sc.height / 2;
2886
+ const prevEntityBottom = transform.y - rb.vy * dt + cap.offsetY + cap.height / 2;
2887
+ if (prevEntityBottom > platformTop) continue;
2888
+ }
2889
+ transform.y += ov.y;
2890
+ if (ov.y < 0) {
2891
+ rb.onGround = true;
2892
+ if (rb.friction < 1) rb.vx *= rb.friction;
2893
+ const delta = staticDelta.get(sid);
2894
+ if (delta) {
2895
+ transform.x += delta.dx;
2896
+ if (delta.dy < 0) transform.y += delta.dy;
2897
+ }
2898
+ }
2899
+ rb.vy = rb.bounce > 0 ? -rb.vy * rb.bounce : 0;
2900
+ }
2901
+ }
2902
+ }
2903
+ }
2904
+ }
2905
+ const POSITION_SLOP = 0.5;
2906
+ const CORRECTION_FACTOR = 0.4;
2640
2907
  const currentCollisionPairs = /* @__PURE__ */ new Map();
2641
2908
  for (let i = 0; i < dynamics.length; i++) {
2642
2909
  for (let j = i + 1; j < dynamics.length; j++) {
@@ -2667,10 +2934,14 @@ var PhysicsSystem = class {
2667
2934
  rba.onGround = true;
2668
2935
  }
2669
2936
  }
2670
- ta.x += ov.x / 2;
2671
- ta.y += ov.y / 2;
2672
- tb.x -= ov.x / 2;
2673
- tb.y -= ov.y / 2;
2937
+ const absOx = Math.abs(ov.x);
2938
+ const absOy = Math.abs(ov.y);
2939
+ const corrX = absOx > POSITION_SLOP ? Math.sign(ov.x) * (absOx - POSITION_SLOP) * CORRECTION_FACTOR : 0;
2940
+ const corrY = absOy > POSITION_SLOP ? Math.sign(ov.y) * (absOy - POSITION_SLOP) * CORRECTION_FACTOR : 0;
2941
+ ta.x += corrX / 2;
2942
+ ta.y += corrY / 2;
2943
+ tb.x -= corrX / 2;
2944
+ tb.y -= corrY / 2;
2674
2945
  const key = pairKey(ia, ib);
2675
2946
  currentCollisionPairs.set(key, [ia, ib]);
2676
2947
  }
@@ -2723,6 +2994,40 @@ var PhysicsSystem = class {
2723
2994
  }
2724
2995
  }
2725
2996
  }
2997
+ for (const id of capsuleDynamics) {
2998
+ const rb = world.getComponent(id, "RigidBody");
2999
+ if (rb.onGround) {
3000
+ rb.isNearGround = true;
3001
+ continue;
3002
+ }
3003
+ const transform = world.getComponent(id, "Transform");
3004
+ const cap = world.getComponent(id, "CapsuleCollider");
3005
+ const probeAABB = {
3006
+ cx: transform.x + cap.offsetX,
3007
+ cy: transform.y + cap.offsetY + 2,
3008
+ hw: cap.width / 2,
3009
+ hh: cap.height / 2
3010
+ };
3011
+ const candidateCells = this.getCells(probeAABB.cx, probeAABB.cy, probeAABB.hw, probeAABB.hh);
3012
+ const checked = /* @__PURE__ */ new Set();
3013
+ outerCapsule: for (const cell of candidateCells) {
3014
+ const bucket = staticGrid.get(cell);
3015
+ if (!bucket) continue;
3016
+ for (const sid of bucket) {
3017
+ if (checked.has(sid)) continue;
3018
+ checked.add(sid);
3019
+ const st = world.getComponent(sid, "Transform");
3020
+ const sc = world.getComponent(sid, "BoxCollider");
3021
+ if (sc.isTrigger) continue;
3022
+ if (!canInteractGeneric(cap.layer, cap.mask, sc.layer, sc.mask)) continue;
3023
+ const ov = getOverlap(probeAABB, getAABB(st, sc));
3024
+ if (ov && Math.abs(ov.y) <= Math.abs(ov.x) && ov.y < 0) {
3025
+ rb.isNearGround = true;
3026
+ break outerCapsule;
3027
+ }
3028
+ }
3029
+ }
3030
+ }
2726
3031
  const allWithCollider = world.query("Transform", "BoxCollider");
2727
3032
  const currentTriggerPairs = /* @__PURE__ */ new Map();
2728
3033
  for (let i = 0; i < allWithCollider.length; i++) {
@@ -2911,6 +3216,55 @@ var PhysicsSystem = class {
2911
3216
  }
2912
3217
  this.activeCompoundPairs = /* @__PURE__ */ new Map();
2913
3218
  }
3219
+ if (allCapsule.length > 0) {
3220
+ const currentCapsulePairs = /* @__PURE__ */ new Map();
3221
+ const allBoxForCapsule = world.query("Transform", "BoxCollider");
3222
+ for (const cid of allCapsule) {
3223
+ const cc = world.getComponent(cid, "CapsuleCollider");
3224
+ const ct = world.getComponent(cid, "Transform");
3225
+ const capsuleAABB = getCapsuleAABB(ct, cc);
3226
+ for (const bid of allBoxForCapsule) {
3227
+ if (bid === cid) continue;
3228
+ const bc = world.getComponent(bid, "BoxCollider");
3229
+ if (!canInteractGeneric(cc.layer, cc.mask, bc.layer, bc.mask)) continue;
3230
+ const bt = world.getComponent(bid, "Transform");
3231
+ const ov = getOverlap(capsuleAABB, getAABB(bt, bc));
3232
+ if (ov) currentCapsulePairs.set(pairKey(cid, bid), [cid, bid]);
3233
+ }
3234
+ }
3235
+ for (let i = 0; i < allCapsule.length; i++) {
3236
+ for (let j = i + 1; j < allCapsule.length; j++) {
3237
+ const ia = allCapsule[i];
3238
+ const ib = allCapsule[j];
3239
+ const ca = world.getComponent(ia, "CapsuleCollider");
3240
+ const cb = world.getComponent(ib, "CapsuleCollider");
3241
+ if (!canInteractGeneric(ca.layer, ca.mask, cb.layer, cb.mask)) continue;
3242
+ const ta = world.getComponent(ia, "Transform");
3243
+ const tb = world.getComponent(ib, "Transform");
3244
+ const ov = getOverlap(getCapsuleAABB(ta, ca), getCapsuleAABB(tb, cb));
3245
+ if (ov) currentCapsulePairs.set(pairKey(ia, ib), [ia, ib]);
3246
+ }
3247
+ }
3248
+ for (const [key, [a, b]] of currentCapsulePairs) {
3249
+ if (!this.activeCapsulePairs.has(key)) {
3250
+ this.events?.emit("capsuleEnter", { a, b });
3251
+ } else {
3252
+ this.events?.emit("capsuleStay", { a, b });
3253
+ }
3254
+ this.events?.emit("capsule", { a, b });
3255
+ }
3256
+ for (const [key, [a, b]] of this.activeCapsulePairs) {
3257
+ if (!currentCapsulePairs.has(key)) {
3258
+ this.events?.emit("capsuleExit", { a, b });
3259
+ }
3260
+ }
3261
+ this.activeCapsulePairs = currentCapsulePairs;
3262
+ } else if (this.activeCapsulePairs.size > 0) {
3263
+ for (const [, [a, b]] of this.activeCapsulePairs) {
3264
+ this.events?.emit("capsuleExit", { a, b });
3265
+ }
3266
+ this.activeCapsulePairs = /* @__PURE__ */ new Map();
3267
+ }
2914
3268
  }
2915
3269
  };
2916
3270
 
@@ -3928,7 +4282,7 @@ function Game({
3928
4282
  if (handle.buffer.length > MAX_DEVTOOLS_FRAMES) handle.buffer.shift();
3929
4283
  handle.onFrame?.();
3930
4284
  }
3931
- });
4285
+ }, deterministic ? { fixedDt: 1 / 60 } : void 0);
3932
4286
  const state = {
3933
4287
  ecs,
3934
4288
  input,
@@ -4019,6 +4373,12 @@ function Game({
4019
4373
  cancelled = true;
4020
4374
  };
4021
4375
  }, [engine]);
4376
+ useEffect3(() => {
4377
+ if (!engine) return;
4378
+ const canvas = engine.canvas;
4379
+ if (canvas.width !== width) canvas.width = width;
4380
+ if (canvas.height !== height) canvas.height = height;
4381
+ }, [width, height, engine]);
4022
4382
  useEffect3(() => {
4023
4383
  engine?.physics.setGravity(gravity);
4024
4384
  }, [gravity, engine]);
@@ -4139,7 +4499,7 @@ function Entity({ id, tags = [], children }) {
4139
4499
  setEntityId(eid);
4140
4500
  return () => {
4141
4501
  engine.ecs.destroyEntity(eid);
4142
- if (id) engine.entityIds.delete(id);
4502
+ if (id && engine.entityIds.get(id) === eid) engine.entityIds.delete(id);
4143
4503
  };
4144
4504
  }, []);
4145
4505
  if (entityId === null) return null;
@@ -4301,12 +4661,13 @@ function RigidBody({
4301
4661
  vx = 0,
4302
4662
  vy = 0,
4303
4663
  lockX = false,
4304
- lockY = false
4664
+ lockY = false,
4665
+ ccd = false
4305
4666
  }) {
4306
4667
  const engine = useContext7(EngineContext);
4307
4668
  const entityId = useContext7(EntityContext);
4308
4669
  useEffect9(() => {
4309
- engine.ecs.addComponent(entityId, createRigidBody({ mass, gravityScale, isStatic, bounce, friction, vx, vy, lockX, lockY }));
4670
+ engine.ecs.addComponent(entityId, createRigidBody({ mass, gravityScale, isStatic, bounce, friction, vx, vy, lockX, lockY, ccd }));
4310
4671
  return () => engine.ecs.removeComponent(entityId, "RigidBody");
4311
4672
  }, []);
4312
4673
  return null;
@@ -4406,19 +4767,24 @@ function CompoundCollider({
4406
4767
  }
4407
4768
 
4408
4769
  // src/components/Script.tsx
4409
- import { useEffect as useEffect14, useContext as useContext12 } from "react";
4770
+ import { useEffect as useEffect14, useContext as useContext12, useRef as useRef4 } from "react";
4410
4771
  function Script({ init, update }) {
4411
4772
  const engine = useContext12(EngineContext);
4412
4773
  const entityId = useContext12(EntityContext);
4774
+ const initRef = useRef4(init);
4775
+ initRef.current = init;
4776
+ const updateRef = useRef4(update);
4777
+ updateRef.current = update;
4413
4778
  useEffect14(() => {
4414
- if (init) {
4779
+ if (initRef.current) {
4415
4780
  try {
4416
- init(entityId, engine.ecs);
4781
+ initRef.current(entityId, engine.ecs);
4417
4782
  } catch (err) {
4418
4783
  console.error(`[Cubeforge] Script init error on entity ${entityId}:`, err);
4419
4784
  }
4420
4785
  }
4421
- engine.ecs.addComponent(entityId, createScript(update));
4786
+ const stableUpdate = (id, world, input, dt) => updateRef.current(id, world, input, dt);
4787
+ engine.ecs.addComponent(entityId, createScript(stableUpdate));
4422
4788
  return () => engine.ecs.removeComponent(entityId, "Script");
4423
4789
  }, []);
4424
4790
  return null;
@@ -4648,7 +5014,7 @@ function ParticleEmitter({
4648
5014
  }
4649
5015
 
4650
5016
  // src/components/VirtualJoystick.tsx
4651
- import { useRef as useRef4 } from "react";
5017
+ import { useRef as useRef5 } from "react";
4652
5018
 
4653
5019
  // src/hooks/useVirtualInput.ts
4654
5020
  var _axes = { x: 0, y: 0 };
@@ -4682,10 +5048,10 @@ function VirtualJoystick({
4682
5048
  actionLabel = "A",
4683
5049
  actionName = "action"
4684
5050
  }) {
4685
- const baseRef = useRef4(null);
4686
- const stickRef = useRef4(null);
4687
- const activePtr = useRef4(null);
4688
- const baseCenterRef = useRef4({ x: 0, y: 0 });
5051
+ const baseRef = useRef5(null);
5052
+ const stickRef = useRef5(null);
5053
+ const activePtr = useRef5(null);
5054
+ const baseCenterRef = useRef5({ x: 0, y: 0 });
4689
5055
  const radius = size / 2 - 16;
4690
5056
  const applyStickPosition = (dx, dy) => {
4691
5057
  if (!stickRef.current) return;
@@ -5164,10 +5530,10 @@ function ParallaxLayer({
5164
5530
  }
5165
5531
 
5166
5532
  // src/components/ScreenFlash.tsx
5167
- import { forwardRef, useImperativeHandle, useRef as useRef5 } from "react";
5533
+ import { forwardRef, useImperativeHandle, useRef as useRef6 } from "react";
5168
5534
  import { jsx as jsx10 } from "react/jsx-runtime";
5169
5535
  var ScreenFlash = forwardRef((_, ref) => {
5170
- const divRef = useRef5(null);
5536
+ const divRef = useRef6(null);
5171
5537
  useImperativeHandle(ref, () => ({
5172
5538
  flash(color, duration) {
5173
5539
  const el = divRef.current;
@@ -5203,7 +5569,7 @@ var ScreenFlash = forwardRef((_, ref) => {
5203
5569
  ScreenFlash.displayName = "ScreenFlash";
5204
5570
 
5205
5571
  // src/components/CameraZone.tsx
5206
- import { useEffect as useEffect21, useContext as useContext19, useRef as useRef6 } from "react";
5572
+ import { useEffect as useEffect21, useContext as useContext19, useRef as useRef7 } from "react";
5207
5573
  import { Fragment as Fragment5, jsx as jsx11 } from "react/jsx-runtime";
5208
5574
  function CameraZone({
5209
5575
  x,
@@ -5216,8 +5582,8 @@ function CameraZone({
5216
5582
  children
5217
5583
  }) {
5218
5584
  const engine = useContext19(EngineContext);
5219
- const prevFollowRef = useRef6(void 0);
5220
- const activeRef = useRef6(false);
5585
+ const prevFollowRef = useRef7(void 0);
5586
+ const activeRef = useRef7(false);
5221
5587
  useEffect21(() => {
5222
5588
  const eid = engine.ecs.createEntity();
5223
5589
  engine.ecs.addComponent(eid, createScript(() => {
@@ -5458,7 +5824,7 @@ function useInputMap(bindings) {
5458
5824
  }
5459
5825
 
5460
5826
  // src/hooks/useEvents.ts
5461
- import { useContext as useContext27, useEffect as useEffect26, useRef as useRef7 } from "react";
5827
+ import { useContext as useContext27, useEffect as useEffect26, useRef as useRef8 } from "react";
5462
5828
  function useEvents() {
5463
5829
  const engine = useContext27(EngineContext);
5464
5830
  if (!engine) throw new Error("useEvents must be used inside <Game>");
@@ -5466,7 +5832,7 @@ function useEvents() {
5466
5832
  }
5467
5833
  function useEvent(event, handler) {
5468
5834
  const events = useEvents();
5469
- const handlerRef = useRef7(handler);
5835
+ const handlerRef = useRef8(handler);
5470
5836
  handlerRef.current = handler;
5471
5837
  useEffect26(() => {
5472
5838
  return events.on(event, (data) => handlerRef.current(data));
@@ -5544,11 +5910,11 @@ function useInputRecorder() {
5544
5910
  }
5545
5911
 
5546
5912
  // src/hooks/useGamepad.ts
5547
- import { useEffect as useEffect28, useRef as useRef8, useState as useState7 } from "react";
5913
+ import { useEffect as useEffect28, useRef as useRef9, useState as useState7 } from "react";
5548
5914
  var EMPTY_STATE = { connected: false, axes: [], buttons: [] };
5549
5915
  function useGamepad(playerIndex = 0) {
5550
5916
  const [state, setState] = useState7(EMPTY_STATE);
5551
- const rafRef = useRef8(0);
5917
+ const rafRef = useRef9(0);
5552
5918
  useEffect28(() => {
5553
5919
  const poll = () => {
5554
5920
  const gp = navigator.getGamepads()[playerIndex];
@@ -5662,12 +6028,12 @@ function useDropThrough(frames = 8) {
5662
6028
  }
5663
6029
 
5664
6030
  // ../gameplay/src/hooks/useGameStateMachine.ts
5665
- import { useState as useState10, useRef as useRef9, useCallback as useCallback8, useEffect as useEffect29, useContext as useContext32 } from "react";
6031
+ import { useState as useState10, useRef as useRef10, useCallback as useCallback8, useEffect as useEffect29, useContext as useContext32 } from "react";
5666
6032
  function useGameStateMachine(states, initial) {
5667
6033
  const engine = useContext32(EngineContext);
5668
6034
  const [state, setState] = useState10(initial);
5669
- const stateRef = useRef9(initial);
5670
- const statesRef = useRef9(states);
6035
+ const stateRef = useRef10(initial);
6036
+ const statesRef = useRef10(states);
5671
6037
  statesRef.current = states;
5672
6038
  useEffect29(() => {
5673
6039
  statesRef.current[initial]?.onEnter?.();
@@ -5693,22 +6059,22 @@ function useGameStateMachine(states, initial) {
5693
6059
  }
5694
6060
 
5695
6061
  // ../gameplay/src/hooks/useHealth.ts
5696
- import { useRef as useRef10, useEffect as useEffect30, useContext as useContext33, useCallback as useCallback9 } from "react";
6062
+ import { useRef as useRef11, useEffect as useEffect30, useContext as useContext33, useCallback as useCallback9 } from "react";
5697
6063
  function useHealth(maxHp, opts = {}) {
5698
6064
  const engine = useContext33(EngineContext);
5699
6065
  const entityId = useContext33(EntityContext);
5700
- const hpRef = useRef10(maxHp);
5701
- const invincibleRef = useRef10(false);
6066
+ const hpRef = useRef11(maxHp);
6067
+ const invincibleRef = useRef11(false);
5702
6068
  const iFrameDuration = opts.iFrames ?? 1;
5703
- const onDeathRef = useRef10(opts.onDeath);
5704
- const onDamageRef = useRef10(opts.onDamage);
6069
+ const onDeathRef = useRef11(opts.onDeath);
6070
+ const onDamageRef = useRef11(opts.onDamage);
5705
6071
  useEffect30(() => {
5706
6072
  onDeathRef.current = opts.onDeath;
5707
6073
  });
5708
6074
  useEffect30(() => {
5709
6075
  onDamageRef.current = opts.onDamage;
5710
6076
  });
5711
- const timerRef = useRef10(
6077
+ const timerRef = useRef11(
5712
6078
  createTimer(iFrameDuration, () => {
5713
6079
  invincibleRef.current = false;
5714
6080
  })
@@ -5723,7 +6089,7 @@ function useHealth(maxHp, opts = {}) {
5723
6089
  }
5724
6090
  if (hpRef.current <= 0) onDeathRef.current?.();
5725
6091
  }, [iFrameDuration]);
5726
- const takeDamageRef = useRef10(takeDamage);
6092
+ const takeDamageRef = useRef11(takeDamage);
5727
6093
  useEffect30(() => {
5728
6094
  takeDamageRef.current = takeDamage;
5729
6095
  }, [takeDamage]);
@@ -5810,11 +6176,11 @@ function useKinematicBody() {
5810
6176
  }
5811
6177
 
5812
6178
  // ../gameplay/src/hooks/useLevelTransition.ts
5813
- import { useState as useState11, useRef as useRef11, useCallback as useCallback11 } from "react";
6179
+ import { useState as useState11, useRef as useRef12, useCallback as useCallback11 } from "react";
5814
6180
  function useLevelTransition(initial) {
5815
6181
  const [currentLevel, setCurrentLevel] = useState11(initial);
5816
6182
  const [isTransitioning, setIsTransitioning] = useState11(false);
5817
- const overlayRef = useRef11(null);
6183
+ const overlayRef = useRef12(null);
5818
6184
  const transitionTo = useCallback11((level, opts = {}) => {
5819
6185
  const { duration = 0.4, type = "fade" } = opts;
5820
6186
  if (type === "instant") {
@@ -5993,10 +6359,10 @@ function useRestart() {
5993
6359
  }
5994
6360
 
5995
6361
  // ../gameplay/src/hooks/useSave.ts
5996
- import { useCallback as useCallback15, useRef as useRef12 } from "react";
6362
+ import { useCallback as useCallback15, useRef as useRef13 } from "react";
5997
6363
  function useSave(key, defaultValue, opts = {}) {
5998
6364
  const version = opts.version ?? 1;
5999
- const dataRef = useRef12(defaultValue);
6365
+ const dataRef = useRef13(defaultValue);
6000
6366
  const save = useCallback15((value) => {
6001
6367
  dataRef.current = value;
6002
6368
  const slot = { version, data: value };
@@ -6071,11 +6437,11 @@ function useTopDownMovement(entityId, opts = {}) {
6071
6437
  }
6072
6438
 
6073
6439
  // ../gameplay/src/hooks/useDialogue.ts
6074
- import { useState as useState14, useCallback as useCallback16, useRef as useRef13 } from "react";
6440
+ import { useState as useState14, useCallback as useCallback16, useRef as useRef14 } from "react";
6075
6441
  function useDialogue() {
6076
6442
  const [active, setActive] = useState14(false);
6077
6443
  const [currentId, setCurrentId] = useState14(null);
6078
- const scriptRef = useRef13(null);
6444
+ const scriptRef = useRef14(null);
6079
6445
  const start = useCallback16((script, startId) => {
6080
6446
  scriptRef.current = script;
6081
6447
  const id = startId ?? Object.keys(script)[0];
@@ -6119,16 +6485,16 @@ function useDialogue() {
6119
6485
  }
6120
6486
 
6121
6487
  // ../gameplay/src/hooks/useCutscene.ts
6122
- import { useState as useState15, useCallback as useCallback17, useRef as useRef14, useEffect as useEffect33, useContext as useContext38 } from "react";
6488
+ import { useState as useState15, useCallback as useCallback17, useRef as useRef15, useEffect as useEffect33, useContext as useContext38 } from "react";
6123
6489
  function useCutscene() {
6124
6490
  const engine = useContext38(EngineContext);
6125
6491
  const [playing, setPlaying] = useState15(false);
6126
6492
  const [stepIndex, setStepIndex] = useState15(0);
6127
- const stepsRef = useRef14([]);
6128
- const timerRef = useRef14(0);
6129
- const idxRef = useRef14(0);
6130
- const playingRef = useRef14(false);
6131
- const entityRef = useRef14(null);
6493
+ const stepsRef = useRef15([]);
6494
+ const timerRef = useRef15(0);
6495
+ const idxRef = useRef15(0);
6496
+ const playingRef = useRef15(false);
6497
+ const entityRef = useRef15(null);
6132
6498
  const finish = useCallback17(() => {
6133
6499
  playingRef.current = false;
6134
6500
  setPlaying(false);
@@ -6253,7 +6619,7 @@ function useGameStore(key, initialState) {
6253
6619
  }
6254
6620
 
6255
6621
  // ../../packages/audio/src/useSound.ts
6256
- import { useEffect as useEffect34, useRef as useRef15 } from "react";
6622
+ import { useEffect as useEffect34, useRef as useRef16 } from "react";
6257
6623
  var _audioCtx = null;
6258
6624
  function getAudioCtx() {
6259
6625
  if (!_audioCtx) _audioCtx = new AudioContext();
@@ -6320,12 +6686,12 @@ async function loadBuffer(src) {
6320
6686
  return buf;
6321
6687
  }
6322
6688
  function useSound(src, opts = {}) {
6323
- const bufferRef = useRef15(null);
6324
- const sourceRef = useRef15(null);
6325
- const gainRef = useRef15(null);
6326
- const volRef = useRef15(opts.volume ?? 1);
6327
- const loopRef = useRef15(opts.loop ?? false);
6328
- const groupRef = useRef15(opts.group);
6689
+ const bufferRef = useRef16(null);
6690
+ const sourceRef = useRef16(null);
6691
+ const gainRef = useRef16(null);
6692
+ const volRef = useRef16(opts.volume ?? 1);
6693
+ const loopRef = useRef16(opts.loop ?? false);
6694
+ const groupRef = useRef16(opts.group);
6329
6695
  useEffect34(() => {
6330
6696
  let cancelled = false;
6331
6697
  loadBuffer(src).then((buf) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cubeforge",
3
- "version": "0.3.13",
3
+ "version": "0.3.14",
4
4
  "description": "React-first 2D browser game engine",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",